From 0c69c43eadfaeb042ffa5acfffb44e2837471bfd Mon Sep 17 00:00:00 2001 From: Alejandro Rojas Date: Wed, 8 Nov 2023 22:30:44 -0500 Subject: [PATCH 001/223] add source node Local and VirtualEnv operator --- cosmos/operators/local.py | 12 ++++++++++++ cosmos/operators/virtualenv.py | 8 ++++++++ 2 files changed, 20 insertions(+) diff --git a/cosmos/operators/local.py b/cosmos/operators/local.py index 1c00f476c8..0f5092091e 100644 --- a/cosmos/operators/local.py +++ b/cosmos/operators/local.py @@ -742,3 +742,15 @@ def __init__(self, **kwargs: str) -> None: raise DeprecationWarning( "The DbtDepsOperator has been deprecated. " "Please use the `install_deps` flag in dbt_args instead." ) + + +class DbtSourceLocalOperator(DbtLocalBaseOperator): + """ + Executes a dbt source freshness command. + """ + + ui_color = "#34CCEB" + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + self.base_cmd = ["source", "freshness"] diff --git a/cosmos/operators/virtualenv.py b/cosmos/operators/virtualenv.py index 4d6338e095..f7f319f551 100644 --- a/cosmos/operators/virtualenv.py +++ b/cosmos/operators/virtualenv.py @@ -18,6 +18,7 @@ DbtSeedLocalOperator, DbtSnapshotLocalOperator, DbtTestLocalOperator, + DbtSourceLocalOperator, ) if TYPE_CHECKING: @@ -145,3 +146,10 @@ class DbtDocsVirtualenvOperator(DbtVirtualenvBaseOperator, DbtDocsLocalOperator) Executes `dbt docs generate` command within a Python Virtual Environment, that is created before running the dbt command and deleted just after. """ + + +class DbtSourceVirtualenvOperator(DbtVirtualenvBaseOperator, DbtSourceLocalOperator): + """ + Executes `dbt source freshness` command within a Python Virtual Environment, that is created before running the dbt + command and deleted just after. + """ From ee4722e41a0feab707b4c425055c8055ca2395f1 Mon Sep 17 00:00:00 2001 From: Alejandro Rojas Date: Wed, 8 Nov 2023 22:31:43 -0500 Subject: [PATCH 002/223] change source node creation --- cosmos/airflow/graph.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/cosmos/airflow/graph.py b/cosmos/airflow/graph.py index 3a140a2357..98c6c57816 100644 --- a/cosmos/airflow/graph.py +++ b/cosmos/airflow/graph.py @@ -117,6 +117,7 @@ def create_task_metadata( DbtResourceType.SNAPSHOT: "DbtSnapshot", DbtResourceType.SEED: "DbtSeed", DbtResourceType.TEST: "DbtTest", + DbtResourceType.SOURCE: "DbtSource", } args = {**args, **{"models": node.name}} @@ -125,6 +126,18 @@ def create_task_metadata( task_id = f"{node.name}_run" if use_task_group is True: task_id = "run" + if node.resource_type == DbtResourceType.SOURCE: + task_full_name = node.unique_id[len("source.") :] + task_id = f"{task_full_name}_source" + args["select"] = f"source:{node.unique_id[len('source.'):]}" + args["models"] = None + if use_task_group is True: + task_id = node.resource_type.value + if node.has_freshness is False: + return TaskMetadata( + id=task_id, + # arguments=args, + ) else: task_id = f"{node.name}_{node.resource_type.value}" if use_task_group is True: From 99d4be756d39cc051074f5b9bbc21e6dd1671d80 Mon Sep 17 00:00:00 2001 From: Alejandro Rojas Date: Wed, 8 Nov 2023 22:32:34 -0500 Subject: [PATCH 003/223] add freshness variable to dbtNode --- cosmos/dbt/graph.py | 44 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/cosmos/dbt/graph.py b/cosmos/dbt/graph.py index 0322c8ac4a..15bdbcac7f 100644 --- a/cosmos/dbt/graph.py +++ b/cosmos/dbt/graph.py @@ -40,7 +40,7 @@ class CosmosLoadDbtException(Exception): @dataclass class DbtNode: """ - Metadata related to a dbt node (e.g. model, seed, snapshot). + Metadata related to a dbt node (e.g. model, seed, snapshot, source). """ name: str @@ -50,6 +50,7 @@ class DbtNode: file_path: Path tags: list[str] = field(default_factory=lambda: []) config: dict[str, Any] = field(default_factory=lambda: {}) + has_freshness: bool = False has_test: bool = False @@ -61,6 +62,32 @@ def create_symlinks(project_path: Path, tmp_dir: Path) -> None: os.symlink(project_path / child_name, tmp_dir / child_name) +def is_freshness_effective(freshness: dict[str, Any]) -> bool: + """Function to find if a source has null freshness. Scenarios where freshness + looks like: + "freshness": { + "warn_after": { + "count": null, + "period": null + }, + "error_after": { + "count": null, + "period": null + }, + "filter": null + } + should be considered as null, this function ensures that.""" + if freshness is None: + return False + for key, value in freshness.items(): + if isinstance(value, dict): + if any(subvalue is not None for subvalue in value.values()): + return True + elif value is not None: + return True + return False + + def run_command(command: list[str], tmp_dir: Path, env_vars: dict[str, str]) -> str: """Run a command in a subprocess, returning the stdout.""" logger.info("Running command: `%s`", " ".join(command)) @@ -105,6 +132,9 @@ def parse_dbt_ls_output(project_path: Path, ls_stdout: str) -> dict[str, DbtNode file_path=project_path / node_dict["original_file_path"], tags=node_dict["tags"], config=node_dict["config"], + has_freshness=is_freshness_effective(node_dict.get("freshness")) + if node_dict["resource_type"] == "source" + else False, ) nodes[node.unique_id] = node logger.debug("Parsed dbt resource `%s` of type `%s`", node.unique_id, node.resource_type) @@ -185,7 +215,14 @@ def load( def run_dbt_ls(self, project_path: Path, tmp_dir: Path, env_vars: dict[str, str]) -> dict[str, DbtNode]: """Runs dbt ls command and returns the parsed nodes.""" - ls_command = [self.dbt_cmd, "ls", "--output", "json"] + ls_command = [ + self.dbt_cmd, + "ls", + "--output", + "json", + "--output-keys", + "name alias unique_id resource_type depends_on original_file_path tags config freshness", + ] if self.render_config.exclude: ls_command.extend(["--exclude", *self.render_config.exclude]) @@ -371,6 +408,9 @@ def load_from_dbt_manifest(self) -> None: file_path=self.execution_config.project_path / Path(node_dict["original_file_path"]), tags=node_dict["tags"], config=node_dict["config"], + has_freshness=node_dict["freshness"] is not None + if node_dict["resource_type"] == "source" and "freshness" in node_dict + else False, ) nodes[node.unique_id] = node From d746b8173d0a27c3d72e4d2f81ebf358b7365a3e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 6 Nov 2023 15:39:55 -0500 Subject: [PATCH 004/223] =?UTF-8?q?=E2=AC=86=20[pre-commit.ci]=20pre-commi?= =?UTF-8?q?t=20autoupdate=20(#653)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.1.3 → v0.1.4](https://github.com/astral-sh/ruff-pre-commit/compare/v0.1.3...v0.1.4) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 53f80df2f7..c46e316343 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -53,7 +53,7 @@ repos: - --py37-plus - --keep-runtime-typing - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.3 + rev: v0.1.4 hooks: - id: ruff args: From 5e8801243260794cad2e982dc8ef374c53451735 Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Mon, 6 Nov 2023 15:22:54 +0000 Subject: [PATCH 005/223] Update example DAG for manifest rendering (cherry picked from commit a6cea8f8f2d8519cbd93ac457e0382e39e682977) --- dev/dags/cosmos_manifest_example.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dev/dags/cosmos_manifest_example.py b/dev/dags/cosmos_manifest_example.py index a00f9e75b7..c94ea41a2e 100644 --- a/dev/dags/cosmos_manifest_example.py +++ b/dev/dags/cosmos_manifest_example.py @@ -6,7 +6,7 @@ from datetime import datetime from pathlib import Path -from cosmos import DbtDag, ProjectConfig, ProfileConfig, RenderConfig, LoadMode +from cosmos import DbtDag, ProjectConfig, ProfileConfig, RenderConfig, LoadMode, ExecutionConfig from cosmos.profiles import PostgresUserPasswordProfileMapping DEFAULT_DBT_ROOT_PATH = Path(__file__).parent / "dbt" @@ -25,12 +25,12 @@ cosmos_manifest_example = DbtDag( # dbt/cosmos-specific parameters project_config=ProjectConfig( - dbt_project_path=DBT_ROOT_PATH / "jaffle_shop", manifest_path=DBT_ROOT_PATH / "jaffle_shop" / "target" / "manifest.json", project_name="jaffle_shop", ), profile_config=profile_config, render_config=RenderConfig(load_method=LoadMode.DBT_MANIFEST, select=["path:seeds/raw_customers.csv"]), + execution_config=ExecutionConfig(dbt_project_path=DBT_ROOT_PATH / "jaffle_shop"), operator_args={"install_deps": True}, # normal dag parameters schedule_interval="@daily", From a655e67090b17a98391cb004300ae8162aacc1a1 Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Mon, 6 Nov 2023 15:30:16 +0000 Subject: [PATCH 006/223] Release 1.2.2 Bug fixes * Support ProjectConfig.dbt_project_path = None & different paths for Rendering and Execution by @MrBones757 in #634 * Fix adding test nodes to DAGs built using LoadMethod.DBT_MANIFEST and LoadMethod.CUSTOM by @edgga in #615 Others * Add pre-commit hook for McCabe max complexity check and fix errors by @jbandoro in #629 * Update contributing docs for running integration tests by @jbandoro in #638 * Fix CI issue running integration tests by @tatiana in #640 and #644 * pre-commit updates in #637 (cherry picked from commit fa0620a195a27881cdccd1fc4416e210b2038ab8) --- CHANGELOG.rst | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d8b2bd8e15..f6c714f79b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -11,6 +11,22 @@ Features * Add ``DbtDocsGCSOperator`` for uploading dbt docs to GCS by @jbandoro in #616 +1.2.2 (2023-11-06) +------------------ + +Bug fixes + +* Support ``ProjectConfig.dbt_project_path = None`` & different paths for Rendering and Execution by @MrBones757 in #634 +* Fix adding test nodes to DAGs built using ``LoadMethod.DBT_MANIFEST`` and ``LoadMethod.CUSTOM`` by @edgga in #615 + +Others + +* Add pre-commit hook for McCabe max complexity check and fix errors by @jbandoro in #629 +* Update contributing docs for running integration tests by @jbandoro in #638 +* Fix CI issue running integration tests by @tatiana in #640 and #644 +* pre-commit updates in #637 + + 1.2.1 (2023-10-25) ------------------ From 8897f79f4521b198fa01f3c45de80e53fc21e6f3 Mon Sep 17 00:00:00 2001 From: DanMawdsleyBA Date: Sat, 4 Nov 2023 10:56:05 +0000 Subject: [PATCH 007/223] Support for Snowflake encrypted private key environment variable (#649) Adds a snowflake mapping for encrypted private key using an environment variable Closes: #632 Breaking Change? This does rename the previous SnowflakeEncryptedPrivateKeyFilePemProfileMapping to SnowflakeEncryptedPrivateKeyFilePemProfileMapping but this makes it clearer as a new SnowflakeEncryptedPrivateKeyPemProfileMapping is added which supports the env variable. Also was only released as a pre-release change --- cosmos/profiles/__init__.py | 5 +- cosmos/profiles/snowflake/__init__.py | 4 +- .../user_encrypted_privatekey_env_variable.py | 93 ++++++++ ...y.py => user_encrypted_privatekey_file.py} | 13 +- cosmos/profiles/snowflake/user_pass.py | 5 +- ...user_encrypted_privatekey_env_variable.py} | 15 +- ...nowflake_user_encrypted_privatekey_file.py | 216 ++++++++++++++++++ 7 files changed, 338 insertions(+), 13 deletions(-) create mode 100644 cosmos/profiles/snowflake/user_encrypted_privatekey_env_variable.py rename cosmos/profiles/snowflake/{user_encrypted_privatekey.py => user_encrypted_privatekey_file.py} (85%) rename tests/profiles/snowflake/{test_snowflake_user_encrypted_privatekey.py => test_snowflake_user_encrypted_privatekey_env_variable.py} (92%) create mode 100644 tests/profiles/snowflake/test_snowflake_user_encrypted_privatekey_file.py diff --git a/cosmos/profiles/__init__.py b/cosmos/profiles/__init__.py index 47c7309abc..8280cd9508 100644 --- a/cosmos/profiles/__init__.py +++ b/cosmos/profiles/__init__.py @@ -16,7 +16,8 @@ from .redshift.user_pass import RedshiftUserPasswordProfileMapping from .snowflake.user_pass import SnowflakeUserPasswordProfileMapping from .snowflake.user_privatekey import SnowflakePrivateKeyPemProfileMapping -from .snowflake.user_encrypted_privatekey import SnowflakeEncryptedPrivateKeyPemProfileMapping +from .snowflake.user_encrypted_privatekey_file import SnowflakeEncryptedPrivateKeyFilePemProfileMapping +from .snowflake.user_encrypted_privatekey_env_variable import SnowflakeEncryptedPrivateKeyPemProfileMapping from .spark.thrift import SparkThriftProfileMapping from .trino.certificate import TrinoCertificateProfileMapping from .trino.jwt import TrinoJWTProfileMapping @@ -32,6 +33,7 @@ PostgresUserPasswordProfileMapping, RedshiftUserPasswordProfileMapping, SnowflakeUserPasswordProfileMapping, + SnowflakeEncryptedPrivateKeyFilePemProfileMapping, SnowflakeEncryptedPrivateKeyPemProfileMapping, SnowflakePrivateKeyPemProfileMapping, SparkThriftProfileMapping, @@ -71,6 +73,7 @@ def get_automatic_profile_mapping( "RedshiftUserPasswordProfileMapping", "SnowflakeUserPasswordProfileMapping", "SnowflakePrivateKeyPemProfileMapping", + "SnowflakeEncryptedPrivateKeyFilePemProfileMapping", "SparkThriftProfileMapping", "ExasolUserPasswordProfileMapping", "TrinoLDAPProfileMapping", diff --git a/cosmos/profiles/snowflake/__init__.py b/cosmos/profiles/snowflake/__init__.py index 26c3fb5954..fdf323a766 100644 --- a/cosmos/profiles/snowflake/__init__.py +++ b/cosmos/profiles/snowflake/__init__.py @@ -2,10 +2,12 @@ from .user_pass import SnowflakeUserPasswordProfileMapping from .user_privatekey import SnowflakePrivateKeyPemProfileMapping -from .user_encrypted_privatekey import SnowflakeEncryptedPrivateKeyPemProfileMapping +from .user_encrypted_privatekey_file import SnowflakeEncryptedPrivateKeyFilePemProfileMapping +from .user_encrypted_privatekey_env_variable import SnowflakeEncryptedPrivateKeyPemProfileMapping __all__ = [ "SnowflakeUserPasswordProfileMapping", "SnowflakePrivateKeyPemProfileMapping", + "SnowflakeEncryptedPrivateKeyFilePemProfileMapping", "SnowflakeEncryptedPrivateKeyPemProfileMapping", ] diff --git a/cosmos/profiles/snowflake/user_encrypted_privatekey_env_variable.py b/cosmos/profiles/snowflake/user_encrypted_privatekey_env_variable.py new file mode 100644 index 0000000000..fecfa97fe7 --- /dev/null +++ b/cosmos/profiles/snowflake/user_encrypted_privatekey_env_variable.py @@ -0,0 +1,93 @@ +"Maps Airflow Snowflake connections to dbt profiles if they use a user/private key." +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +from ..base import BaseProfileMapping + +if TYPE_CHECKING: + from airflow.models import Connection + + +class SnowflakeEncryptedPrivateKeyPemProfileMapping(BaseProfileMapping): + """ + Maps Airflow Snowflake connections to dbt profiles if they use a user/private key. + https://docs.getdbt.com/docs/core/connect-data-platform/snowflake-setup#key-pair-authentication + https://airflow.apache.org/docs/apache-airflow-providers-snowflake/stable/connections/snowflake.html + """ + + airflow_connection_type: str = "snowflake" + dbt_profile_type: str = "snowflake" + is_community: bool = True + + required_fields = [ + "account", + "user", + "database", + "warehouse", + "schema", + "private_key", + "private_key_passphrase", + ] + secret_fields = [ + "private_key", + "private_key_passphrase", + ] + airflow_param_mapping = { + "account": "extra.account", + "user": "login", + "database": "extra.database", + "warehouse": "extra.warehouse", + "schema": "schema", + "role": "extra.role", + "private_key": "extra.private_key_content", + "private_key_passphrase": "password", + } + + def can_claim_connection(self) -> bool: + # Make sure this isn't a private key path credential + result = super().can_claim_connection() + if result and self.conn.extra_dejson.get("private_key_file") is not None: + return False + return result + + @property + def conn(self) -> Connection: + """ + Snowflake can be odd because the fields used to be stored with keys in the format + 'extra__snowflake__account', but now are stored as 'account'. + + This standardizes the keys to be 'account', 'database', etc. + """ + conn = super().conn + + conn_dejson = conn.extra_dejson + + if conn_dejson.get("extra__snowflake__account"): + conn_dejson = {key.replace("extra__snowflake__", ""): value for key, value in conn_dejson.items()} + + conn.extra = json.dumps(conn_dejson) + + return conn + + @property + def profile(self) -> dict[str, Any | None]: + "Gets profile." + profile_vars = { + **self.mapped_params, + **self.profile_args, + "private_key": self.get_env_var_format("private_key"), + "private_key_passphrase": self.get_env_var_format("private_key_passphrase"), + } + + # remove any null values + return self.filter_null(profile_vars) + + def transform_account(self, account: str) -> str: + "Transform the account to the format . if it's not already." + region = self.conn.extra_dejson.get("region") + if region and region not in account: + account = f"{account}.{region}" + + return str(account) diff --git a/cosmos/profiles/snowflake/user_encrypted_privatekey.py b/cosmos/profiles/snowflake/user_encrypted_privatekey_file.py similarity index 85% rename from cosmos/profiles/snowflake/user_encrypted_privatekey.py rename to cosmos/profiles/snowflake/user_encrypted_privatekey_file.py index 0623598be6..6831cbd280 100644 --- a/cosmos/profiles/snowflake/user_encrypted_privatekey.py +++ b/cosmos/profiles/snowflake/user_encrypted_privatekey_file.py @@ -1,4 +1,4 @@ -"Maps Airflow Snowflake connections to dbt profiles if they use a user/private key." +"Maps Airflow Snowflake connections to dbt profiles if they use a user/private key path." from __future__ import annotations import json @@ -10,9 +10,9 @@ from airflow.models import Connection -class SnowflakeEncryptedPrivateKeyPemProfileMapping(BaseProfileMapping): +class SnowflakeEncryptedPrivateKeyFilePemProfileMapping(BaseProfileMapping): """ - Maps Airflow Snowflake connections to dbt profiles if they use a user/private key. + Maps Airflow Snowflake connections to dbt profiles if they use a user/private key path. https://docs.getdbt.com/docs/core/connect-data-platform/snowflake-setup#key-pair-authentication https://airflow.apache.org/docs/apache-airflow-providers-snowflake/stable/connections/snowflake.html """ @@ -44,6 +44,13 @@ class SnowflakeEncryptedPrivateKeyPemProfileMapping(BaseProfileMapping): "private_key_path": "extra.private_key_file", } + def can_claim_connection(self) -> bool: + # Make sure this isn't a private key environmentvariable + result = super().can_claim_connection() + if result and self.conn.extra_dejson.get("private_key_content") is not None: + return False + return result + @property def conn(self) -> Connection: """ diff --git a/cosmos/profiles/snowflake/user_pass.py b/cosmos/profiles/snowflake/user_pass.py index 2e1025a2c6..fa634d1a2e 100644 --- a/cosmos/profiles/snowflake/user_pass.py +++ b/cosmos/profiles/snowflake/user_pass.py @@ -44,7 +44,10 @@ class SnowflakeUserPasswordProfileMapping(BaseProfileMapping): def can_claim_connection(self) -> bool: # Make sure this isn't a private key path credential result = super().can_claim_connection() - if result and self.conn.extra_dejson.get("private_key_file") is not None: + if result and ( + self.conn.extra_dejson.get("private_key_file") is not None + or self.conn.extra_dejson.get("private_key_content") is not None + ): return False return result diff --git a/tests/profiles/snowflake/test_snowflake_user_encrypted_privatekey.py b/tests/profiles/snowflake/test_snowflake_user_encrypted_privatekey_env_variable.py similarity index 92% rename from tests/profiles/snowflake/test_snowflake_user_encrypted_privatekey.py rename to tests/profiles/snowflake/test_snowflake_user_encrypted_privatekey_env_variable.py index b61b85094b..2c7515f72f 100644 --- a/tests/profiles/snowflake/test_snowflake_user_encrypted_privatekey.py +++ b/tests/profiles/snowflake/test_snowflake_user_encrypted_privatekey_env_variable.py @@ -1,4 +1,4 @@ -"Tests for the Snowflake user/private key profile." +"Tests for the Snowflake user/private key environmentvariable profile." import json from unittest.mock import patch @@ -29,7 +29,7 @@ def mock_snowflake_conn(): # type: ignore "region": "my_region", "database": "my_database", "warehouse": "my_warehouse", - "private_key_file": "path/to/private_key.p8", + "private_key_content": "my_private_key", } ), ) @@ -52,7 +52,7 @@ def test_connection_claiming() -> None: "account": "my_account", "database": "my_database", "warehouse": "my_warehouse", - "private_key_file": "path/to/private_key.p8", + "private_key_content": "my_private_key", } ), } @@ -130,8 +130,8 @@ def test_profile_args( assert profile_mapping.profile == { "type": mock_snowflake_conn.conn_type, "user": mock_snowflake_conn.login, + "private_key": "{{ env_var('COSMOS_CONN_SNOWFLAKE_PRIVATE_KEY') }}", "private_key_passphrase": "{{ env_var('COSMOS_CONN_SNOWFLAKE_PRIVATE_KEY_PASSPHRASE') }}", - "private_key_path": mock_snowflake_conn.extra_dejson.get("private_key_file"), "schema": mock_snowflake_conn.schema, "account": f"{mock_account}.{mock_region}", "database": mock_snowflake_conn.extra_dejson.get("database"), @@ -160,7 +160,7 @@ def test_profile_args_overrides( "type": mock_snowflake_conn.conn_type, "user": mock_snowflake_conn.login, "private_key_passphrase": "{{ env_var('COSMOS_CONN_SNOWFLAKE_PRIVATE_KEY_PASSPHRASE') }}", - "private_key_path": mock_snowflake_conn.extra_dejson.get("private_key_file"), + "private_key": "{{ env_var('COSMOS_CONN_SNOWFLAKE_PRIVATE_KEY') }}", "schema": mock_snowflake_conn.schema, "account": f"{mock_account}.{mock_region}", "database": "my_db_override", @@ -178,6 +178,7 @@ def test_profile_env_vars( mock_snowflake_conn.conn_id, ) assert profile_mapping.env_vars == { + "COSMOS_CONN_SNOWFLAKE_PRIVATE_KEY": mock_snowflake_conn.extra_dejson.get("private_key_content"), "COSMOS_CONN_SNOWFLAKE_PRIVATE_KEY_PASSPHRASE": mock_snowflake_conn.password, } @@ -197,7 +198,7 @@ def test_old_snowflake_format() -> None: "extra__snowflake__account": "my_account", "extra__snowflake__database": "my_database", "extra__snowflake__warehouse": "my_warehouse", - "extra__snowflake__private_key_file": "path/to/private_key.p8", + "extra__snowflake__private_key_content": "my_private_key", } ), ) @@ -207,8 +208,8 @@ def test_old_snowflake_format() -> None: assert profile_mapping.profile == { "type": conn.conn_type, "user": conn.login, + "private_key": "{{ env_var('COSMOS_CONN_SNOWFLAKE_PRIVATE_KEY') }}", "private_key_passphrase": "{{ env_var('COSMOS_CONN_SNOWFLAKE_PRIVATE_KEY_PASSPHRASE') }}", - "private_key_path": conn.extra_dejson.get("private_key_file"), "schema": conn.schema, "account": conn.extra_dejson.get("account"), "database": conn.extra_dejson.get("database"), diff --git a/tests/profiles/snowflake/test_snowflake_user_encrypted_privatekey_file.py b/tests/profiles/snowflake/test_snowflake_user_encrypted_privatekey_file.py new file mode 100644 index 0000000000..d8c3aedcf6 --- /dev/null +++ b/tests/profiles/snowflake/test_snowflake_user_encrypted_privatekey_file.py @@ -0,0 +1,216 @@ +"Tests for the Snowflake user/private key file profile." + +import json +from unittest.mock import patch + +import pytest +from airflow.models.connection import Connection + +from cosmos.profiles import get_automatic_profile_mapping +from cosmos.profiles.snowflake import ( + SnowflakeEncryptedPrivateKeyFilePemProfileMapping, +) + + +@pytest.fixture() +def mock_snowflake_conn(): # type: ignore + """ + Sets the connection as an environment variable. + """ + conn = Connection( + conn_id="my_snowflake_pk_connection", + conn_type="snowflake", + login="my_user", + schema="my_schema", + password="secret", + extra=json.dumps( + { + "account": "my_account", + "region": "my_region", + "database": "my_database", + "warehouse": "my_warehouse", + "private_key_file": "path/to/private_key.p8", + } + ), + ) + + with patch("airflow.hooks.base.BaseHook.get_connection", return_value=conn): + yield conn + + +def test_connection_claiming() -> None: + """ + Tests that the Snowflake profile mapping claims the correct connection type. + """ + potential_values = { + "conn_type": "snowflake", + "login": "my_user", + "schema": "my_database", + "password": "secret", + "extra": json.dumps( + { + "account": "my_account", + "database": "my_database", + "warehouse": "my_warehouse", + "private_key_file": "path/to/private_key.p8", + } + ), + } + + # if we're missing any of the values, it shouldn't claim + for key in potential_values: + values = potential_values.copy() + del values[key] + conn = Connection(**values) # type: ignore + + print("testing with", values) + + with patch("airflow.hooks.base.BaseHook.get_connection", return_value=conn): + profile_mapping = SnowflakeEncryptedPrivateKeyFilePemProfileMapping( + conn, + ) + assert not profile_mapping.can_claim_connection() + + # test when we're missing the account + conn = Connection(**potential_values) # type: ignore + conn.extra = '{"database": "my_database", "warehouse": "my_warehouse", "private_key_content": "my_private_key"}' + print("testing with", conn.extra) + with patch("airflow.hooks.base.BaseHook.get_connection", return_value=conn): + profile_mapping = SnowflakeEncryptedPrivateKeyFilePemProfileMapping(conn) + assert not profile_mapping.can_claim_connection() + + # test when we're missing the database + conn = Connection(**potential_values) # type: ignore + conn.extra = '{"account": "my_account", "warehouse": "my_warehouse", "private_key_content": "my_private_key"}' + print("testing with", conn.extra) + with patch("airflow.hooks.base.BaseHook.get_connection", return_value=conn): + profile_mapping = SnowflakeEncryptedPrivateKeyFilePemProfileMapping(conn) + assert not profile_mapping.can_claim_connection() + + # test when we're missing the warehouse + conn = Connection(**potential_values) # type: ignore + conn.extra = '{"account": "my_account", "database": "my_database", "private_key_content": "my_private_key"}' + print("testing with", conn.extra) + with patch("airflow.hooks.base.BaseHook.get_connection", return_value=conn): + profile_mapping = SnowflakeEncryptedPrivateKeyFilePemProfileMapping(conn) + assert not profile_mapping.can_claim_connection() + + # if we have them all, it should claim + conn = Connection(**potential_values) # type: ignore + with patch("airflow.hooks.base.BaseHook.get_connection", return_value=conn): + profile_mapping = SnowflakeEncryptedPrivateKeyFilePemProfileMapping(conn) + assert profile_mapping.can_claim_connection() + + +def test_profile_mapping_selected( + mock_snowflake_conn: Connection, +) -> None: + """ + Tests that the correct profile mapping is selected. + """ + profile_mapping = get_automatic_profile_mapping( + mock_snowflake_conn.conn_id, + ) + assert isinstance(profile_mapping, SnowflakeEncryptedPrivateKeyFilePemProfileMapping) + + +def test_profile_args( + mock_snowflake_conn: Connection, +) -> None: + """ + Tests that the profile values get set correctly. + """ + profile_mapping = get_automatic_profile_mapping( + mock_snowflake_conn.conn_id, + ) + + mock_account = mock_snowflake_conn.extra_dejson.get("account") + mock_region = mock_snowflake_conn.extra_dejson.get("region") + + assert profile_mapping.profile == { + "type": mock_snowflake_conn.conn_type, + "user": mock_snowflake_conn.login, + "private_key_passphrase": "{{ env_var('COSMOS_CONN_SNOWFLAKE_PRIVATE_KEY_PASSPHRASE') }}", + "private_key_path": mock_snowflake_conn.extra_dejson.get("private_key_file"), + "schema": mock_snowflake_conn.schema, + "account": f"{mock_account}.{mock_region}", + "database": mock_snowflake_conn.extra_dejson.get("database"), + "warehouse": mock_snowflake_conn.extra_dejson.get("warehouse"), + } + + +def test_profile_args_overrides( + mock_snowflake_conn: Connection, +) -> None: + """ + Tests that you can override the profile values. + """ + profile_mapping = get_automatic_profile_mapping( + mock_snowflake_conn.conn_id, + profile_args={"database": "my_db_override"}, + ) + assert profile_mapping.profile_args == { + "database": "my_db_override", + } + + mock_account = mock_snowflake_conn.extra_dejson.get("account") + mock_region = mock_snowflake_conn.extra_dejson.get("region") + + assert profile_mapping.profile == { + "type": mock_snowflake_conn.conn_type, + "user": mock_snowflake_conn.login, + "private_key_passphrase": "{{ env_var('COSMOS_CONN_SNOWFLAKE_PRIVATE_KEY_PASSPHRASE') }}", + "private_key_path": mock_snowflake_conn.extra_dejson.get("private_key_file"), + "schema": mock_snowflake_conn.schema, + "account": f"{mock_account}.{mock_region}", + "database": "my_db_override", + "warehouse": mock_snowflake_conn.extra_dejson.get("warehouse"), + } + + +def test_profile_env_vars( + mock_snowflake_conn: Connection, +) -> None: + """ + Tests that the environment variables get set correctly. + """ + profile_mapping = get_automatic_profile_mapping( + mock_snowflake_conn.conn_id, + ) + assert profile_mapping.env_vars == { + "COSMOS_CONN_SNOWFLAKE_PRIVATE_KEY_PASSPHRASE": mock_snowflake_conn.password, + } + + +def test_old_snowflake_format() -> None: + """ + Tests that the old format still works. + """ + conn = Connection( + conn_id="my_snowflake_connection", + conn_type="snowflake", + login="my_user", + schema="my_schema", + password="secret", + extra=json.dumps( + { + "extra__snowflake__account": "my_account", + "extra__snowflake__database": "my_database", + "extra__snowflake__warehouse": "my_warehouse", + "extra__snowflake__private_key_file": "path/to/private_key.p8", + } + ), + ) + + with patch("airflow.hooks.base.BaseHook.get_connection", return_value=conn): + profile_mapping = SnowflakeEncryptedPrivateKeyFilePemProfileMapping(conn) + assert profile_mapping.profile == { + "type": conn.conn_type, + "user": conn.login, + "private_key_passphrase": "{{ env_var('COSMOS_CONN_SNOWFLAKE_PRIVATE_KEY_PASSPHRASE') }}", + "private_key_path": conn.extra_dejson.get("private_key_file"), + "schema": conn.schema, + "account": conn.extra_dejson.get("account"), + "database": conn.extra_dejson.get("database"), + "warehouse": conn.extra_dejson.get("warehouse"), + } From fbf3f62ca73a099c7215de228b483d3c0197fa1b Mon Sep 17 00:00:00 2001 From: Joppe Vos <44348300+joppevos@users.noreply.github.com> Date: Wed, 8 Nov 2023 16:29:49 +0100 Subject: [PATCH 008/223] Add `operator_args` `full_refresh` as a templated field (#623) This allows you to fully refresh a model from the console. Full-refresh/backfill is a common task. Using Airflow parameters makes this easy. Without this, you'd have to trigger an entire deployment. In our setup, company analysts manage their models without modifying the DAG code. This empowers such users. Example of usage: ```python with DAG( dag_id="jaffle", params={"full_refresh": Param(default=False, type="boolean")}, render_template_as_native_obj=True ): task = DbtTaskGroup( operator_args={"full_refresh": "{{ params.get('full_refresh') }}", "install_deps": True}, ) ``` Closes: #151 --- cosmos/operators/local.py | 3 +++ tests/operators/test_local.py | 13 +++++++++++++ 2 files changed, 16 insertions(+) diff --git a/cosmos/operators/local.py b/cosmos/operators/local.py index 0f5092091e..f6ff73d859 100644 --- a/cosmos/operators/local.py +++ b/cosmos/operators/local.py @@ -397,6 +397,8 @@ class DbtSeedLocalOperator(DbtLocalBaseOperator): ui_color = "#F58D7E" + template_fields: Sequence[str] = DbtBaseOperator.template_fields + ("full_refresh",) # type: ignore[operator] + def __init__(self, full_refresh: bool = False, **kwargs: Any) -> None: self.full_refresh = full_refresh super().__init__(**kwargs) @@ -434,6 +436,7 @@ class DbtRunLocalOperator(DbtLocalBaseOperator): ui_color = "#7352BA" ui_fgcolor = "#F4F2FC" + template_fields: Sequence[str] = DbtBaseOperator.template_fields + ("full_refresh",) # type: ignore[operator] def __init__(self, full_refresh: bool = False, **kwargs: Any) -> None: self.full_refresh = full_refresh diff --git a/tests/operators/test_local.py b/tests/operators/test_local.py index 580d49e6ce..14213b3358 100644 --- a/tests/operators/test_local.py +++ b/tests/operators/test_local.py @@ -420,6 +420,19 @@ def test_calculate_openlineage_events_completes_openlineage_errors(mock_processo assert err_msg in caplog.text +@pytest.mark.parametrize( + "operator_class,expected_template", + [ + (DbtSeedLocalOperator, ("env", "vars", "full_refresh")), + (DbtRunLocalOperator, ("env", "vars", "full_refresh")), + ], +) +def test_dbt_base_operator_template_fields(operator_class, expected_template): + # Check if value of template fields is what we expect for the operators we're validating + dbt_base_operator = operator_class(profile_config=profile_config, task_id="my-task", project_dir="my/dir") + assert dbt_base_operator.template_fields == expected_template + + @patch.object(DbtDocsGCSLocalOperator, "required_files", ["file1", "file2"]) def test_dbt_docs_gcs_local_operator(): mock_gcs = MagicMock() From d8d5b80643a662b8f22ebe3bb42452cb8a4614d2 Mon Sep 17 00:00:00 2001 From: agreenburg Date: Thu, 9 Nov 2023 11:09:08 -0500 Subject: [PATCH 009/223] Add cosmos/propagate_logs Airflow config support for disabling log propagation if desired (#648) Add Airflow config check for cosmos/propagate_logs to allow override of default propagation behavior. Expose entry-point so that Airflow can theoretically detect configuration default. Closes #639 ## Breaking Change? This is backward-compatible as it falls back to default behavior if the `cosmos` section or `propagate_logs` option don't exist. ## Checklist - [X] I have made corresponding changes to the documentation (if required) - [X] I have added tests that prove my fix is effective or that my feature works --------- Co-authored-by: Andrew Greenburg --- cosmos/__init__.py | 27 +++++++++++++++++++++++++++ cosmos/log.py | 5 +++++ docs/configuration/index.rst | 1 + docs/configuration/logging.rst | 19 +++++++++++++++++++ pyproject.toml | 3 +++ tests/test_log.py | 18 ++++++++++++++++++ 6 files changed, 73 insertions(+) create mode 100644 docs/configuration/logging.rst diff --git a/cosmos/__init__.py b/cosmos/__init__.py index 10fea5a1bd..18f675750c 100644 --- a/cosmos/__init__.py +++ b/cosmos/__init__.py @@ -116,3 +116,30 @@ "LoadMode", "TestBehavior", ] + +""" +Required provider info for using Airflow config for configuration +""" + + +def get_provider_info(): + return { + "package-name": "astronomer-cosmos", # Required + "name": "Astronomer Cosmos", # Required + "description": "Astronomer Cosmos is a library for rendering dbt workflows in Airflow. Contains dags, task groups, and operators.", # Required + "versions": [__version__], # Required + "config": { + "cosmos": { + "description": None, + "options": { + "propagate_logs": { + "description": "Enable log propagation from Cosmos custom logger\n", + "version_added": "1.3.0a1", + "type": "boolean", + "example": None, + "default": "True", + }, + }, + }, + }, + } diff --git a/cosmos/log.py b/cosmos/log.py index 0527621532..e4ad6e2bad 100644 --- a/cosmos/log.py +++ b/cosmos/log.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +from airflow.configuration import conf from airflow.utils.log.colored_log import CustomTTYColoredFormatter @@ -23,9 +24,13 @@ def get_logger(name: str | None = None) -> logging.Logger: By using this logger, we introduce a (yellow) astronomer-cosmos string into the project's log messages: [2023-08-09T14:20:55.532+0100] {subprocess.py:94} INFO - (astronomer-cosmos) - 13:20:55 Completed successfully """ + propagateLogs: bool = True + if conf.has_option("cosmos", "propagate_logs"): + propagateLogs = conf.getboolean("cosmos", "propagate_logs") logger = logging.getLogger(name) formatter: logging.Formatter = CustomTTYColoredFormatter(fmt=LOG_FORMAT) # type: ignore handler = logging.StreamHandler() handler.setFormatter(formatter) logger.addHandler(handler) + logger.propagate = propagateLogs return logger diff --git a/docs/configuration/index.rst b/docs/configuration/index.rst index 2c027e32d0..8c282be030 100644 --- a/docs/configuration/index.rst +++ b/docs/configuration/index.rst @@ -21,3 +21,4 @@ Cosmos offers a number of configuration options to customize its behavior. For m Selecting & Excluding Operator Args Compiled SQL + Logging diff --git a/docs/configuration/logging.rst b/docs/configuration/logging.rst new file mode 100644 index 0000000000..9c27a950c2 --- /dev/null +++ b/docs/configuration/logging.rst @@ -0,0 +1,19 @@ +.. _logging: + +Logging +==================== + +Cosmos uses a custom logger implementation so that all log messages are clearly tagged with ``(astronomer-cosmos)``. By default this logger has propagation enabled. + +In some environments (for example when running Celery workers) this can cause duplicated log messages to appear in the logs. In this case log propagation can be disabled via airflow configuration using the boolean option ``propagate_logs`` under a ``cosmos`` section. + +.. code-block:: cfg + + [cosmos] + propagate_logs = False + +or + +.. code-block:: python + + AIRFLOW__COSMOS__PROPAGATE_LOGS = "False" diff --git a/pyproject.toml b/pyproject.toml index cd90c8d969..0041d9488b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -120,6 +120,9 @@ kubernetes = [ ] +[project.entry-points.cosmos] +provider_info = "cosmos:get_provider_info" + [project.urls] Homepage = "https://github.com/astronomer/astronomer-cosmos" Documentation = "https://astronomer.github.io/astronomer-cosmos" diff --git a/tests/test_log.py b/tests/test_log.py index d94949ed38..d145dbca49 100644 --- a/tests/test_log.py +++ b/tests/test_log.py @@ -1,6 +1,8 @@ import logging +from cosmos import get_provider_info from cosmos.log import get_logger +from airflow.configuration import conf def test_get_logger(): @@ -12,3 +14,19 @@ def test_get_logger(): assert custom_logger.propagate is True assert custom_logger.handlers[0].formatter.__class__.__name__ == "CustomTTYColoredFormatter" assert custom_string in custom_logger.handlers[0].formatter._fmt + + +def test_propagate_logs_conf(): + if not conf.has_section("cosmos"): + conf.add_section("cosmos") + conf.set("cosmos", "propagate_logs", "False") + custom_logger = get_logger("cosmos-log") + assert custom_logger.propagate is False + + +def test_get_provider_info(): + provider_info = get_provider_info() + assert "cosmos" in provider_info.get("config").keys() + assert "options" in provider_info.get("config").get("cosmos").keys() + assert "propagate_logs" in provider_info.get("config").get("cosmos").get("options").keys() + assert provider_info["config"]["cosmos"]["options"]["propagate_logs"]["type"] == "boolean" From 5e95cfd3f09e4579cf9a1b8809f6686e6eedcbc2 Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Thu, 9 Nov 2023 16:09:54 +0000 Subject: [PATCH 010/223] Fix reusing config accross TaskGroups/DAGs (#664) If execution_config was reused, Cosmos 1.2.2 would raise: ``` astronomer-cosmos/dags/basic_cosmos_task_group.py Traceback (most recent call last): File "/Users/tati/Code/cosmos-clean/astronomer-cosmos/venv-38/lib/python3.8/site-packages/airflow/models/dagbag.py", line 343, in parse loader.exec_module(new_module) File "", line 848, in exec_module File "", line 219, in _call_with_frames_removed File "/Users/tati/Code/cosmos-clean/astronomer-cosmos/dags/basic_cosmos_task_group.py", line 74, in basic_cosmos_task_group() File "/Users/tati/Code/cosmos-clean/astronomer-cosmos/venv-38/lib/python3.8/site-packages/airflow/models/dag.py", line 3817, in factory f(**f_kwargs) File "/Users/tati/Code/cosmos-clean/astronomer-cosmos/dags/basic_cosmos_task_group.py", line 54, in basic_cosmos_task_group orders = DbtTaskGroup( File "/Users/tati/Code/cosmos-clean/astronomer-cosmos/cosmos/airflow/task_group.py", line 26, in __init__ DbtToAirflowConverter.__init__(self, *args, **specific_kwargs(**kwargs)) File "/Users/tati/Code/cosmos-clean/astronomer-cosmos/cosmos/converter.py", line 113, in __init__ raise CosmosValueError( cosmos.exceptions.CosmosValueError: ProjectConfig.dbt_project_path is mutually exclusive with RenderConfig.dbt_project_path and ExecutionConfig.dbt_project_path.If using RenderConfig.dbt_project_path or ExecutionConfig.dbt_project_path, ProjectConfig.dbt_project_path should be None ``` This has been raised by an Astro customer and our field engineer, who tried to run: https://github.com/astronomer/cosmos-demo --- cosmos/converter.py | 5 +++++ dev/dags/basic_cosmos_task_group.py | 26 ++++++++++++++++++++++---- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/cosmos/converter.py b/cosmos/converter.py index dbc290271e..45d98a4cf1 100644 --- a/cosmos/converter.py +++ b/cosmos/converter.py @@ -3,6 +3,7 @@ from __future__ import annotations +import copy import inspect from typing import Any, Callable @@ -118,6 +119,10 @@ def __init__( # If we are using the old interface, we should migrate it to the new interface # This is safe to do now since we have validated which config interface we're using if project_config.dbt_project_path: + # We copy the configuration so the change does not affect other DAGs or TaskGroups + # that may reuse the same original configuration + render_config = copy.deepcopy(render_config) + execution_config = copy.deepcopy(execution_config) render_config.project_path = project_config.dbt_project_path execution_config.project_path = project_config.dbt_project_path diff --git a/dev/dags/basic_cosmos_task_group.py b/dev/dags/basic_cosmos_task_group.py index 50cb6ed09e..7319149531 100644 --- a/dev/dags/basic_cosmos_task_group.py +++ b/dev/dags/basic_cosmos_task_group.py @@ -2,13 +2,14 @@ An example DAG that uses Cosmos to render a dbt project as a TaskGroup. """ import os + from datetime import datetime from pathlib import Path from airflow.decorators import dag from airflow.operators.empty import EmptyOperator -from cosmos import DbtTaskGroup, ProjectConfig, ProfileConfig +from cosmos import DbtTaskGroup, ProjectConfig, ProfileConfig, RenderConfig, ExecutionConfig from cosmos.profiles import PostgresUserPasswordProfileMapping DEFAULT_DBT_ROOT_PATH = Path(__file__).parent / "dbt" @@ -23,6 +24,8 @@ ), ) +shared_execution_config = ExecutionConfig() + @dag( schedule_interval="@daily", @@ -35,11 +38,25 @@ def basic_cosmos_task_group() -> None: """ pre_dbt = EmptyOperator(task_id="pre_dbt") - jaffle_shop = DbtTaskGroup( - group_id="test_123", + customers = DbtTaskGroup( + group_id="customers", + project_config=ProjectConfig( + (DBT_ROOT_PATH / "jaffle_shop").as_posix(), + ), + render_config=RenderConfig(select=["path:seeds/raw_customers.csv"]), + execution_config=shared_execution_config, + operator_args={"install_deps": True}, + profile_config=profile_config, + default_args={"retries": 2}, + ) + + orders = DbtTaskGroup( + group_id="orders", project_config=ProjectConfig( (DBT_ROOT_PATH / "jaffle_shop").as_posix(), ), + render_config=RenderConfig(select=["path:seeds/raw_orders.csv"]), + execution_config=shared_execution_config, operator_args={"install_deps": True}, profile_config=profile_config, default_args={"retries": 2}, @@ -47,7 +64,8 @@ def basic_cosmos_task_group() -> None: post_dbt = EmptyOperator(task_id="post_dbt") - pre_dbt >> jaffle_shop >> post_dbt + pre_dbt >> customers >> post_dbt + pre_dbt >> orders >> post_dbt basic_cosmos_task_group() From 5fcee8ffce98b88b2b0a070c5959050902be9ae0 Mon Sep 17 00:00:00 2001 From: Benjamin Dornel <62495124+benjamin-awd@users.noreply.github.com> Date: Fri, 10 Nov 2023 23:37:18 +0800 Subject: [PATCH 011/223] Add aws_session_token for Athena mapping (#663) Adds the `aws_session_token` argument to Athena, which was added to dbt-athena 1.6.4 in https://github.com/dbt-athena/dbt-athena/pull/459 Closes: #609 Also addresses this comment: https://github.com/astronomer/astronomer-cosmos/pull/578#discussion_r1378301372 --- cosmos/profiles/__init__.py | 1 + cosmos/profiles/athena/access_key.py | 8 ++++---- tests/profiles/athena/test_athena_access_key.py | 8 +++++++- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/cosmos/profiles/__init__.py b/cosmos/profiles/__init__.py index 8280cd9508..1f39a91a0f 100644 --- a/cosmos/profiles/__init__.py +++ b/cosmos/profiles/__init__.py @@ -64,6 +64,7 @@ def get_automatic_profile_mapping( __all__ = [ + "AthenaAccessKeyProfileMapping", "BaseProfileMapping", "GoogleCloudServiceAccountFileProfileMapping", "GoogleCloudServiceAccountDictProfileMapping", diff --git a/cosmos/profiles/athena/access_key.py b/cosmos/profiles/athena/access_key.py index b79bb793ab..a8f71c2b7a 100644 --- a/cosmos/profiles/athena/access_key.py +++ b/cosmos/profiles/athena/access_key.py @@ -26,12 +26,11 @@ class AthenaAccessKeyProfileMapping(BaseProfileMapping): "s3_staging_dir", "schema", ] - secret_fields = [ - "aws_secret_access_key", - ] + secret_fields = ["aws_secret_access_key", "aws_session_token"] airflow_param_mapping = { "aws_access_key_id": "login", "aws_secret_access_key": "password", + "aws_session_token": "extra.aws_session_token", "aws_profile_name": "extra.aws_profile_name", "database": "extra.database", "debug_query_state": "extra.debug_query_state", @@ -53,7 +52,8 @@ def profile(self) -> dict[str, Any | None]: profile = { **self.mapped_params, **self.profile_args, - # aws_secret_access_key should always get set as env var + # aws_secret_access_key and aws_session_token should always get set as env var "aws_secret_access_key": self.get_env_var_format("aws_secret_access_key"), + "aws_session_token": self.get_env_var_format("aws_session_token"), } return self.filter_null(profile) diff --git a/tests/profiles/athena/test_athena_access_key.py b/tests/profiles/athena/test_athena_access_key.py index 2063ef6ed6..22c8efa2c0 100644 --- a/tests/profiles/athena/test_athena_access_key.py +++ b/tests/profiles/athena/test_athena_access_key.py @@ -22,6 +22,7 @@ def mock_athena_conn(): # type: ignore password="my_aws_secret_key", extra=json.dumps( { + "aws_session_token": "token123", "database": "my_database", "region_name": "my_region", "s3_staging_dir": "s3://my_bucket/dbt/", @@ -107,6 +108,7 @@ def test_athena_profile_args( "type": "athena", "aws_access_key_id": mock_athena_conn.login, "aws_secret_access_key": "{{ env_var('COSMOS_CONN_AWS_AWS_SECRET_ACCESS_KEY') }}", + "aws_session_token": "{{ env_var('COSMOS_CONN_AWS_AWS_SESSION_TOKEN') }}", "database": mock_athena_conn.extra_dejson.get("database"), "region_name": mock_athena_conn.extra_dejson.get("region_name"), "s3_staging_dir": mock_athena_conn.extra_dejson.get("s3_staging_dir"), @@ -122,17 +124,20 @@ def test_athena_profile_args_overrides( """ profile_mapping = get_automatic_profile_mapping( mock_athena_conn.conn_id, - profile_args={"schema": "my_custom_schema", "database": "my_custom_db"}, + profile_args={"schema": "my_custom_schema", "database": "my_custom_db", "aws_session_token": "override_token"}, ) + assert profile_mapping.profile_args == { "schema": "my_custom_schema", "database": "my_custom_db", + "aws_session_token": "override_token", } assert profile_mapping.profile == { "type": "athena", "aws_access_key_id": mock_athena_conn.login, "aws_secret_access_key": "{{ env_var('COSMOS_CONN_AWS_AWS_SECRET_ACCESS_KEY') }}", + "aws_session_token": "{{ env_var('COSMOS_CONN_AWS_AWS_SESSION_TOKEN') }}", "database": "my_custom_db", "region_name": mock_athena_conn.extra_dejson.get("region_name"), "s3_staging_dir": mock_athena_conn.extra_dejson.get("s3_staging_dir"), @@ -151,4 +156,5 @@ def test_athena_profile_env_vars( ) assert profile_mapping.env_vars == { "COSMOS_CONN_AWS_AWS_SECRET_ACCESS_KEY": mock_athena_conn.password, + "COSMOS_CONN_AWS_AWS_SESSION_TOKEN": mock_athena_conn.extra_dejson.get("aws_session_token"), } From 2d04ac2eece1d0dd65cc269aa37c4dceb32a0349 Mon Sep 17 00:00:00 2001 From: Joppe Vos <44348300+joppevos@users.noreply.github.com> Date: Fri, 10 Nov 2023 16:40:15 +0100 Subject: [PATCH 012/223] Fix installing deps when using `profile_mapping` & `ExecutionMode.LOCAL` (#659) Extends the local operator when running `dbt deps` with the provides profile flags. This makes the logic consistent between DAG parsing and task running as referenced below https://github.com/astronomer/astronomer-cosmos/blob/8e2d5908ce89aa98813af6dfd112239e124bd69a/cosmos/dbt/graph.py#L247-L266 Closes: #658 --- cosmos/operators/local.py | 26 ++++++++++++++++---------- tests/operators/test_local.py | 30 ++++++++++++++++++++++++++++++ tests/operators/test_virtualenv.py | 2 +- 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/cosmos/operators/local.py b/cosmos/operators/local.py index f6ff73d859..45e64b4766 100644 --- a/cosmos/operators/local.py +++ b/cosmos/operators/local.py @@ -206,17 +206,11 @@ def run_command( tmp_project_dir, ) - # if we need to install deps, do so - if self.install_deps: - self.run_subprocess( - command=[self.dbt_executable_path, "deps"], - env=env, - output_encoding=self.output_encoding, - cwd=tmp_project_dir, - ) - with self.profile_config.ensure_profile() as (profile_path, env_vars): + with self.profile_config.ensure_profile() as profile_values: + (profile_path, env_vars) = profile_values env.update(env_vars) - full_cmd = cmd + [ + + flags = [ "--profiles-dir", str(profile_path.parent), "--profile", @@ -225,6 +219,18 @@ def run_command( self.profile_config.target_name, ] + if self.install_deps: + deps_command = [self.dbt_executable_path, "deps"] + deps_command.extend(flags) + self.run_subprocess( + command=deps_command, + env=env, + output_encoding=self.output_encoding, + cwd=tmp_project_dir, + ) + + full_cmd = cmd + flags + logger.info("Trying to run the command:\n %s\nFrom %s", full_cmd, tmp_project_dir) logger.info("Using environment variables keys: %s", env.keys()) result = self.run_subprocess( diff --git a/tests/operators/test_local.py b/tests/operators/test_local.py index 14213b3358..38863620f4 100644 --- a/tests/operators/test_local.py +++ b/tests/operators/test_local.py @@ -458,3 +458,33 @@ def test_dbt_docs_gcs_local_operator(): call(filename="fake-dir/target/file2", bucket_name="fake-bucket", object_name="fake-folder/file2"), ] mock_hook.upload.assert_has_calls(expected_upload_calls) + + +@patch("cosmos.operators.local.DbtLocalBaseOperator.store_compiled_sql") +@patch("cosmos.operators.local.DbtLocalBaseOperator.exception_handling") +@patch("cosmos.config.ProfileConfig.ensure_profile") +@patch("cosmos.operators.local.DbtLocalBaseOperator.run_subprocess") +def test_operator_execute_deps_parameters( + mock_build_and_run_cmd, mock_ensure_profile, mock_exception_handling, mock_store_compiled_sql +): + expected_call_kwargs = [ + "/usr/local/bin/dbt", + "deps", + "--profiles-dir", + "/path/to", + "--profile", + "default", + "--target", + "dev", + ] + task = DbtRunLocalOperator( + profile_config=real_profile_config, + task_id="my-task", + project_dir=DBT_PROJ_DIR, + install_deps=True, + emit_datasets=False, + dbt_executable_path="/usr/local/bin/dbt", + ) + mock_ensure_profile.return_value.__enter__.return_value = (Path("/path/to/profile"), {"ENV_VAR": "value"}) + task.execute(context={"task_instance": MagicMock()}) + assert mock_build_and_run_cmd.call_args_list[0].kwargs["command"] == expected_call_kwargs diff --git a/tests/operators/test_virtualenv.py b/tests/operators/test_virtualenv.py index 142a251a7c..13dba8f942 100644 --- a/tests/operators/test_virtualenv.py +++ b/tests/operators/test_virtualenv.py @@ -60,7 +60,7 @@ def test_run_command( dbt_cmd = run_command_args[2] assert python_cmd[0][0][0].endswith("/bin/python") assert python_cmd[0][-1][-1] == "from importlib.metadata import version; print(version('dbt-core'))" - assert dbt_deps[0][0][-1] == "deps" + assert dbt_deps[0][0][1] == "deps" assert dbt_deps[0][0][0].endswith("/bin/dbt") assert dbt_deps[0][0][0] == dbt_cmd[0][0][0] assert dbt_cmd[0][0][1] == "do-something" From a3688d16e48f995449de0f66d75c9304d600ae23 Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Mon, 13 Nov 2023 22:14:07 +0000 Subject: [PATCH 013/223] Fix 'Unable to find the dbt executable: dbt' error (#666) Since Cosmos 1.2.2 users who used `ExecutionMode.DBT_LS` (directly or via `ExecutionMode.AUTOMATIC`) and set `ExecutionConfig.dbt_executable_path` (most, if not all, Astro CLI users), like: ``` execution_config = ExecutionConfig( dbt_executable_path = f"{os.environ['AIRFLOW_HOME']}/dbt_venv/bin/dbt", ) ``` Started facing the issue: ``` Broken DAG: [/usr/local/airflow/dags/my_example.py] Traceback (most recent call last): File "/usr/local/lib/python3.11/site-packages/cosmos/dbt/graph.py", line 178, in load self.load_via_dbt_ls() File "/usr/local/lib/python3.11/site-packages/cosmos/dbt/graph.py", line 233, in load_via_dbt_ls raise CosmosLoadDbtException(f"Unable to find the dbt executable: {self.dbt_cmd}") cosmos.dbt.graph.CosmosLoadDbtException: Unable to find the dbt executable: dbt ``` This issue was initially reported in the Airflow #airflow-astronomer Slack channel: https://apache-airflow.slack.com/archives/C03T0AVNA6A/p1699584315506629 The workaround to avoid this error in Cosmos 1.2.2 and 1.2.3 is to set the `dbt_executable_path` in the `RenderConfig`: ``` render_config=RenderConfig(dbt_executable_path = f"{os.environ['AIRFLOW_HOME']}/dbt_venv/bin/dbt",), ``` This PR solves the bug from Cosmos 1.2.4 onwards. --- cosmos/config.py | 30 +++++++++++++++++ cosmos/converter.py | 1 - cosmos/dbt/graph.py | 30 ++++++----------- tests/dbt/test_graph.py | 22 ++++++------ tests/test_config.py | 38 ++++++++++++++++++++- tests/test_converter.py | 74 +++++++++++++++++++++++++++++++++++++++-- 6 files changed, 161 insertions(+), 34 deletions(-) diff --git a/cosmos/config.py b/cosmos/config.py index 87baba8645..57d5200b15 100644 --- a/cosmos/config.py +++ b/cosmos/config.py @@ -3,6 +3,7 @@ from __future__ import annotations import contextlib +import shutil import tempfile from dataclasses import InitVar, dataclass, field from pathlib import Path @@ -19,6 +20,14 @@ DEFAULT_PROFILES_FILE_NAME = "profiles.yml" +class CosmosConfigException(Exception): + """ + Exceptions related to user misconfiguration. + """ + + pass + + @dataclass class RenderConfig: """ @@ -51,6 +60,27 @@ class RenderConfig: def __post_init__(self, dbt_project_path: str | Path | None) -> None: self.project_path = Path(dbt_project_path) if dbt_project_path else None + def validate_dbt_command(self, fallback_cmd: str | Path = "") -> None: + """ + When using LoadMode.DBT_LS, the dbt executable path is necessary for rendering. + + Validates that the original dbt command works, if not, attempt to use the fallback_dbt_cmd. + If neither works, raise an exception. + + The fallback behaviour is necessary for Cosmos < 1.2.2 backwards compatibility. + """ + if not shutil.which(self.dbt_executable_path): + if isinstance(fallback_cmd, Path): + fallback_cmd = fallback_cmd.as_posix() + + if fallback_cmd and shutil.which(fallback_cmd): + self.dbt_executable_path = fallback_cmd + else: + raise CosmosConfigException( + "Unable to find the dbt executable, attempted: " + f"<{self.dbt_executable_path}>" + (f" and <{fallback_cmd}>." if fallback_cmd else ".") + ) + class ProjectConfig: """ diff --git a/cosmos/converter.py b/cosmos/converter.py index 45d98a4cf1..559b7ea69b 100644 --- a/cosmos/converter.py +++ b/cosmos/converter.py @@ -160,7 +160,6 @@ def __init__( project=project_config, render_config=render_config, execution_config=execution_config, - dbt_cmd=render_config.dbt_executable_path, profile_config=profile_config, operator_args=operator_args, ) diff --git a/cosmos/dbt/graph.py b/cosmos/dbt/graph.py index 15bdbcac7f..510b550195 100644 --- a/cosmos/dbt/graph.py +++ b/cosmos/dbt/graph.py @@ -3,7 +3,6 @@ import itertools import json import os -import shutil import tempfile from dataclasses import dataclass, field from pathlib import Path @@ -21,7 +20,6 @@ ExecutionMode, LoadMode, ) -from cosmos.dbt.executable import get_system_dbt from cosmos.dbt.parser.project import LegacyDbtProject from cosmos.dbt.selector import select_nodes from cosmos.log import get_logger @@ -147,15 +145,6 @@ class DbtGraph: Supports different ways of loading the `dbt` project into this representation. Different loading methods can result in different `nodes` and `filtered_nodes`. - - Example of how to use: - - dbt_graph = DbtGraph( - project=ProjectConfig(dbt_project_path=DBT_PROJECT_PATH), - render_config=RenderConfig(exclude=["*orders*"], select=[]), - dbt_cmd="/usr/local/bin/dbt" - ) - dbt_graph.load(method=LoadMode.DBT_LS, execution_mode=ExecutionMode.LOCAL) """ nodes: dict[str, DbtNode] = dict() @@ -167,7 +156,6 @@ def __init__( render_config: RenderConfig = RenderConfig(), execution_config: ExecutionConfig = ExecutionConfig(), profile_config: ProfileConfig | None = None, - dbt_cmd: str = get_system_dbt(), operator_args: dict[str, Any] | None = None, ): self.project = project @@ -175,7 +163,6 @@ def __init__( self.profile_config = profile_config self.execution_config = execution_config self.operator_args = operator_args or {} - self.dbt_cmd = dbt_cmd def load( self, @@ -213,10 +200,12 @@ def load( else: load_method[method]() - def run_dbt_ls(self, project_path: Path, tmp_dir: Path, env_vars: dict[str, str]) -> dict[str, DbtNode]: + def run_dbt_ls( + self, dbt_cmd: str, project_path: Path, tmp_dir: Path, env_vars: dict[str, str] + ) -> dict[str, DbtNode]: """Runs dbt ls command and returns the parsed nodes.""" ls_command = [ - self.dbt_cmd, + dbt_cmd, "ls", "--output", "json", @@ -257,6 +246,10 @@ def load_via_dbt_ls(self) -> None: * self.nodes * self.filtered_nodes """ + self.render_config.validate_dbt_command(fallback_cmd=self.execution_config.dbt_executable_path) + dbt_cmd = self.render_config.dbt_executable_path + dbt_cmd = dbt_cmd.as_posix() if isinstance(dbt_cmd, Path) else dbt_cmd + logger.info(f"Trying to parse the dbt project in `{self.render_config.project_path}` using dbt ls...") if not self.render_config.project_path or not self.execution_config.project_path: raise CosmosLoadDbtException( @@ -266,9 +259,6 @@ def load_via_dbt_ls(self) -> None: if not self.profile_config: raise CosmosLoadDbtException("Unable to load project via dbt ls without a profile config.") - if not shutil.which(self.dbt_cmd): - raise CosmosLoadDbtException(f"Unable to find the dbt executable: {self.dbt_cmd}") - with tempfile.TemporaryDirectory() as tmpdir: logger.info( f"Content of the dbt project dir {self.render_config.project_path}: `{os.listdir(self.render_config.project_path)}`" @@ -297,12 +287,12 @@ def load_via_dbt_ls(self) -> None: env[DBT_TARGET_PATH_ENVVAR] = str(self.target_dir) if self.render_config.dbt_deps: - deps_command = [self.dbt_cmd, "deps"] + deps_command = [dbt_cmd, "deps"] deps_command.extend(self.local_flags) stdout = run_command(deps_command, tmpdir_path, env) logger.debug("dbt deps output: %s", stdout) - nodes = self.run_dbt_ls(self.execution_config.project_path, tmpdir_path, env) + nodes = self.run_dbt_ls(dbt_cmd, self.execution_config.project_path, tmpdir_path, env) self.nodes = nodes self.filtered_nodes = nodes diff --git a/tests/dbt/test_graph.py b/tests/dbt/test_graph.py index b108878fc9..6ae6ab2005 100644 --- a/tests/dbt/test_graph.py +++ b/tests/dbt/test_graph.py @@ -5,7 +5,7 @@ import pytest -from cosmos.config import ExecutionConfig, ProfileConfig, ProjectConfig, RenderConfig +from cosmos.config import ExecutionConfig, ProfileConfig, ProjectConfig, RenderConfig, CosmosConfigException from cosmos.constants import DbtResourceType, ExecutionMode from cosmos.dbt.graph import ( CosmosLoadDbtException, @@ -312,9 +312,8 @@ def test_load_via_dbt_ls_without_exclude(project_name): def test_load_via_custom_without_project_path(): project_config = ProjectConfig(manifest_path=SAMPLE_MANIFEST, project_name="test") execution_config = ExecutionConfig() - render_config = RenderConfig() + render_config = RenderConfig(dbt_executable_path="/inexistent/dbt") dbt_graph = DbtGraph( - dbt_cmd="/inexistent/dbt", project=project_config, execution_config=execution_config, render_config=render_config, @@ -326,12 +325,14 @@ def test_load_via_custom_without_project_path(): assert err_info.value.args[0] == expected -def test_load_via_dbt_ls_without_profile(): +@patch("cosmos.config.RenderConfig.validate_dbt_command", return_value=None) +def test_load_via_dbt_ls_without_profile(mock_validate_dbt_command): project_config = ProjectConfig(dbt_project_path=DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME) execution_config = ExecutionConfig(dbt_project_path=DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME) - render_config = RenderConfig(dbt_project_path=DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME) + render_config = RenderConfig( + dbt_executable_path="existing-dbt-cmd", dbt_project_path=DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME + ) dbt_graph = DbtGraph( - dbt_cmd="/inexistent/dbt", project=project_config, execution_config=execution_config, render_config=render_config, @@ -346,10 +347,11 @@ def test_load_via_dbt_ls_without_profile(): def test_load_via_dbt_ls_with_invalid_dbt_path(): project_config = ProjectConfig(dbt_project_path=DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME) execution_config = ExecutionConfig(dbt_project_path=DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME) - render_config = RenderConfig(dbt_project_path=DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME) + render_config = RenderConfig( + dbt_project_path=DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME, dbt_executable_path="/inexistent/dbt" + ) with patch("pathlib.Path.exists", return_value=True): dbt_graph = DbtGraph( - dbt_cmd="/inexistent/dbt", project=project_config, execution_config=execution_config, render_config=render_config, @@ -359,10 +361,10 @@ def test_load_via_dbt_ls_with_invalid_dbt_path(): profiles_yml_filepath=Path(__file__).parent.parent / "sample/profiles.yml", ), ) - with pytest.raises(CosmosLoadDbtException) as err_info: + with pytest.raises(CosmosConfigException) as err_info: dbt_graph.load_via_dbt_ls() - expected = "Unable to find the dbt executable: /inexistent/dbt" + expected = "Unable to find the dbt executable, attempted: and ." assert err_info.value.args[0] == expected diff --git a/tests/test_config.py b/tests/test_config.py index 9eec48055d..cc0711043c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,8 +1,9 @@ from pathlib import Path +from unittest.mock import patch import pytest -from cosmos.config import ProfileConfig, ProjectConfig +from cosmos.config import ProfileConfig, ProjectConfig, RenderConfig, CosmosConfigException from cosmos.exceptions import CosmosValueError @@ -121,3 +122,38 @@ def test_profile_config_validate(): profile_config = ProfileConfig(profile_name="test", target_name="test") assert profile_config.validate_profile() is None assert err_info.value.args[0] == "Either profiles_yml_filepath or profile_mapping must be set to render a profile" + + +@patch("cosmos.config.shutil.which", return_value=None) +def test_render_config_without_dbt_cmd(mock_which): + render_config = RenderConfig() + with pytest.raises(CosmosConfigException) as err_info: + render_config.validate_dbt_command("inexistent-dbt") + + error_msg = err_info.value.args[0] + assert error_msg.startswith("Unable to find the dbt executable, attempted: <") + assert error_msg.endswith("dbt> and .") + + +@patch("cosmos.config.shutil.which", return_value=None) +def test_render_config_with_invalid_dbt_commands(mock_which): + render_config = RenderConfig(dbt_executable_path="invalid-dbt") + with pytest.raises(CosmosConfigException) as err_info: + render_config.validate_dbt_command() + + error_msg = err_info.value.args[0] + assert error_msg == "Unable to find the dbt executable, attempted: ." + + +@patch("cosmos.config.shutil.which", side_effect=(None, "fallback-dbt-path")) +def test_render_config_uses_fallback_if_default_not_found(mock_which): + render_config = RenderConfig() + render_config.validate_dbt_command(Path("/tmp/fallback-dbt-path")) + assert render_config.dbt_executable_path == "/tmp/fallback-dbt-path" + + +@patch("cosmos.config.shutil.which", side_effect=("user-dbt", "fallback-dbt-path")) +def test_render_config_uses_default_if_exists(mock_which): + render_config = RenderConfig(dbt_executable_path="user-dbt") + render_config.validate_dbt_command("fallback-dbt-path") + assert render_config.dbt_executable_path == "user-dbt" diff --git a/tests/test_converter.py b/tests/test_converter.py index 5d89513b31..4210b24d64 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -1,11 +1,13 @@ +from datetime import datetime from pathlib import Path - from unittest.mock import patch + import pytest +from airflow.models import DAG from cosmos.converter import DbtToAirflowConverter, validate_arguments from cosmos.constants import DbtResourceType, ExecutionMode -from cosmos.config import ProjectConfig, ProfileConfig, ExecutionConfig, RenderConfig +from cosmos.config import ProjectConfig, ProfileConfig, ExecutionConfig, RenderConfig, CosmosConfigException from cosmos.dbt.graph import DbtNode from cosmos.exceptions import CosmosValueError @@ -141,6 +143,74 @@ def test_converter_fails_execution_config_no_project_dir(mock_load_dbt_graph, ex ) +def test_converter_fails_render_config_invalid_dbt_path_with_dbt_ls(): + """ + Validate that a dbt project fails to be rendered to Airflow with DBT_LS if + the dbt command is invalid. + """ + project_config = ProjectConfig(dbt_project_path=SAMPLE_DBT_PROJECT.as_posix(), project_name="sample") + execution_config = ExecutionConfig( + execution_mode=ExecutionMode.LOCAL, + dbt_executable_path="invalid-execution-dbt", + ) + render_config = RenderConfig( + emit_datasets=True, + dbt_executable_path="invalid-render-dbt", + ) + profile_config = ProfileConfig( + profile_name="my_profile_name", + target_name="my_target_name", + profiles_yml_filepath=SAMPLE_PROFILE_YML, + ) + with pytest.raises(CosmosConfigException) as err_info: + with DAG("test-id", start_date=datetime(2022, 1, 1)) as dag: + DbtToAirflowConverter( + dag=dag, + nodes=nodes, + project_config=project_config, + profile_config=profile_config, + execution_config=execution_config, + render_config=render_config, + ) + assert ( + err_info.value.args[0] + == "Unable to find the dbt executable, attempted: and ." + ) + + +def test_converter_fails_render_config_invalid_dbt_path_with_manifest(): + """ + Validate that a dbt project succeeds to be rendered to Airflow with DBT_MANIFEST even when + the dbt command is invalid. + """ + project_config = ProjectConfig(manifest_path=SAMPLE_DBT_MANIFEST.as_posix(), project_name="sample") + + execution_config = ExecutionConfig( + execution_mode=ExecutionMode.LOCAL, + dbt_executable_path="invalid-execution-dbt", + dbt_project_path=SAMPLE_DBT_PROJECT.as_posix(), + ) + render_config = RenderConfig( + emit_datasets=True, + dbt_executable_path="invalid-render-dbt", + ) + profile_config = ProfileConfig( + profile_name="my_profile_name", + target_name="my_target_name", + profiles_yml_filepath=SAMPLE_PROFILE_YML, + ) + with DAG("test-id", start_date=datetime(2022, 1, 1)) as dag: + converter = DbtToAirflowConverter( + dag=dag, + nodes=nodes, + project_config=project_config, + profile_config=profile_config, + execution_config=execution_config, + render_config=render_config, + ) + assert converter + + @pytest.mark.parametrize( "execution_mode,operator_args", [ From 44c56f2fea99278db4d78a52e6d68575907f3c6b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 14 Nov 2023 13:54:02 +0000 Subject: [PATCH 014/223] =?UTF-8?q?=E2=AC=86=20[pre-commit.ci]=20pre-commi?= =?UTF-8?q?t=20autoupdate=20(#669)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.1.4 → v0.1.5](https://github.com/astral-sh/ruff-pre-commit/compare/v0.1.4...v0.1.5) - [github.com/psf/black: 23.10.1 → 23.11.0](https://github.com/psf/black/compare/23.10.1...23.11.0) - [github.com/pre-commit/mirrors-mypy: v1.6.1 → v1.7.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.6.1...v1.7.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Tatiana Al-Chueyr --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c46e316343..9cd8cefee7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -53,13 +53,13 @@ repos: - --py37-plus - --keep-runtime-typing - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.4 + rev: v0.1.5 hooks: - id: ruff args: - --fix - repo: https://github.com/psf/black - rev: 23.10.1 + rev: 23.11.0 hooks: - id: black args: [ "--config", "./pyproject.toml" ] @@ -70,7 +70,7 @@ repos: alias: black additional_dependencies: [black>=22.10.0] - repo: https://github.com/pre-commit/mirrors-mypy - rev: 'v1.6.1' + rev: 'v1.7.0' hooks: - id: mypy name: mypy-python From f12fab521175202991d4cfe25a609267fa57cc25 Mon Sep 17 00:00:00 2001 From: Justin Bandoro <79104794+jbandoro@users.noreply.github.com> Date: Tue, 14 Nov 2023 07:00:01 -0800 Subject: [PATCH 015/223] Refactor LoadMethod.LOCAL to use symlinks instead of copying directory (#660) This PR refactors the `create_symlinks` function that was previously used in load via dbt ls so that it can be used in `DbtLocalBaseOperator.run_command` instead of copying the entire directory. Closes: #614 --- cosmos/dbt/graph.py | 9 +-------- cosmos/dbt/project.py | 14 ++++++++++++++ cosmos/operators/local.py | 13 ++++--------- dev/dags/dbt/{simple => }/data/imdb.db | Bin 73728 -> 53248 bytes .../models/movies_ratings_simplified.sql | 2 +- dev/dags/dbt/simple/models/source.yml | 2 +- dev/dags/dbt/simple/profiles.yml | 4 ++-- dev/dags/example_cosmos_sources.py | 2 +- tests/dbt/test_graph.py | 14 +------------- tests/dbt/test_project.py | 15 +++++++++++++++ tests/sample/manifest_source.json | 12 ++++++------ 11 files changed, 46 insertions(+), 41 deletions(-) create mode 100644 cosmos/dbt/project.py rename dev/dags/dbt/{simple => }/data/imdb.db (71%) create mode 100644 tests/dbt/test_project.py diff --git a/cosmos/dbt/graph.py b/cosmos/dbt/graph.py index 510b550195..da93a54c40 100644 --- a/cosmos/dbt/graph.py +++ b/cosmos/dbt/graph.py @@ -21,6 +21,7 @@ LoadMode, ) from cosmos.dbt.parser.project import LegacyDbtProject +from cosmos.dbt.project import create_symlinks from cosmos.dbt.selector import select_nodes from cosmos.log import get_logger @@ -52,14 +53,6 @@ class DbtNode: has_test: bool = False -def create_symlinks(project_path: Path, tmp_dir: Path) -> None: - """Helper function to create symlinks to the dbt project files.""" - ignore_paths = (DBT_LOG_DIR_NAME, DBT_TARGET_DIR_NAME, "dbt_packages", "profiles.yml") - for child_name in os.listdir(project_path): - if child_name not in ignore_paths: - os.symlink(project_path / child_name, tmp_dir / child_name) - - def is_freshness_effective(freshness: dict[str, Any]) -> bool: """Function to find if a source has null freshness. Scenarios where freshness looks like: diff --git a/cosmos/dbt/project.py b/cosmos/dbt/project.py new file mode 100644 index 0000000000..63f4fc0079 --- /dev/null +++ b/cosmos/dbt/project.py @@ -0,0 +1,14 @@ +from pathlib import Path +import os +from cosmos.constants import ( + DBT_LOG_DIR_NAME, + DBT_TARGET_DIR_NAME, +) + + +def create_symlinks(project_path: Path, tmp_dir: Path) -> None: + """Helper function to create symlinks to the dbt project files.""" + ignore_paths = (DBT_LOG_DIR_NAME, DBT_TARGET_DIR_NAME, "dbt_packages", "profiles.yml") + for child_name in os.listdir(project_path): + if child_name not in ignore_paths: + os.symlink(project_path / child_name, tmp_dir / child_name) diff --git a/cosmos/operators/local.py b/cosmos/operators/local.py index 45e64b4766..b7e5fe359b 100644 --- a/cosmos/operators/local.py +++ b/cosmos/operators/local.py @@ -1,7 +1,6 @@ from __future__ import annotations import os -import shutil import signal import tempfile from attr import define @@ -45,6 +44,7 @@ FullOutputSubprocessResult, ) from cosmos.dbt.parser.output import extract_log_issues, parse_output +from cosmos.dbt.project import create_symlinks DBT_NO_TESTS_MSG = "Nothing to do" DBT_WARN_MSG = "WARN" @@ -192,19 +192,14 @@ def run_command( """ Copies the dbt project to a temporary directory and runs the command. """ - with tempfile.TemporaryDirectory() as tmp_dir: + with tempfile.TemporaryDirectory() as tmp_project_dir: logger.info( "Cloning project to writable temp directory %s from %s", - tmp_dir, + tmp_project_dir, self.project_dir, ) - # need a subfolder because shutil.copytree will fail if the destination dir already exists - tmp_project_dir = os.path.join(tmp_dir, "dbt_project") - shutil.copytree( - self.project_dir, - tmp_project_dir, - ) + create_symlinks(Path(self.project_dir), Path(tmp_project_dir)) with self.profile_config.ensure_profile() as profile_values: (profile_path, env_vars) = profile_values diff --git a/dev/dags/dbt/simple/data/imdb.db b/dev/dags/dbt/data/imdb.db similarity index 71% rename from dev/dags/dbt/simple/data/imdb.db rename to dev/dags/dbt/data/imdb.db index 605f6526e931ad2ae4e0a5d89faf7ae302840bc7..be0c1fe7739b7877496e2b6bdd6ec60950552897 100644 GIT binary patch delta 55 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 Date: Tue, 14 Nov 2023 12:21:26 -0500 Subject: [PATCH 016/223] Store compiled_sql even when task fails (fixes issue #369) (#671) Update `DbtLocalBaseOperator` code to store `compiled_sql` prior to exception handling so that when a task fails, the `compiled_sql` can still be reviewed. In the process found and fixed a related bug where `compiled_sql` was being dropped on some operations due to the way that the `full_refresh` field was being added to the `template_fields`. Closes #369 Fixes bug introduced in https://github.com/astronomer/astronomer-cosmos/pull/623 where compiled_sql was being lost in `DbtSeedLocalOperator` and `DbtRunLocalOperator` Co-authored-by: Andrew Greenburg --- cosmos/operators/local.py | 6 +++--- tests/operators/test_local.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cosmos/operators/local.py b/cosmos/operators/local.py index b7e5fe359b..71a7f4b4c6 100644 --- a/cosmos/operators/local.py +++ b/cosmos/operators/local.py @@ -247,8 +247,8 @@ def run_command( logger.info("Outlets: %s", outlets) self.register_dataset(inlets, outlets) - self.exception_handling(result) self.store_compiled_sql(tmp_project_dir, context) + self.exception_handling(result) if self.callback: self.callback(tmp_project_dir) @@ -398,7 +398,7 @@ class DbtSeedLocalOperator(DbtLocalBaseOperator): ui_color = "#F58D7E" - template_fields: Sequence[str] = DbtBaseOperator.template_fields + ("full_refresh",) # type: ignore[operator] + template_fields: Sequence[str] = DbtLocalBaseOperator.template_fields + ("full_refresh",) # type: ignore[operator] def __init__(self, full_refresh: bool = False, **kwargs: Any) -> None: self.full_refresh = full_refresh @@ -437,7 +437,7 @@ class DbtRunLocalOperator(DbtLocalBaseOperator): ui_color = "#7352BA" ui_fgcolor = "#F4F2FC" - template_fields: Sequence[str] = DbtBaseOperator.template_fields + ("full_refresh",) # type: ignore[operator] + template_fields: Sequence[str] = DbtLocalBaseOperator.template_fields + ("full_refresh",) # type: ignore[operator] def __init__(self, full_refresh: bool = False, **kwargs: Any) -> None: self.full_refresh = full_refresh diff --git a/tests/operators/test_local.py b/tests/operators/test_local.py index 38863620f4..b0a36b3352 100644 --- a/tests/operators/test_local.py +++ b/tests/operators/test_local.py @@ -423,8 +423,8 @@ def test_calculate_openlineage_events_completes_openlineage_errors(mock_processo @pytest.mark.parametrize( "operator_class,expected_template", [ - (DbtSeedLocalOperator, ("env", "vars", "full_refresh")), - (DbtRunLocalOperator, ("env", "vars", "full_refresh")), + (DbtSeedLocalOperator, ("env", "vars", "compiled_sql", "full_refresh")), + (DbtRunLocalOperator, ("env", "vars", "compiled_sql", "full_refresh")), ], ) def test_dbt_base_operator_template_fields(operator_class, expected_template): From ad8a9b8ee1f8e9f128c8de0716a8fa16cd4bd107 Mon Sep 17 00:00:00 2001 From: ugmuka <55133701+ugmuka@users.noreply.github.com> Date: Wed, 15 Nov 2023 20:55:08 +0900 Subject: [PATCH 017/223] Docs fix: add execution config to MWAA code example (#667) (#674) Add execution config to MWAA code example document. Closes: #667 --- docs/getting_started/mwaa.rst | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/getting_started/mwaa.rst b/docs/getting_started/mwaa.rst index f7a5693021..726cf0cb80 100644 --- a/docs/getting_started/mwaa.rst +++ b/docs/getting_started/mwaa.rst @@ -87,8 +87,9 @@ In your ``my_cosmos_dag.py`` file, import the ``DbtDag`` class from Cosmos and c .. code-block:: python - from cosmos import DbtDag, ProjectConfig, ProfileConfig + from cosmos import DbtDag, ProjectConfig, ProfileConfig, ExecutionConfig from cosmos.profiles import PostgresUserPasswordProfileMapping + from cosmos.constants import ExecutionMode profile_config = ProfileConfig( profile_name="default", @@ -99,11 +100,17 @@ In your ``my_cosmos_dag.py`` file, import the ``DbtDag`` class from Cosmos and c ), ) + execution_config = ExecutionConfig( + dbt_executable_path=f"{os.environ['AIRFLOW_HOME']}/dbt_venv/bin/dbt", + execution_mode=ExecutionMode.VIRTUALENV, + ) + my_cosmos_dag = DbtDag( project_config=ProjectConfig( "", ), profile_config=profile_config, + execution_config=execution_config, # normal dag parameters schedule_interval="@daily", start_date=datetime(2023, 1, 1), From 9c6677184b18322762d0689ac7f7d49d656baa8b Mon Sep 17 00:00:00 2001 From: Justin Bandoro Date: Wed, 15 Nov 2023 15:55:41 -0800 Subject: [PATCH 018/223] fix: test_load_via_dbt_ls_with_invalid_dbt_path if user has system dbt --- cosmos/config.py | 4 +++- tests/dbt/test_graph.py | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/cosmos/config.py b/cosmos/config.py index 57d5200b15..f9d47f5e2e 100644 --- a/cosmos/config.py +++ b/cosmos/config.py @@ -258,7 +258,7 @@ class ExecutionConfig: execution_mode: ExecutionMode = ExecutionMode.LOCAL test_indirect_selection: TestIndirectSelection = TestIndirectSelection.EAGER - dbt_executable_path: str | Path = get_system_dbt() + dbt_executable_path: str | Path | None = None dbt_project_path: InitVar[str | Path | None] = None @@ -266,3 +266,5 @@ class ExecutionConfig: def __post_init__(self, dbt_project_path: str | Path | None) -> None: self.project_path = Path(dbt_project_path) if dbt_project_path else None + if not self.dbt_executable_path: + self.dbt_executable_path = get_system_dbt() diff --git a/tests/dbt/test_graph.py b/tests/dbt/test_graph.py index 607e56641e..0ad7424c84 100644 --- a/tests/dbt/test_graph.py +++ b/tests/dbt/test_graph.py @@ -343,7 +343,8 @@ def test_load_via_dbt_ls_without_profile(mock_validate_dbt_command): assert err_info.value.args[0] == expected -def test_load_via_dbt_ls_with_invalid_dbt_path(): +@patch("cosmos.dbt.executable.shutil.which", return_value=None) +def test_load_via_dbt_ls_with_invalid_dbt_path(mock_which): project_config = ProjectConfig(dbt_project_path=DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME) execution_config = ExecutionConfig(dbt_project_path=DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME) render_config = RenderConfig( From a1af36d30a7a5bf9108daae0475f0a6ff7fc0420 Mon Sep 17 00:00:00 2001 From: Justin Bandoro Date: Wed, 15 Nov 2023 16:03:54 -0800 Subject: [PATCH 019/223] change to default factory --- cosmos/config.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/cosmos/config.py b/cosmos/config.py index f9d47f5e2e..a33e968304 100644 --- a/cosmos/config.py +++ b/cosmos/config.py @@ -258,7 +258,7 @@ class ExecutionConfig: execution_mode: ExecutionMode = ExecutionMode.LOCAL test_indirect_selection: TestIndirectSelection = TestIndirectSelection.EAGER - dbt_executable_path: str | Path | None = None + dbt_executable_path: str | Path = field(default_factory=get_system_dbt) dbt_project_path: InitVar[str | Path | None] = None @@ -266,5 +266,3 @@ class ExecutionConfig: def __post_init__(self, dbt_project_path: str | Path | None) -> None: self.project_path = Path(dbt_project_path) if dbt_project_path else None - if not self.dbt_executable_path: - self.dbt_executable_path = get_system_dbt() From 79aff93a1e1618f0bf1e3523a2de11e3ff26a828 Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Thu, 16 Nov 2023 21:25:15 +0000 Subject: [PATCH 020/223] Release 1.2.4 (#677) **Bug fixes** * Store `compiled_sql` even when task fails by @agreenburg in #671 * Refactor `LoadMethod.LOCAL` to use symlinks instead of copying directory by @jbandoro in #660 * Fix 'Unable to find the dbt executable: dbt' error by @tatiana in #666 * Fix installing deps when using `profile_mapping` & `ExecutionMode.LOCAL` by @joppevos in #659 **Others** * Docs fix: add execution config to MWAA code example by @ugmuka in #674 (cherry picked from commit aa9b7bbc544937c51510ea5e4353208bf466e9d4) --- CHANGELOG.rst | 25 +++++++++++++++++++++++++ cosmos/__init__.py | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f6c714f79b..590b864b0e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -11,6 +11,31 @@ Features * Add ``DbtDocsGCSOperator`` for uploading dbt docs to GCS by @jbandoro in #616 +1.2.4 (2023-11-14) +------------------ + +Bug fixes + +* Store ``compiled_sql`` even when task fails by @agreenburg in #671 +* Refactor ``LoadMethod.LOCAL`` to use symlinks instead of copying directory by @jbandoro in #660 +* Fix 'Unable to find the dbt executable: dbt' error by @tatiana in #666 +* Fix installing deps when using ``profile_mapping`` & ``ExecutionMode.LOCAL`` by @joppevos in #659 + +Others + +* Docs fix: add execution config to MWAA code example by @ugmuka in #674 + + +1.2.3 (2023-11-09) +------------------ + +Features + +* Add ``ProfileMapping`` for Vertica by @perttus in #540 +* Add ``ProfileMapping`` for Snowflake encrypted private key path by @ivanstillfront in #608 +* Add ``DbtDocsGCSOperator`` for uploading dbt docs to GCS by @jbandoro in #616 + + 1.2.2 (2023-11-06) ------------------ diff --git a/cosmos/__init__.py b/cosmos/__init__.py index 18f675750c..f4af1bd632 100644 --- a/cosmos/__init__.py +++ b/cosmos/__init__.py @@ -5,7 +5,7 @@ Contains dags, task groups, and operators. """ -__version__ = "1.3.0a1" +__version__ = "1.2.4" from cosmos.airflow.dag import DbtDag from cosmos.airflow.task_group import DbtTaskGroup From 2f65657e1b14899d196aa41f40aa665036ab3a04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Quang=20B=C3=ACnh?= Date: Fri, 17 Nov 2023 07:08:27 +0700 Subject: [PATCH 021/223] Fix running models that use alias while supporting dbt versions (#662) Current version, cosmos will got bug `Not found node` because it run with alias selection as: `--models customers_abc_v1 ` and `--models customers_abc_v2` . I propose to parsing node selection in `unique_id` instead of using `alias` . So node selection should be: `unique_id.split('.', 2)[2]` , reference to [function](https://github.com/dbt-labs/dbt-core/blob/main/core/dbt/contracts/graph/node_args.py#L26) and [resource-details document](https://docs.getdbt.com/reference/artifacts/manifest-json#resource-details). In addition, with this change help cosmos also support versioned models on dbt-core `>=1.5.0` instead `>=1.6.0` as current version. Cosmos will support dynamic aliases and versioned models Closes: #636 --- cosmos/airflow/graph.py | 12 +- cosmos/dbt/graph.py | 29 +++-- dev/dags/dbt/model_version/models/schema.yml | 4 + tests/airflow/test_graph.py | 119 ++++++++++++------- tests/dbt/test_graph.py | 17 ++- tests/dbt/test_selector.py | 15 +-- tests/test_converter.py | 3 +- 7 files changed, 124 insertions(+), 75 deletions(-) diff --git a/cosmos/airflow/graph.py b/cosmos/airflow/graph.py index 98c6c57816..0288b9e8db 100644 --- a/cosmos/airflow/graph.py +++ b/cosmos/airflow/graph.py @@ -83,11 +83,11 @@ def create_test_task_metadata( task_args["indirect_selection"] = test_indirect_selection.value if node is not None: if node.resource_type == DbtResourceType.MODEL: - task_args["models"] = node.name + task_args["models"] = node.resource_name elif node.resource_type == DbtResourceType.SOURCE: - task_args["select"] = f"source:{node.unique_id[len('source.'):]}" + task_args["select"] = f"source:{node.resource_name}" else: # tested with node.resource_type == DbtResourceType.SEED or DbtResourceType.SNAPSHOT - task_args["select"] = node.name + task_args["select"] = node.resource_name return TaskMetadata( id=test_task_name, operator_class=calculate_operator_class( @@ -108,8 +108,8 @@ def create_task_metadata( :param execution_mode: Where Cosmos should run each dbt task (e.g. ExecutionMode.LOCAL, ExecutionMode.KUBERNETES). Default is ExecutionMode.LOCAL. :param args: Arguments to be used to instantiate an Airflow Task - :param use_name_as_task_id_prefix: If resource_type is DbtResourceType.MODEL, it determines whether - using name as task id prefix or not. If it is True task_id = _run, else task_id=run. + :param use_task_group: It determines whether to use the name as a prefix for the task id or not. + If it is False, then use the name as a prefix for the task id, otherwise do not. :returns: The metadata necessary to instantiate the source dbt node as an Airflow task. """ dbt_resource_to_class = { @@ -119,7 +119,7 @@ def create_task_metadata( DbtResourceType.TEST: "DbtTest", DbtResourceType.SOURCE: "DbtSource", } - args = {**args, **{"models": node.name}} + args = {**args, **{"models": node.resource_name}} if DbtResourceType(node.resource_type) in DEFAULT_DBT_RESOURCES and node.resource_type in dbt_resource_to_class: if node.resource_type == DbtResourceType.MODEL: diff --git a/cosmos/dbt/graph.py b/cosmos/dbt/graph.py index da93a54c40..f154496e5d 100644 --- a/cosmos/dbt/graph.py +++ b/cosmos/dbt/graph.py @@ -42,7 +42,6 @@ class DbtNode: Metadata related to a dbt node (e.g. model, seed, snapshot, source). """ - name: str unique_id: str resource_type: DbtResourceType depends_on: list[str] @@ -52,6 +51,23 @@ class DbtNode: has_freshness: bool = False has_test: bool = False + @property + def resource_name(self) -> str: + """ + Use this property to retrieve the resource name for command generation, for instance: ["dbt", "run", "--models", f"{resource_name}"]. + The unique_id format is defined as [..](https://docs.getdbt.com/reference/artifacts/manifest-json#resource-details). + For a special case like a versioned model, the unique_id follows this pattern: [model...](https://github.com/dbt-labs/dbt-core/blob/main/core/dbt/contracts/graph/node_args.py#L26C3-L31) + """ + return self.unique_id.split(".", 2)[2] + + @property + def name(self) -> str: + """ + Use this property as the task name or task group name. + Replace period (.) with underscore (_) due to versioned models. + """ + return self.resource_name.replace(".", "_") + def is_freshness_effective(freshness: dict[str, Any]) -> bool: """Function to find if a source has null freshness. Scenarios where freshness @@ -116,7 +132,6 @@ def parse_dbt_ls_output(project_path: Path, ls_stdout: str) -> dict[str, DbtNode logger.debug("Skipped dbt ls line: %s", line) else: node = DbtNode( - name=node_dict.get("alias", node_dict["name"]), unique_id=node_dict["unique_id"], resource_type=DbtResourceType(node_dict["resource_type"]), depends_on=node_dict.get("depends_on", {}).get("nodes", []), @@ -232,9 +247,6 @@ def load_via_dbt_ls(self) -> None: This is the most accurate way of loading `dbt` projects and filtering them out, since it uses the `dbt` command line for both parsing and filtering the nodes. - Noted that if dbt project contains versioned models, need to use dbt>=1.6.0 instead. Because, as dbt<1.6.0, - dbt cli doesn't support select a specific versioned models as stg_customers_v1, customers_v1, ... - Updates in-place: * self.nodes * self.filtered_nodes @@ -328,8 +340,7 @@ def load_via_custom_parser(self) -> None: for model_name, model in models: config = {item.split(":")[0]: item.split(":")[-1] for item in model.config.config_selectors} node = DbtNode( - name=model_name, - unique_id=model_name, + unique_id=f"{model.type.value}.{self.project.project_name}.{model_name}", resource_type=DbtResourceType(model.type.value), depends_on=list(model.config.upstream_models), file_path=Path( @@ -362,9 +373,6 @@ def load_from_dbt_manifest(self) -> None: However, since the Manifest does not represent filters, it relies on the Custom Cosmos implementation to filter out the nodes relevant to the user (based on self.exclude and self.select). - Noted that if dbt project contains versioned models, need to use dbt>=1.6.0 instead. Because, as dbt<1.6.0, - dbt cli doesn't support select a specific versioned models as stg_customers_v1, customers_v1, ... - Updates in-place: * self.nodes * self.filtered_nodes @@ -384,7 +392,6 @@ def load_from_dbt_manifest(self) -> None: resources = {**manifest.get("nodes", {}), **manifest.get("sources", {}), **manifest.get("exposures", {})} for unique_id, node_dict in resources.items(): node = DbtNode( - name=node_dict.get("alias", node_dict["name"]), unique_id=unique_id, resource_type=DbtResourceType(node_dict["resource_type"]), depends_on=node_dict.get("depends_on", {}).get("nodes", []), diff --git a/dev/dags/dbt/model_version/models/schema.yml b/dev/dags/dbt/model_version/models/schema.yml index 40a5a4055b..66f1ccedd7 100644 --- a/dev/dags/dbt/model_version/models/schema.yml +++ b/dev/dags/dbt/model_version/models/schema.yml @@ -37,7 +37,11 @@ models: - include: all exclude: - full_name + config: + alias: '{{ "customers_" ~ var("division", "USA") ~ "_v1" }}' - v: 2 + config: + alias: '{{ "customers_" ~ var("division", "USA") ~ "_v2" }}' - name: orders description: This table has basic information about orders, as well as some derived facts based on payments diff --git a/tests/airflow/test_graph.py b/tests/airflow/test_graph.py index 6bc244b6b6..35e313fca1 100644 --- a/tests/airflow/test_graph.py +++ b/tests/airflow/test_graph.py @@ -17,43 +17,57 @@ generate_task_or_group, ) from cosmos.config import ProfileConfig -from cosmos.constants import DbtResourceType, ExecutionMode, TestBehavior, TestIndirectSelection +from cosmos.constants import ( + DbtResourceType, + ExecutionMode, + TestBehavior, + TestIndirectSelection, +) from cosmos.dbt.graph import DbtNode from cosmos.profiles import PostgresUserPasswordProfileMapping SAMPLE_PROJ_PATH = Path("/home/user/path/dbt-proj/") parent_seed = DbtNode( - name="seed_parent", - unique_id="seed_parent", + unique_id=f"{DbtResourceType.SEED.value}.{SAMPLE_PROJ_PATH.stem}.seed_parent", resource_type=DbtResourceType.SEED, depends_on=[], file_path="", ) parent_node = DbtNode( - name="parent", - unique_id="parent", + unique_id=f"{DbtResourceType.MODEL.value}.{SAMPLE_PROJ_PATH.stem}.parent", resource_type=DbtResourceType.MODEL, - depends_on=["seed_parent"], + depends_on=[parent_seed.unique_id], file_path=SAMPLE_PROJ_PATH / "gen2/models/parent.sql", tags=["has_child"], config={"materialized": "view"}, has_test=True, ) test_parent_node = DbtNode( - name="test_parent", unique_id="test_parent", resource_type=DbtResourceType.TEST, depends_on=["parent"], file_path="" + unique_id=f"{DbtResourceType.TEST.value}.{SAMPLE_PROJ_PATH.stem}.test_parent", + resource_type=DbtResourceType.TEST, + depends_on=[parent_node.unique_id], + file_path="", ) child_node = DbtNode( - name="child", - unique_id="child", + unique_id=f"{DbtResourceType.MODEL.value}.{SAMPLE_PROJ_PATH.stem}.child", resource_type=DbtResourceType.MODEL, - depends_on=["parent"], + depends_on=[parent_node.unique_id], file_path=SAMPLE_PROJ_PATH / "gen3/models/child.sql", tags=["nightly"], config={"materialized": "table"}, ) -sample_nodes_list = [parent_seed, parent_node, test_parent_node, child_node] +child2_node = DbtNode( + unique_id=f"{DbtResourceType.MODEL.value}.{SAMPLE_PROJ_PATH.stem}.child2.v2", + resource_type=DbtResourceType.MODEL, + depends_on=[parent_node.unique_id], + file_path=SAMPLE_PROJ_PATH / "gen3/models/child2_v2.sql", + tags=["nightly"], + config={"materialized": "table"}, +) + +sample_nodes_list = [parent_seed, parent_node, test_parent_node, child_node, child2_node] sample_nodes = {node.unique_id: node for node in sample_nodes_list} @@ -91,6 +105,7 @@ def test_build_airflow_graph_with_after_each(): "parent.run", "parent.test", "child_run", + "child2_v2_run", ] assert topological_sort == expected_sort @@ -100,15 +115,16 @@ def test_build_airflow_graph_with_after_each(): assert task_groups["parent"].upstream_task_ids == {"seed_parent_seed"} assert list(task_groups["parent"].children.keys()) == ["parent.run", "parent.test"] - assert len(dag.leaves) == 1 + assert len(dag.leaves) == 2 assert dag.leaves[0].task_id == "child_run" + assert dag.leaves[1].task_id == "child2_v2_run" @pytest.mark.parametrize( "node_type,task_suffix", [(DbtResourceType.MODEL, "run"), (DbtResourceType.SEED, "seed"), (DbtResourceType.SNAPSHOT, "snapshot")], ) -def test_create_task_group_for_after_each_supported_nodes(node_type, task_suffix): +def test_create_task_group_for_after_each_supported_nodes(node_type: DbtResourceType, task_suffix): """ dbt test runs tests defined on models, sources, snapshots, and seeds. It expects that you have already created those resources through the appropriate commands. @@ -116,8 +132,7 @@ def test_create_task_group_for_after_each_supported_nodes(node_type, task_suffix """ with DAG("test-task-group-after-each", start_date=datetime(2022, 1, 1)) as dag: node = DbtNode( - name="dbt_node", - unique_id="dbt_node", + unique_id=f"{node_type.value}.{SAMPLE_PROJ_PATH.stem}.dbt_node", resource_type=node_type, file_path=SAMPLE_PROJ_PATH / "gen2/models/parent.sql", tags=["has_child"], @@ -178,7 +193,7 @@ def test_build_airflow_graph_with_after_all(): dbt_project_name="astro_shop", ) topological_sort = [task.task_id for task in dag.topological_sort()] - expected_sort = ["seed_parent_seed", "parent_run", "child_run", "astro_shop_test"] + expected_sort = ["seed_parent_seed", "parent_run", "child_run", "child2_v2_run", "astro_shop_test"] assert topological_sort == expected_sort task_groups = dag.task_group_dict @@ -195,8 +210,7 @@ def test_calculate_operator_class(): def test_calculate_leaves(): grandparent_node = DbtNode( - name="grandparent", - unique_id="grandparent", + unique_id=f"{DbtResourceType.MODEL.value}.{SAMPLE_PROJ_PATH.stem}.grandparent", resource_type=DbtResourceType.MODEL, depends_on=[], file_path="", @@ -204,28 +218,25 @@ def test_calculate_leaves(): config={}, ) parent1_node = DbtNode( - name="parent1", - unique_id="parent1", + unique_id=f"{DbtResourceType.MODEL.value}.{SAMPLE_PROJ_PATH.stem}.parent1", resource_type=DbtResourceType.MODEL, - depends_on=["grandparent"], + depends_on=[grandparent_node.unique_id], file_path="", tags=[], config={}, ) parent2_node = DbtNode( - name="parent2", - unique_id="parent2", + unique_id=f"{DbtResourceType.MODEL.value}.{SAMPLE_PROJ_PATH.stem}.parent2", resource_type=DbtResourceType.MODEL, - depends_on=["grandparent"], + depends_on=[parent1_node.unique_id], file_path="", tags=[], config={}, ) child_node = DbtNode( - name="child", - unique_id="child", + unique_id=f"{DbtResourceType.MODEL.value}.{SAMPLE_PROJ_PATH.stem}.child", resource_type=DbtResourceType.MODEL, - depends_on=["parent1", "parent2"], + depends_on=[parent1_node.unique_id, parent2_node.unique_id], file_path="", tags=[], config={}, @@ -235,14 +246,13 @@ def test_calculate_leaves(): nodes = {node.unique_id: node for node in nodes_list} leaves = calculate_leaves(nodes.keys(), nodes) - assert leaves == ["child"] + assert leaves == [f"{DbtResourceType.MODEL.value}.{SAMPLE_PROJ_PATH.stem}.child"] @patch("cosmos.airflow.graph.logger.propagate", True) def test_create_task_metadata_unsupported(caplog): child_node = DbtNode( - name="unsupported", - unique_id="unsupported", + unique_id=f"unsupported.{SAMPLE_PROJ_PATH.stem}.unsupported", resource_type="unsupported", depends_on=[], file_path="", @@ -252,7 +262,7 @@ def test_create_task_metadata_unsupported(caplog): response = create_task_metadata(child_node, execution_mode="", args={}) assert response is None expected_msg = ( - "Unavailable conversion function for (node ). " + "Unavailable conversion function for (node ). " "Define a converter function using render_config.node_converters." ) assert caplog.messages[0] == expected_msg @@ -260,8 +270,7 @@ def test_create_task_metadata_unsupported(caplog): def test_create_task_metadata_model(caplog): child_node = DbtNode( - name="my_model", - unique_id="my_folder.my_model", + unique_id=f"{DbtResourceType.MODEL.value}.my_folder.my_model", resource_type=DbtResourceType.MODEL, depends_on=[], file_path="", @@ -274,10 +283,24 @@ def test_create_task_metadata_model(caplog): assert metadata.arguments == {"models": "my_model"} +def test_create_task_metadata_model_with_versions(caplog): + child_node = DbtNode( + unique_id=f"{DbtResourceType.MODEL.value}.my_folder.my_model.v1", + resource_type=DbtResourceType.MODEL, + depends_on=[], + file_path="", + tags=[], + config={}, + ) + metadata = create_task_metadata(child_node, execution_mode=ExecutionMode.LOCAL, args={}) + assert metadata.id == "my_model_v1_run" + assert metadata.operator_class == "cosmos.operators.local.DbtRunLocalOperator" + assert metadata.arguments == {"models": "my_model.v1"} + + def test_create_task_metadata_model_use_task_group(caplog): child_node = DbtNode( - name="my_model", - unique_id="my_folder.my_model", + unique_id=f"{DbtResourceType.MODEL.value}.my_folder.my_model", resource_type=DbtResourceType.MODEL, depends_on=[], file_path=Path(""), @@ -291,8 +314,7 @@ def test_create_task_metadata_model_use_task_group(caplog): @pytest.mark.parametrize("use_task_group", (None, True, False)) def test_create_task_metadata_seed(caplog, use_task_group): sample_node = DbtNode( - name="my_seed", - unique_id="my_folder.my_seed", + unique_id=f"{DbtResourceType.SEED.value}.my_folder.my_seed", resource_type=DbtResourceType.SEED, depends_on=[], file_path="", @@ -320,8 +342,7 @@ def test_create_task_metadata_seed(caplog, use_task_group): def test_create_task_metadata_snapshot(caplog): sample_node = DbtNode( - name="my_snapshot", - unique_id="my_folder.my_snapshot", + unique_id=f"{DbtResourceType.SNAPSHOT.value}.my_folder.my_snapshot", resource_type=DbtResourceType.SNAPSHOT, depends_on=[], file_path="", @@ -337,22 +358,33 @@ def test_create_task_metadata_snapshot(caplog): @pytest.mark.parametrize( "node_type,node_unique_id,test_indirect_selection,additional_arguments", [ - (DbtResourceType.MODEL, "node_name", TestIndirectSelection.EAGER, {"models": "node_name"}), + ( + DbtResourceType.MODEL, + f"{DbtResourceType.MODEL.value}.my_folder.node_name", + TestIndirectSelection.EAGER, + {"models": "node_name"}, + ), + ( + DbtResourceType.MODEL, + f"{DbtResourceType.MODEL.value}.my_folder.node_name.v1", + TestIndirectSelection.EAGER, + {"models": "node_name.v1"}, + ), ( DbtResourceType.SEED, - "node_name", + f"{DbtResourceType.SEED.value}.my_folder.node_name", TestIndirectSelection.CAUTIOUS, {"select": "node_name", "indirect_selection": "cautious"}, ), ( DbtResourceType.SOURCE, - "source.node_name", + f"{DbtResourceType.SOURCE.value}.my_folder.node_name", TestIndirectSelection.BUILDABLE, {"select": "source:node_name", "indirect_selection": "buildable"}, ), ( DbtResourceType.SNAPSHOT, - "node_name", + f"{DbtResourceType.SNAPSHOT.value}.my_folder.node_name", TestIndirectSelection.EMPTY, {"select": "node_name", "indirect_selection": "empty"}, ), @@ -360,7 +392,6 @@ def test_create_task_metadata_snapshot(caplog): ) def test_create_test_task_metadata(node_type, node_unique_id, test_indirect_selection, additional_arguments): sample_node = DbtNode( - name="node_name", unique_id=node_unique_id, resource_type=node_type, depends_on=[], diff --git a/tests/dbt/test_graph.py b/tests/dbt/test_graph.py index 0ad7424c84..3e32182596 100644 --- a/tests/dbt/test_graph.py +++ b/tests/dbt/test_graph.py @@ -12,8 +12,8 @@ DbtGraph, DbtNode, LoadMode, - run_command, parse_dbt_ls_output, + run_command, ) from cosmos.profiles import PostgresUserPasswordProfileMapping @@ -42,6 +42,20 @@ def tmp_dbt_project_dir(): shutil.rmtree(tmp_dir, ignore_errors=True) # delete directory +@pytest.mark.parametrize( + "unique_id,expected_name, expected_select", + [ + ("model.my_project.customers", "customers", "customers"), + ("model.my_project.customers.v1", "customers_v1", "customers.v1"), + ("model.my_project.orders.v2", "orders_v2", "orders.v2"), + ], +) +def test_dbt_node_name_and_select(unique_id, expected_name, expected_select): + node = DbtNode(unique_id=unique_id, resource_type=DbtResourceType.MODEL, depends_on=[], file_path="") + assert node.name == expected_name + assert node.resource_name == expected_select + + @pytest.mark.parametrize( "project_name,manifest_filepath,model_filepath", [(DBT_PROJECT_NAME, SAMPLE_MANIFEST, "customers.sql"), ("jaffle_shop_python", SAMPLE_MANIFEST_PY, "customers.py")], @@ -692,7 +706,6 @@ def test_parse_dbt_ls_output(): expected_nodes = { "fake-unique-id": DbtNode( - name="fake-name", unique_id="fake-unique-id", resource_type=DbtResourceType.MODEL, file_path=Path("fake-project/fake-file-path.sql"), diff --git a/tests/dbt/test_selector.py b/tests/dbt/test_selector.py index 9f6071a20b..f7ece63910 100644 --- a/tests/dbt/test_selector.py +++ b/tests/dbt/test_selector.py @@ -39,8 +39,7 @@ def test_is_empty_config(selector_config, paths, tags, config, other, expected): grandparent_node = DbtNode( - name="grandparent", - unique_id="grandparent", + unique_id=f"{DbtResourceType.MODEL.value}.{SAMPLE_PROJ_PATH.stem}.grandparent", resource_type=DbtResourceType.MODEL, depends_on=[], file_path=SAMPLE_PROJ_PATH / "gen1/models/grandparent.sql", @@ -48,8 +47,7 @@ def test_is_empty_config(selector_config, paths, tags, config, other, expected): config={"materialized": "view", "tags": ["has_child"]}, ) parent_node = DbtNode( - name="parent", - unique_id="parent", + unique_id=f"{DbtResourceType.MODEL.value}.{SAMPLE_PROJ_PATH.stem}.parent", resource_type=DbtResourceType.MODEL, depends_on=["grandparent"], file_path=SAMPLE_PROJ_PATH / "gen2/models/parent.sql", @@ -57,8 +55,7 @@ def test_is_empty_config(selector_config, paths, tags, config, other, expected): config={"materialized": "view", "tags": ["has_child", "is_child"]}, ) child_node = DbtNode( - name="child", - unique_id="child", + unique_id=f"{DbtResourceType.MODEL.value}.{SAMPLE_PROJ_PATH.stem}.child", resource_type=DbtResourceType.MODEL, depends_on=["parent"], file_path=SAMPLE_PROJ_PATH / "gen3/models/child.sql", @@ -67,8 +64,7 @@ def test_is_empty_config(selector_config, paths, tags, config, other, expected): ) grandchild_1_test_node = DbtNode( - name="grandchild_1", - unique_id="grandchild_1", + unique_id=f"{DbtResourceType.MODEL.value}.{SAMPLE_PROJ_PATH.stem}.grandchild_1", resource_type=DbtResourceType.MODEL, depends_on=["parent"], file_path=SAMPLE_PROJ_PATH / "gen3/models/grandchild_1.sql", @@ -77,8 +73,7 @@ def test_is_empty_config(selector_config, paths, tags, config, other, expected): ) grandchild_2_test_node = DbtNode( - name="grandchild_2", - unique_id="grandchild_2", + unique_id=f"{DbtResourceType.MODEL.value}.{SAMPLE_PROJ_PATH.stem}.grandchild_2", resource_type=DbtResourceType.MODEL, depends_on=["parent"], file_path=SAMPLE_PROJ_PATH / "gen3/models/grandchild_2.sql", diff --git a/tests/test_converter.py b/tests/test_converter.py index 4210b24d64..8b51010618 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -31,8 +31,7 @@ def test_validate_arguments_tags(argument_key): parent_seed = DbtNode( - name="seed_parent", - unique_id="seed_parent", + unique_id=f"{DbtResourceType.SEED}.{SAMPLE_DBT_PROJECT.stem}.seed_parent", resource_type=DbtResourceType.SEED, depends_on=[], file_path="", From 419ebcae694330f0000bbf974d4d26ab95c0706d Mon Sep 17 00:00:00 2001 From: MrBones757 Date: Sat, 18 Nov 2023 16:04:26 +0800 Subject: [PATCH 022/223] Make `profiles_yml_path` optional for `ExecutionMode.DOCKER` and `KUBERNETES` (#681) This PR moves validation of the `profiles_yml_path` to later in the dag generation process such that additional context can be gathered from `ExecutionConfig` to avoid failing unnecessarily when the file does not exist. Closes: #680 Closes: #656 --- cosmos/config.py | 18 ++++++++++++------ cosmos/converter.py | 28 ++++++++++++++++++++-------- tests/test_config.py | 31 ++++++++++++++++++++++++------- tests/test_converter.py | 22 ++++++++++++++++++++-- 4 files changed, 76 insertions(+), 23 deletions(-) diff --git a/cosmos/config.py b/cosmos/config.py index a33e968304..5c64193c18 100644 --- a/cosmos/config.py +++ b/cosmos/config.py @@ -41,8 +41,8 @@ class RenderConfig: :param exclude: A list of dbt exclude arguments (e.g. 'tag:nightly') :param dbt_deps: Configure to run dbt deps when using dbt ls for dag parsing :param node_converters: a dictionary mapping a ``DbtResourceType`` into a callable. Users can control how to render dbt nodes in Airflow. Only supported when using ``load_method=LoadMode.DBT_MANIFEST`` or ``LoadMode.DBT_LS``. - :param dbt_executable_path: The path to the dbt executable for dag generation. Defaults to dbt if available on the path. Mutually Exclusive with ProjectConfig.dbt_project_path - :param dbt_project_path Configures the DBT project location accessible on the airflow controller for DAG rendering - Required when using ``load_method=LoadMode.DBT_LS`` or ``load_method=LoadMode.CUSTOM`` + :param dbt_executable_path: The path to the dbt executable for dag generation. Defaults to dbt if available on the path. + :param dbt_project_path Configures the DBT project location accessible on the airflow controller for DAG rendering. Mutually Exclusive with ProjectConfig.dbt_project_path. Required when using ``load_method=LoadMode.DBT_LS`` or ``load_method=LoadMode.CUSTOM``. """ emit_datasets: bool = True @@ -195,15 +195,21 @@ class ProfileConfig: profile_mapping: BaseProfileMapping | None = None def __post_init__(self) -> None: - "Validates that we have enough information to render a profile." - # if using a user-supplied profiles.yml, validate that it exists - if self.profiles_yml_filepath and not Path(self.profiles_yml_filepath).exists(): - raise CosmosValueError(f"The file {self.profiles_yml_filepath} does not exist.") + self.validate_profile() def validate_profile(self) -> None: "Validates that we have enough information to render a profile." if not self.profiles_yml_filepath and not self.profile_mapping: raise CosmosValueError("Either profiles_yml_filepath or profile_mapping must be set to render a profile") + if self.profiles_yml_filepath and self.profile_mapping: + raise CosmosValueError( + "Both profiles_yml_filepath and profile_mapping are defined and are mutually exclusive. Ensure only one of these is defined." + ) + + def validate_profiles_yml(self) -> None: + "Validates a user-supplied profiles.yml is present" + if self.profiles_yml_filepath and not Path(self.profiles_yml_filepath).exists(): + raise CosmosValueError(f"The file {self.profiles_yml_filepath} does not exist.") @contextlib.contextmanager def ensure_profile( diff --git a/cosmos/converter.py b/cosmos/converter.py index 559b7ea69b..2142cc6e42 100644 --- a/cosmos/converter.py +++ b/cosmos/converter.py @@ -11,6 +11,7 @@ from airflow.utils.task_group import TaskGroup from cosmos.airflow.graph import build_airflow_graph +from cosmos.constants import ExecutionMode from cosmos.dbt.graph import DbtGraph from cosmos.dbt.selector import retrieve_by_label from cosmos.config import ProjectConfig, ExecutionConfig, RenderConfig, ProfileConfig @@ -49,7 +50,11 @@ def airflow_kwargs(**kwargs: dict[str, Any]) -> dict[str, Any]: def validate_arguments( - select: list[str], exclude: list[str], profile_args: dict[str, Any], task_args: dict[str, Any] + select: list[str], + exclude: list[str], + profile_config: ProfileConfig, + task_args: dict[str, Any], + execution_mode: ExecutionMode, ) -> None: """ Validate that mutually exclusive selectors filters have not been given. @@ -57,8 +62,9 @@ def validate_arguments( :param select: A list of dbt select arguments (e.g. 'config.materialized:incremental') :param exclude: A list of dbt exclude arguments (e.g. 'tag:nightly') - :param profile_args: Arguments to pass to the dbt profile + :param profile_config: ProfileConfig Object :param task_args: Arguments to be used to instantiate an Airflow Task + :param execution_mode: the current execution mode """ for field in ("tags", "paths"): select_items = retrieve_by_label(select, field) @@ -69,8 +75,12 @@ def validate_arguments( # if task_args has a schema, add it to the profile args and add a deprecated warning if "schema" in task_args: - profile_args["schema"] = task_args["schema"] logger.warning("Specifying a schema in the `task_args` is deprecated. Please use the `profile_args` instead.") + if profile_config.profile_mapping: + profile_config.profile_mapping.profile_args["schema"] = task_args["schema"] + + if execution_mode in [ExecutionMode.LOCAL, ExecutionMode.VIRTUALENV]: + profile_config.validate_profiles_yml() class DbtToAirflowConverter: @@ -139,10 +149,6 @@ def __init__( "RenderConfig.dbt_project_path is required for rendering an airflow DAG from a DBT Graph if no manifest is provided." ) - profile_args = {} - if profile_config.profile_mapping: - profile_args = profile_config.profile_mapping.profile_args - if not operator_args: operator_args = {} @@ -174,7 +180,13 @@ def __init__( if execution_config.dbt_executable_path: task_args["dbt_executable_path"] = execution_config.dbt_executable_path - validate_arguments(render_config.select, render_config.exclude, profile_args, task_args) + validate_arguments( + render_config.select, + render_config.exclude, + profile_config, + task_args, + execution_mode=execution_config.execution_mode, + ) build_airflow_graph( nodes=dbt_graph.filtered_nodes, diff --git a/tests/test_config.py b/tests/test_config.py index cc0711043c..578a68f760 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,5 +1,6 @@ from pathlib import Path from unittest.mock import patch +from cosmos.profiles.postgres.user_pass import PostgresUserPasswordProfileMapping import pytest @@ -8,6 +9,7 @@ DBT_PROJECTS_ROOT_DIR = Path(__file__).parent / "sample/" +SAMPLE_PROFILE_YML = Path(__file__).parent / "sample/profiles.yml" PIPELINE_FOLDER = "jaffle_shop" @@ -111,17 +113,32 @@ def test_project_name(): assert dbt_project.project_name == "sample" -def test_profile_config_post_init(): +def test_profile_config_validate_none(): with pytest.raises(CosmosValueError) as err_info: - ProfileConfig(profiles_yml_filepath="/tmp/some-profile", profile_name="test", target_name="test") - assert err_info.value.args[0] == "The file /tmp/some-profile does not exist." + ProfileConfig(profile_name="test", target_name="test") + assert err_info.value.args[0] == "Either profiles_yml_filepath or profile_mapping must be set to render a profile" -def test_profile_config_validate(): +def test_profile_config_validate_both(): with pytest.raises(CosmosValueError) as err_info: - profile_config = ProfileConfig(profile_name="test", target_name="test") - assert profile_config.validate_profile() is None - assert err_info.value.args[0] == "Either profiles_yml_filepath or profile_mapping must be set to render a profile" + ProfileConfig( + profile_name="test", + target_name="test", + profiles_yml_filepath=SAMPLE_PROFILE_YML, + profile_mapping=PostgresUserPasswordProfileMapping(conn_id="test", profile_args={}), + ) + assert ( + err_info.value.args[0] + == "Both profiles_yml_filepath and profile_mapping are defined and are mutually exclusive. Ensure only one of these is defined." + ) + + +def test_profile_config_validate_profiles_yml(): + profile_config = ProfileConfig(profile_name="test", target_name="test", profiles_yml_filepath="/tmp/no-exists") + with pytest.raises(CosmosValueError) as err_info: + profile_config.validate_profiles_yml() + + assert err_info.value.args[0] == "The file /tmp/no-exists does not exist." @patch("cosmos.config.shutil.which", return_value=None) diff --git a/tests/test_converter.py b/tests/test_converter.py index 8b51010618..3bb5af163e 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -1,6 +1,7 @@ from datetime import datetime from pathlib import Path from unittest.mock import patch +from cosmos.profiles.postgres import PostgresUserPasswordProfileMapping import pytest from airflow.models import DAG @@ -22,14 +23,31 @@ def test_validate_arguments_tags(argument_key): selector_name = argument_key[:-1] select = [f"{selector_name}:a,{selector_name}:b"] exclude = [f"{selector_name}:b,{selector_name}:c"] - profile_args = {} + profile_config = ProfileConfig( + profile_name="test", + target_name="test", + profile_mapping=PostgresUserPasswordProfileMapping(conn_id="test", profile_args={}), + ) task_args = {} with pytest.raises(CosmosValueError) as err: - validate_arguments(select, exclude, profile_args, task_args) + validate_arguments(select, exclude, profile_config, task_args, execution_mode=ExecutionMode.LOCAL) expected = f"Can't specify the same {selector_name} in `select` and `exclude`: {{'b'}}" assert err.value.args[0] == expected +def test_validate_arguments_schema_in_task_args(): + profile_config = ProfileConfig( + profile_name="test", + target_name="test", + profile_mapping=PostgresUserPasswordProfileMapping(conn_id="test", profile_args={}), + ) + task_args = {"schema": "abcd"} + validate_arguments( + select=[], exclude=[], profile_config=profile_config, task_args=task_args, execution_mode=ExecutionMode.LOCAL + ) + assert profile_config.profile_mapping.profile_args["schema"] == "abcd" + + parent_seed = DbtNode( unique_id=f"{DbtResourceType.SEED}.{SAMPLE_DBT_PROJECT.stem}.seed_parent", resource_type=DbtResourceType.SEED, From 1fa174c2d87cfb822038abfeb2dcaa17f61a4bec Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 21 Nov 2023 12:44:07 -0500 Subject: [PATCH 023/223] =?UTF-8?q?=E2=AC=86=20[pre-commit.ci]=20pre-commi?= =?UTF-8?q?t=20autoupdate=20(#697)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.1.5 → v0.1.6](https://github.com/astral-sh/ruff-pre-commit/compare/v0.1.5...v0.1.6) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9cd8cefee7..a631ab91c8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -53,7 +53,7 @@ repos: - --py37-plus - --keep-runtime-typing - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.5 + rev: v0.1.6 hooks: - id: ruff args: From e3ec852d4611710ae69f2e011e0e91a70720abb5 Mon Sep 17 00:00:00 2001 From: Perttu Salonen Date: Tue, 21 Nov 2023 21:09:31 +0200 Subject: [PATCH 024/223] FIX: vertica profile username (#688) ## Description Vertica requires `username` instead of `user`. Without the fix Vertica profile doesn't work, tested with version 1.3.0a1 ## Related Issue(s) #538 #701 Signed-off-by: Perttu Salonen --- cosmos/profiles/vertica/user_pass.py | 8 ++++---- tests/profiles/vertica/test_vertica_user_pass.py | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/cosmos/profiles/vertica/user_pass.py b/cosmos/profiles/vertica/user_pass.py index 494185e051..ccaaf301dc 100644 --- a/cosmos/profiles/vertica/user_pass.py +++ b/cosmos/profiles/vertica/user_pass.py @@ -1,4 +1,4 @@ -"Maps Airflow Vertica connections using user + password authentication to dbt profiles." +"Maps Airflow Vertica connections using username + password authentication to dbt profiles." from __future__ import annotations from typing import Any @@ -8,7 +8,7 @@ class VerticaUserPasswordProfileMapping(BaseProfileMapping): """ - Maps Airflow Vertica connections using user + password authentication to dbt profiles. + Maps Airflow Vertica connections using username + password authentication to dbt profiles. https://docs.getdbt.com/reference/warehouse-setups/vertica-setup https://airflow.apache.org/docs/apache-airflow-providers-vertica/stable/connections/vertica.html """ @@ -18,7 +18,7 @@ class VerticaUserPasswordProfileMapping(BaseProfileMapping): required_fields = [ "host", - "user", + "username", "password", "database", "schema", @@ -28,7 +28,7 @@ class VerticaUserPasswordProfileMapping(BaseProfileMapping): ] airflow_param_mapping = { "host": "host", - "user": "login", + "username": "login", "password": "password", "port": "port", "schema": "schema", diff --git a/tests/profiles/vertica/test_vertica_user_pass.py b/tests/profiles/vertica/test_vertica_user_pass.py index 953a3c553f..19771c799b 100644 --- a/tests/profiles/vertica/test_vertica_user_pass.py +++ b/tests/profiles/vertica/test_vertica_user_pass.py @@ -59,7 +59,7 @@ def test_connection_claiming() -> None: # - conn_type == vertica # and the following exist: # - host - # - user + # - username # - password # - port # - database or database @@ -142,7 +142,7 @@ def test_profile_args( assert profile_mapping.profile == { "type": mock_vertica_conn.conn_type, "host": mock_vertica_conn.host, - "user": mock_vertica_conn.login, + "username": mock_vertica_conn.login, "password": "{{ env_var('COSMOS_CONN_VERTICA_PASSWORD') }}", "port": mock_vertica_conn.port, "schema": "my_schema", @@ -168,7 +168,7 @@ def test_profile_args_overrides( assert profile_mapping.profile == { "type": mock_vertica_conn.conn_type, "host": mock_vertica_conn.host, - "user": mock_vertica_conn.login, + "username": mock_vertica_conn.login, "password": "{{ env_var('COSMOS_CONN_VERTICA_PASSWORD') }}", "port": mock_vertica_conn.port, "database": "my_db_override", From 060cb2f69ff6c34e0857a7cdafebf924a80ba152 Mon Sep 17 00:00:00 2001 From: David Spulak Date: Wed, 22 Nov 2023 03:31:27 +0100 Subject: [PATCH 025/223] Add support for Kubernetes `on_warning_callback` (#673) To make `on_warning_callback` work with pod operators, we need to read the logs of the dbt test runs. This is done by ensuring the pod is kept alive, and `on_success_callback` the log is read and analysed for warnings. Afterwards, the pod is cleaned up based on the original settings from the user. If `on_warning_callback` is not set, everything stays the way it always was. This feature only work with `apache-airflow-providers-cncf-kubernetes >= 7.4.0`. --- cosmos/operators/kubernetes.py | 100 +++++++++++++++++++++++- tests/operators/test_kubernetes.py | 119 ++++++++++++++++++++++++++++- 2 files changed, 214 insertions(+), 5 deletions(-) diff --git a/cosmos/operators/kubernetes.py b/cosmos/operators/kubernetes.py index 996bbc9dda..af0988a6ac 100644 --- a/cosmos/operators/kubernetes.py +++ b/cosmos/operators/kubernetes.py @@ -4,12 +4,17 @@ from typing import Any, Callable, Sequence import yaml -from airflow.utils.context import Context +from airflow.utils.context import Context, context_merge from cosmos.log import get_logger from cosmos.config import ProfileConfig from cosmos.operators.base import DbtBaseOperator +from airflow.models import TaskInstance +from cosmos.dbt.parser.output import extract_log_issues + +DBT_NO_TESTS_MSG = "Nothing to do" +DBT_WARN_MSG = "WARN" logger = get_logger(__name__) @@ -19,6 +24,7 @@ convert_env_vars, ) from airflow.providers.cncf.kubernetes.operators.pod import KubernetesPodOperator + from airflow.providers.cncf.kubernetes.utils.pod_manager import OnFinishAction except ImportError: try: # apache-airflow-providers-cncf-kubernetes < 7.4.0 @@ -158,10 +164,96 @@ class DbtTestKubernetesOperator(DbtKubernetesBaseOperator): ui_color = "#8194E0" def __init__(self, on_warning_callback: Callable[..., Any] | None = None, **kwargs: Any) -> None: - super().__init__(**kwargs) + if not on_warning_callback: + super().__init__(**kwargs) + else: + self.on_warning_callback = on_warning_callback + self.is_delete_operator_pod_original = kwargs.get("is_delete_operator_pod", None) + if self.is_delete_operator_pod_original is not None: + self.on_finish_action_original = ( + OnFinishAction.DELETE_POD if self.is_delete_operator_pod_original else OnFinishAction.KEEP_POD + ) + else: + self.on_finish_action_original = OnFinishAction(kwargs.get("on_finish_action", "delete_pod")) + self.is_delete_operator_pod_original = self.on_finish_action_original == OnFinishAction.DELETE_POD + # In order to read the pod logs, we need to keep the pod around. + # Depending on the on_finish_action & is_delete_operator_pod settings, + # we will clean up the pod later in the _handle_warnings method, which + # is called in on_success_callback. + kwargs["is_delete_operator_pod"] = False + kwargs["on_finish_action"] = OnFinishAction.KEEP_POD + + # Add an additional callback to both success and failure callbacks. + # In case of success, check for a warning in the logs and clean up the pod. + self.on_success_callback = kwargs.get("on_success_callback", None) or [] + if isinstance(self.on_success_callback, list): + self.on_success_callback += [self._handle_warnings] + else: + self.on_success_callback = [self.on_success_callback, self._handle_warnings] + kwargs["on_success_callback"] = self.on_success_callback + # In case of failure, clean up the pod. + self.on_failure_callback = kwargs.get("on_failure_callback", None) or [] + if isinstance(self.on_failure_callback, list): + self.on_failure_callback += [self._cleanup_pod] + else: + self.on_failure_callback = [self.on_failure_callback, self._cleanup_pod] + kwargs["on_failure_callback"] = self.on_failure_callback + + super().__init__(**kwargs) + self.base_cmd = ["test"] - # as of now, on_warning_callback in kubernetes executor does nothing - self.on_warning_callback = on_warning_callback + + def _handle_warnings(self, context: Context) -> None: + """ + Handles warnings by extracting log issues, creating additional context, and calling the + on_warning_callback with the updated context. + + :param context: The original airflow context in which the build and run command was executed. + """ + if not ( + isinstance(context["task_instance"], TaskInstance) + and isinstance(context["task_instance"].task, DbtTestKubernetesOperator) + ): + return + task = context["task_instance"].task + logs = [ + log.decode("utf-8") for log in task.pod_manager.read_pod_logs(task.pod, "base") if log.decode("utf-8") != "" + ] + + should_trigger_callback = all( + [ + logs, + self.on_warning_callback, + DBT_NO_TESTS_MSG not in logs[-1], + DBT_WARN_MSG in logs[-1], + ] + ) + + if should_trigger_callback: + warnings = int(logs[-1].split(f"{DBT_WARN_MSG}=")[1].split()[0]) + if warnings > 0: + test_names, test_results = extract_log_issues(logs) + context_merge(context, test_names=test_names, test_results=test_results) + self.on_warning_callback(context) + + self._cleanup_pod(context) + + def _cleanup_pod(self, context: Context) -> None: + """ + Handles the cleaning up of the pod after success or failure, if + there is a on_warning_callback function defined. + + :param context: The original airflow context in which the build and run command was executed. + """ + if not ( + isinstance(context["task_instance"], TaskInstance) + and isinstance(context["task_instance"].task, DbtTestKubernetesOperator) + ): + return + task = context["task_instance"].task + if task.pod: + task.on_finish_action = self.on_finish_action_original + task.cleanup(pod=task.pod, remote_pod=task.remote_pod) class DbtRunOperationKubernetesOperator(DbtKubernetesBaseOperator): diff --git a/tests/operators/test_kubernetes.py b/tests/operators/test_kubernetes.py index 7ef606cfe4..585b1ab322 100644 --- a/tests/operators/test_kubernetes.py +++ b/tests/operators/test_kubernetes.py @@ -1,7 +1,7 @@ from pathlib import Path from unittest.mock import MagicMock, patch -from airflow.utils.context import Context +import pytest from pendulum import datetime from cosmos.operators.kubernetes import ( @@ -12,6 +12,16 @@ DbtTestKubernetesOperator, ) +from airflow.utils.context import Context, context_merge +from airflow.models import TaskInstance + +try: + from airflow.providers.cncf.kubernetes.utils.pod_manager import OnFinishAction + + module_available = True +except ImportError: + module_available = False + def test_dbt_kubernetes_operator_add_global_flags() -> None: dbt_kube_operator = DbtKubernetesBaseOperator( @@ -103,6 +113,113 @@ def test_dbt_kubernetes_build_command(): ] +@pytest.mark.parametrize( + "additional_kwargs,expected_results", + [ + ({"on_success_callback": None, "is_delete_operator_pod": True}, (1, 1, True, "delete_pod")), + ( + {"on_success_callback": (lambda **kwargs: None), "is_delete_operator_pod": False}, + (2, 1, False, "keep_pod"), + ), + ( + {"on_success_callback": [(lambda **kwargs: None), (lambda **kwargs: None)], "is_delete_operator_pod": None}, + (3, 1, True, "delete_pod"), + ), + ( + {"on_failure_callback": None, "is_delete_operator_pod": True, "on_finish_action": "keep_pod"}, + (1, 1, True, "delete_pod"), + ), + ( + { + "on_failure_callback": (lambda **kwargs: None), + "is_delete_operator_pod": None, + "on_finish_action": "delete_pod", + }, + (1, 2, True, "delete_pod"), + ), + ( + { + "on_failure_callback": [(lambda **kwargs: None), (lambda **kwargs: None)], + "is_delete_operator_pod": None, + "on_finish_action": "delete_succeeded_pod", + }, + (1, 3, False, "delete_succeeded_pod"), + ), + ({"is_delete_operator_pod": None, "on_finish_action": "keep_pod"}, (1, 1, False, "keep_pod")), + ({}, (1, 1, True, "delete_pod")), + ], +) +@pytest.mark.skipif( + not module_available, reason="Kubernetes module `airflow.providers.cncf.kubernetes.utils.pod_manager` not available" +) +def test_dbt_test_kubernetes_operator_constructor(additional_kwargs, expected_results): + test_operator = DbtTestKubernetesOperator( + on_warning_callback=(lambda **kwargs: None), **additional_kwargs, **base_kwargs + ) + + print(additional_kwargs, test_operator.__dict__) + + assert isinstance(test_operator.on_success_callback, list) + assert isinstance(test_operator.on_failure_callback, list) + assert test_operator._handle_warnings in test_operator.on_success_callback + assert test_operator._cleanup_pod in test_operator.on_failure_callback + assert len(test_operator.on_success_callback) == expected_results[0] + assert len(test_operator.on_failure_callback) == expected_results[1] + assert test_operator.is_delete_operator_pod_original == expected_results[2] + assert test_operator.on_finish_action_original == OnFinishAction(expected_results[3]) + + +class FakePodManager: + def read_pod_logs(self, pod, container): + assert pod == "pod" + assert container == "base" + log_string = """ +19:48:25 Concurrency: 4 threads (target='target') +19:48:25 +19:48:25 1 of 2 START test dbt_utils_accepted_range_table_col__12__0 ................... [RUN] +19:48:25 2 of 2 START test unique_table__uuid .......................................... [RUN] +19:48:27 1 of 2 WARN 252 dbt_utils_accepted_range_table_col__12__0 ..................... [WARN 117 in 1.83s] +19:48:27 2 of 2 PASS unique_table__uuid ................................................ [PASS in 1.85s] +19:48:27 +19:48:27 Finished running 2 tests, 1 hook in 0 hours 0 minutes and 12.86 seconds (12.86s). +19:48:27 +19:48:27 Completed with 1 warning: +19:48:27 +19:48:27 Warning in test dbt_utils_accepted_range_table_col__12__0 (models/ads/ads.yaml) +19:48:27 Got 252 results, configured to warn if >0 +19:48:27 +19:48:27 compiled Code at target/compiled/model/models/table/table.yaml/dbt_utils_accepted_range_table_col__12__0.sql +19:48:27 +19:48:27 Done. PASS=1 WARN=1 ERROR=0 SKIP=0 TOTAL=2 +""" + return (log.encode("utf-8") for log in log_string.split("\n")) + + +@pytest.mark.skipif( + not module_available, reason="Kubernetes module `airflow.providers.cncf.kubernetes.utils.pod_manager` not available" +) +def test_dbt_test_kubernetes_operator_handle_warnings_and_cleanup_pod(): + def on_warning_callback(context: Context): + assert context["test_names"] == ["dbt_utils_accepted_range_table_col__12__0"] + assert context["test_results"] == ["Got 252 results, configured to warn if >0"] + + def cleanup(pod: str, remote_pod: str): + assert pod == remote_pod + + test_operator = DbtTestKubernetesOperator( + is_delete_operator_pod=True, on_warning_callback=on_warning_callback, **base_kwargs + ) + task_instance = TaskInstance(test_operator) + task_instance.task.pod_manager = FakePodManager() + task_instance.task.pod = task_instance.task.remote_pod = "pod" + task_instance.task.cleanup = cleanup + + context = Context() + context_merge(context, task_instance=task_instance) + + test_operator._handle_warnings(context) + + @patch("airflow.providers.cncf.kubernetes.operators.pod.KubernetesPodOperator.hook") def test_created_pod(test_hook): test_hook.is_in_cluster = False From 08bede15da387e2384414f1695e1d79b9e0c0276 Mon Sep 17 00:00:00 2001 From: adammarples Date: Wed, 22 Nov 2023 10:17:12 +0000 Subject: [PATCH 026/223] Return code or error ignores WarnErrorOptions (#692) Ignore `WarnErrorOptions` string in stdout from dbt-core, do not treat it as a failure Closes: #642 --- cosmos/dbt/graph.py | 2 +- tests/dbt/test_graph.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/cosmos/dbt/graph.py b/cosmos/dbt/graph.py index f154496e5d..eb8ef19a55 100644 --- a/cosmos/dbt/graph.py +++ b/cosmos/dbt/graph.py @@ -115,7 +115,7 @@ def run_command(command: list[str], tmp_dir: Path, env_vars: dict[str, str]) -> "Unable to run dbt ls command due to missing dbt_packages. Set RenderConfig.dbt_deps=True." ) - if returncode or "Error" in stdout: + if returncode or "Error" in stdout.replace("WarnErrorOptions", ""): details = stderr or stdout raise CosmosLoadDbtException(f"Unable to run {command} due to the error:\n{details}") diff --git a/tests/dbt/test_graph.py b/tests/dbt/test_graph.py index 3e32182596..224aff56e1 100644 --- a/tests/dbt/test_graph.py +++ b/tests/dbt/test_graph.py @@ -679,6 +679,7 @@ def test_load_dbt_ls_and_manifest_with_model_version(load_method): "stdout,returncode", [ ("all good", None), + ("WarnErrorOptions", None), pytest.param("fail", 599, marks=pytest.mark.xfail(raises=CosmosLoadDbtException)), pytest.param("Error", None, marks=pytest.mark.xfail(raises=CosmosLoadDbtException)), ], From 6aa1312b2d810cae06c83740ccc8754aeb0740db Mon Sep 17 00:00:00 2001 From: Ian Moritz Date: Wed, 22 Nov 2023 05:37:10 -0500 Subject: [PATCH 027/223] Highlight DAG examples in README (#695) After speaking with customers, it looks like it would be more clear to flag our examples in the dev folder in the README. Co-authored-by: Julian LaNeve --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index e4f69af639..4ac14bbbbf 100644 --- a/README.rst +++ b/README.rst @@ -31,7 +31,7 @@ Run your dbt Core projects as `Apache Airflow `_ DA Quickstart __________ -Check out the Quickstart guide on our `docs `_. +Check out the Quickstart guide on our `docs `_. See more examples at `/dev/dags `_. Example Usage From c296dba55be4557614e114ccd1c16241babd233d Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Thu, 23 Nov 2023 13:51:00 +0000 Subject: [PATCH 028/223] Fix example DAG in the README and docs/index (#705) The example DAG we had declared in our README and index page was incompatible with the latest versions of Cosmos. An Astronomer customer reported this, and we didn't realise it because those code excerpts were not executed. This PR changes the references to an example DAG run as part of our integration tests. As a follow-up, we should try to avoid this redundancy between `README.rst` and `index.rst` - which I logged as follow up ticket in #704. --- README.rst | 45 ++++++----------------------- docs/_static/jaffle_shop_dag.png | Bin 0 -> 270381 bytes docs/index.rst | 48 +++++++------------------------ docs/requirements.txt | 1 + pyproject.toml | 4 ++- 5 files changed, 23 insertions(+), 75 deletions(-) create mode 100644 docs/_static/jaffle_shop_dag.png diff --git a/README.rst b/README.rst index 4ac14bbbbf..041c23f3fa 100644 --- a/README.rst +++ b/README.rst @@ -31,57 +31,29 @@ Run your dbt Core projects as `Apache Airflow `_ DA Quickstart __________ -Check out the Quickstart guide on our `docs `_. See more examples at `/dev/dags `_. +Check out the Quickstart guide on our `docs `_. See more examples at `/dev/dags `_ and at the `cosmos-demo repo `_. Example Usage ___________________ -You can render an Airflow Task Group using the ``DbtTaskGroup`` class. Here's an example with the `jaffle_shop project `_: +You can render a Cosmos Airflow DAG using the ``DbtDag`` class. Here's an example with the `jaffle_shop project `_: +.. + This renders on Github but not Sphinx: -.. code-block:: python +https://github.com/astronomer/astronomer-cosmos/blob/24aa38e528e299ef51ca6baf32f5a6185887d432/dev/dags/basic_cosmos_dag.py#L1-L42 - from pendulum import datetime +This will generate an Airflow DAG that looks like this: - from airflow import DAG - from airflow.operators.empty import EmptyOperator - from cosmos import DbtTaskGroup, ProfileConfig, ProjectConfig - from cosmos.profiles import PostgresUserPasswordProfileMapping +.. figure:: /docs/_static/jaffle_shop_dag.png - profile_config = ProfileConfig( - profile_name="default", - target_name="dev", - profile_mapping=PostgresUserPasswordProfileMapping( - conn_id="airflow_db", - profile_args={"schema": "public"}, - ), - ) - - with DAG( - dag_id="extract_dag", - start_date=datetime(2022, 11, 27), - schedule_interval="@daily", - ): - e1 = EmptyOperator(task_id="pre_dbt") - - dbt_tg = DbtTaskGroup( - project_config=ProjectConfig("jaffle_shop"), - profile_config=profile_config, - ) - - e2 = EmptyOperator(task_id="post_dbt") - - e1 >> dbt_tg >> e2 - -This will generate an Airflow Task Group that looks like this: - -.. figure:: /docs/_static/jaffle_shop_task_group.png Community _________ - Join us on the Airflow `Slack `_ at #airflow-dbt + Changelog _________ @@ -89,6 +61,7 @@ We follow `Semantic Versioning `_ for releases. Check `CHANGELOG.rst `_ for the latest changes. + Contributing Guide __________________ diff --git a/docs/_static/jaffle_shop_dag.png b/docs/_static/jaffle_shop_dag.png new file mode 100644 index 0000000000000000000000000000000000000000..1a8cdf5cb0e88d78495961fb98b0128203306387 GIT binary patch literal 270381 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
)mYav~KD(W)X?wm-xbe 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 literal 0 HcmV?d00001 diff --git a/docs/index.rst b/docs/index.rst index 3c61b645dc..cef9d87db6 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -40,53 +40,25 @@ Run your dbt Core projects as `Apache Airflow `_ DA Example Usage ___________________ -You can render an Airflow Task Group using the ``DbtTaskGroup`` class. Here's an example with the jaffle_shop project: +You can render a Cosmos Airflow DAG using the ``DbtDag`` class. Here's an example with the `jaffle_shop project `_: -.. code-block:: python +.. + The following renders in Sphinx but not Github: - from pendulum import datetime +.. literalinclude:: ./dev/dags/basic_cosmos_dag.py + :language: python + :start-after: [START local_example] + :end-before: [END local_example] - from airflow import DAG - from airflow.operators.empty import EmptyOperator - from cosmos import DbtTaskGroup +This will generate an Airflow DAG that looks like this: - profile_config = ProfileConfig( - profile_name="default", - target_name="dev", - profile_mapping=PostgresUserPasswordProfileMapping( - conn_id="airflow_db", - profile_args={"schema": "public"}, - ), - ) - - with DAG( - dag_id="extract_dag", - start_date=datetime(2022, 11, 27), - schedule_interval="@daily", - ): - e1 = EmptyOperator(task_id="pre_dbt") - - dbt_tg = DbtTaskGroup( - project_config=ProjectConfig("jaffle_shop"), - profile_config=profile_config, - default_args={"retries": 2}, - ) - - e2 = EmptyOperator(task_id="post_dbt") - - e1 >> dbt_tg >> e2 - - -This will generate an Airflow Task Group that looks like this: - -.. image:: https://raw.githubusercontent.com/astronomer/astronomer-cosmos/main/docs/_static/jaffle_shop_task_group.png - +.. figure:: /docs/_static/jaffle_shop_dag.png Getting Started _______________ -To get started now, check out the `Getting Started Guide `_. +Check out the Quickstart guide on our `docs `_. See more examples at `/dev/dags `_ and at the `cosmos-demo repo `_. Changelog diff --git a/docs/requirements.txt b/docs/requirements.txt index 6ead434854..420d62a599 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -4,4 +4,5 @@ pydata-sphinx-theme sphinx-autobuild sphinx-autoapi apache-airflow +apache-airflow-providers-cncf-kubernetes>=5.1.1 openlineage-airflow diff --git a/pyproject.toml b/pyproject.toml index 0041d9488b..df304c3b54 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,7 +96,8 @@ docs =[ "sphinx", "pydata-sphinx-theme", "sphinx-autobuild", - "sphinx-autoapi" + "sphinx-autoapi", + "apache-airflow-providers-cncf-kubernetes>=5.1.1" ] tests = [ "packaging", @@ -227,6 +228,7 @@ dependencies = [ "sphinx-autobuild", "sphinx-autoapi", "openlineage-airflow", + "apache-airflow-providers-cncf-kubernetes>=5.1.1" ] [tool.hatch.envs.docs.scripts] From 4e94dd0f9933e2e1611dc3173f9fa6c8c870ca61 Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Thu, 23 Nov 2023 13:56:05 +0000 Subject: [PATCH 029/223] Fix docs index image (#706) Fix broken docs sample DAG image, due to different syntax to render images between Github and Sphinx. --- docs/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.rst b/docs/index.rst index cef9d87db6..f5cd3673d8 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -53,7 +53,7 @@ You can render a Cosmos Airflow DAG using the ``DbtDag`` class. Here's an exampl This will generate an Airflow DAG that looks like this: -.. figure:: /docs/_static/jaffle_shop_dag.png +.. image:: https://raw.githubusercontent.com/astronomer/astronomer-cosmos/main/docs/_static/jaffle_shop_dag.png Getting Started _______________ From 13bddb00186a0da60bc49517f5a61b9cf877a004 Mon Sep 17 00:00:00 2001 From: Justin Bandoro <79104794+jbandoro@users.noreply.github.com> Date: Thu, 23 Nov 2023 06:07:34 -0800 Subject: [PATCH 030/223] Prevent overriding dbt profile fields with profile args of "type" or "method" (#702) The issue is described in #696 which was discovered when a user was creating a ProfileConfig with `GoogleCloudServiceAccountDictProfileMapping(profile_args={"method": "service-account"})` which was overriding the dbt profile method: https://github.com/astronomer/astronomer-cosmos/blob/24aa38e528e299ef51ca6baf32f5a6185887d432/cosmos/profiles/bigquery/service_account_keyfile_dict.py#L21 when the profile args are mapped to the created profile below: https://github.com/astronomer/astronomer-cosmos/blob/24aa38e528e299ef51ca6baf32f5a6185887d432/cosmos/profiles/bigquery/service_account_keyfile_dict.py#L42-L52 This is not an issue with the profile mapping example above and could happen with any profile mapping by changing the "type" from `dbt_profile_type` or "method" (if used) from `dbt_profile_method` in the class. The fix in this PR is to not allow args with "type" or "method" that are different from the class variables in `profile_args`. I think this is better than logging a warning because if either of those fields are different the dbt run with the created profile will fail anyways. This also allows backwards compatibility in the case users have these already set in their profile args and it matches the class variables. Closes #696 --- cosmos/profiles/base.py | 31 +++++++++++++++++++++++++---- tests/profiles/test_base_profile.py | 31 +++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 tests/profiles/test_base_profile.py diff --git a/cosmos/profiles/base.py b/cosmos/profiles/base.py index 36f2ccbc54..b1cebb38bd 100644 --- a/cosmos/profiles/base.py +++ b/cosmos/profiles/base.py @@ -18,6 +18,9 @@ if TYPE_CHECKING: from airflow.models import Connection +DBT_PROFILE_TYPE_FIELD = "type" +DBT_PROFILE_METHOD_FIELD = "method" + logger = get_logger(__name__) @@ -41,6 +44,26 @@ class BaseProfileMapping(ABC): def __init__(self, conn_id: str, profile_args: dict[str, Any] | None = None): self.conn_id = conn_id self.profile_args = profile_args or {} + self._validate_profile_args() + + def _validate_profile_args(self) -> None: + """ + Check if profile_args contains keys that should not be overridden from the + class variables when creating the profile. + """ + for profile_field in [DBT_PROFILE_TYPE_FIELD, DBT_PROFILE_METHOD_FIELD]: + if profile_field in self.profile_args and self.profile_args.get(profile_field) != getattr( + self, f"dbt_profile_{profile_field}" + ): + raise CosmosValueError( + "`profile_args` for {0} has {1}='{2}' that will override the dbt profile required value of '{3}'. " + "To fix this, remove {1} from `profile_args`.".format( + self.__class__.__name__, + profile_field, + self.profile_args.get(profile_field), + getattr(self, f"dbt_profile_{profile_field}"), + ) + ) @property def conn(self) -> Connection: @@ -100,11 +123,11 @@ def mock_profile(self) -> dict[str, Any]: where live connection values don't matter. """ mock_profile = { - "type": self.dbt_profile_type, + DBT_PROFILE_TYPE_FIELD: self.dbt_profile_type, } if self.dbt_profile_method: - mock_profile["method"] = self.dbt_profile_method + mock_profile[DBT_PROFILE_METHOD_FIELD] = self.dbt_profile_method for field in self.required_fields: # if someone has passed in a value for this field, use it @@ -199,11 +222,11 @@ def get_dbt_value(self, name: str) -> Any: def mapped_params(self) -> dict[str, Any]: "Turns the self.airflow_param_mapping into a dictionary of dbt fields and their values." mapped_params = { - "type": self.dbt_profile_type, + DBT_PROFILE_TYPE_FIELD: self.dbt_profile_type, } if self.dbt_profile_method: - mapped_params["method"] = self.dbt_profile_method + mapped_params[DBT_PROFILE_METHOD_FIELD] = self.dbt_profile_method for dbt_field in self.airflow_param_mapping: mapped_params[dbt_field] = self.get_dbt_value(dbt_field) diff --git a/tests/profiles/test_base_profile.py b/tests/profiles/test_base_profile.py new file mode 100644 index 0000000000..1b1ba3e8ad --- /dev/null +++ b/tests/profiles/test_base_profile.py @@ -0,0 +1,31 @@ +import pytest +from cosmos.profiles.base import BaseProfileMapping +from cosmos.exceptions import CosmosValueError + + +class TestProfileMapping(BaseProfileMapping): + dbt_profile_method: str = "fake-method" + dbt_profile_type: str = "fake-type" + + def profile(self): + raise NotImplementedError + + +@pytest.mark.parametrize("profile_arg", ["type", "method"]) +def test_validate_profile_args(profile_arg: str): + """ + An error should be raised if the profile_args contains a key that should not be overridden from the class variables. + """ + profile_args = {profile_arg: "fake-value"} + dbt_profile_value = getattr(TestProfileMapping, f"dbt_profile_{profile_arg}") + + expected_cosmos_error = ( + f"`profile_args` for TestProfileMapping has {profile_arg}='fake-value' that will override the dbt profile required value of " + f"'{dbt_profile_value}'. To fix this, remove {profile_arg} from `profile_args`." + ) + + with pytest.raises(CosmosValueError, match=expected_cosmos_error): + TestProfileMapping( + conn_id="fake_conn_id", + profile_args=profile_args, + ) From 7730598e216007cdaf5cdf15a31551b289707a16 Mon Sep 17 00:00:00 2001 From: Justin Bandoro <79104794+jbandoro@users.noreply.github.com> Date: Thu, 23 Nov 2023 06:11:42 -0800 Subject: [PATCH 031/223] Add support for env vars in RenderConfig for dbt ls parsing (#690) Currently, there is a workaround to have environment variables that are required when parsing a dbt project with the dbt ls load mode by setting them with `os.environ` in the DAG file. This is what is currently done in the cosmos dev dag [here](https://github.com/astronomer/astronomer-cosmos/blob/e23a445b30ca391842dae870260cc7ce799d4d5c/dev/dags/example_cosmos_sources.py#L29) since that env var is required for parsing with dbt ls. The problem with setting `os.environ` in that python file is that for the sqlite integration test it was enabling this [test](https://github.com/astronomer/astronomer-cosmos/blob/e23a445b30ca391842dae870260cc7ce799d4d5c/tests/dbt/test_graph.py#L388) to unexpectedly pass (which also requires that env var). This PR adds support for `env_vars` as an argument for `RenderConfig` and sets/unsets the environment variables in a context manager for the dbt ls graph parsing. Closes: #5 Closes: #646 --- cosmos/config.py | 2 ++ cosmos/dbt/graph.py | 6 ++++-- cosmos/dbt/project.py | 21 +++++++++++++++++++++ dev/dags/example_cosmos_sources.py | 7 ++++--- docs/configuration/render-config.rst | 1 + tests/dbt/test_graph.py | 6 +++++- tests/dbt/test_project.py | 22 +++++++++++++++++++++- 7 files changed, 58 insertions(+), 7 deletions(-) diff --git a/cosmos/config.py b/cosmos/config.py index 5c64193c18..40756d2bb7 100644 --- a/cosmos/config.py +++ b/cosmos/config.py @@ -42,6 +42,7 @@ class RenderConfig: :param dbt_deps: Configure to run dbt deps when using dbt ls for dag parsing :param node_converters: a dictionary mapping a ``DbtResourceType`` into a callable. Users can control how to render dbt nodes in Airflow. Only supported when using ``load_method=LoadMode.DBT_MANIFEST`` or ``LoadMode.DBT_LS``. :param dbt_executable_path: The path to the dbt executable for dag generation. Defaults to dbt if available on the path. + :param env_vars: A dictionary of environment variables for rendering. Only supported when using ``LoadMode.DBT_LS``. :param dbt_project_path Configures the DBT project location accessible on the airflow controller for DAG rendering. Mutually Exclusive with ProjectConfig.dbt_project_path. Required when using ``load_method=LoadMode.DBT_LS`` or ``load_method=LoadMode.CUSTOM``. """ @@ -53,6 +54,7 @@ class RenderConfig: dbt_deps: bool = True node_converters: dict[DbtResourceType, Callable[..., Any]] | None = None dbt_executable_path: str | Path = get_system_dbt() + env_vars: dict[str, str] = field(default_factory=dict) dbt_project_path: InitVar[str | Path | None] = None project_path: Path | None = field(init=False) diff --git a/cosmos/dbt/graph.py b/cosmos/dbt/graph.py index eb8ef19a55..33c1d07b09 100644 --- a/cosmos/dbt/graph.py +++ b/cosmos/dbt/graph.py @@ -21,7 +21,7 @@ LoadMode, ) from cosmos.dbt.parser.project import LegacyDbtProject -from cosmos.dbt.project import create_symlinks +from cosmos.dbt.project import create_symlinks, environ from cosmos.dbt.selector import select_nodes from cosmos.log import get_logger @@ -271,7 +271,9 @@ def load_via_dbt_ls(self) -> None: tmpdir_path = Path(tmpdir) create_symlinks(self.render_config.project_path, tmpdir_path) - with self.profile_config.ensure_profile(use_mock_values=True) as profile_values: + with self.profile_config.ensure_profile(use_mock_values=True) as profile_values, environ( + self.render_config.env_vars + ): (profile_path, env_vars) = profile_values env = os.environ.copy() env.update(env_vars) diff --git a/cosmos/dbt/project.py b/cosmos/dbt/project.py index 63f4fc0079..14b2f5e4b0 100644 --- a/cosmos/dbt/project.py +++ b/cosmos/dbt/project.py @@ -1,9 +1,13 @@ +from __future__ import annotations + from pathlib import Path import os from cosmos.constants import ( DBT_LOG_DIR_NAME, DBT_TARGET_DIR_NAME, ) +from contextlib import contextmanager +from typing import Generator def create_symlinks(project_path: Path, tmp_dir: Path) -> None: @@ -12,3 +16,20 @@ def create_symlinks(project_path: Path, tmp_dir: Path) -> None: for child_name in os.listdir(project_path): if child_name not in ignore_paths: os.symlink(project_path / child_name, tmp_dir / child_name) + + +@contextmanager +def environ(env_vars: dict[str, str]) -> Generator[None, None, None]: + """Temporarily set environment variables inside the context manager and restore + when exiting. + """ + original_env = {key: os.getenv(key) for key in env_vars} + os.environ.update(env_vars) + try: + yield + finally: + for key, value in original_env.items(): + if value is None: + del os.environ[key] + else: + os.environ[key] = value diff --git a/dev/dags/example_cosmos_sources.py b/dev/dags/example_cosmos_sources.py index 29c70db5aa..157b3adb39 100644 --- a/dev/dags/example_cosmos_sources.py +++ b/dev/dags/example_cosmos_sources.py @@ -26,7 +26,7 @@ DEFAULT_DBT_ROOT_PATH = Path(__file__).parent / "dbt" DBT_ROOT_PATH = Path(os.getenv("DBT_ROOT_PATH", DEFAULT_DBT_ROOT_PATH)) -os.environ["DBT_SQLITE_PATH"] = str(DEFAULT_DBT_ROOT_PATH / "data") +DBT_SQLITE_PATH = str(DEFAULT_DBT_ROOT_PATH / "data") profile_config = ProfileConfig( @@ -62,7 +62,8 @@ def convert_exposure(dag: DAG, task_group: TaskGroup, node: DbtNode, **kwargs): node_converters={ DbtResourceType("source"): convert_source, # known dbt node type to Cosmos (part of DbtResourceType) DbtResourceType("exposure"): convert_exposure, # dbt node type new to Cosmos (will be added to DbtResourceType) - } + }, + env_vars={"DBT_SQLITE_PATH": DBT_SQLITE_PATH}, ) @@ -73,7 +74,7 @@ def convert_exposure(dag: DAG, task_group: TaskGroup, node: DbtNode, **kwargs): ), profile_config=profile_config, render_config=render_config, - operator_args={"append_env": True}, + operator_args={"env": {"DBT_SQLITE_PATH": DBT_SQLITE_PATH}}, # normal dag parameters schedule_interval="@daily", start_date=datetime(2023, 1, 1), diff --git a/docs/configuration/render-config.rst b/docs/configuration/render-config.rst index de0a08cdbe..5e1c23824c 100644 --- a/docs/configuration/render-config.rst +++ b/docs/configuration/render-config.rst @@ -14,6 +14,7 @@ The ``RenderConfig`` class takes the following arguments: - ``dbt_deps``: A Boolean to run dbt deps when using dbt ls for dag parsing. Default True - ``node_converters``: a dictionary mapping a ``DbtResourceType`` into a callable. Users can control how to render dbt nodes in Airflow. Only supported when using ``load_method=LoadMode.DBT_MANIFEST`` or ``LoadMode.DBT_LS``. Find more information below. - ``dbt_executable_path``: The path to the dbt executable for dag generation. Defaults to dbt if available on the path. +- ``env_vars``: A dictionary of environment variables for rendering. Only supported when using ``load_method=LoadMode.DBT_LS``. - ``dbt_project_path``: Configures the DBT project location accessible on their airflow controller for DAG rendering - Required when using ``load_method=LoadMode.DBT_LS`` or ``load_method=LoadMode.CUSTOM`` Customizing how nodes are rendered (experimental) diff --git a/tests/dbt/test_graph.py b/tests/dbt/test_graph.py index 224aff56e1..a424976a10 100644 --- a/tests/dbt/test_graph.py +++ b/tests/dbt/test_graph.py @@ -392,7 +392,11 @@ def test_load_via_dbt_ls_with_sources(load_method): dbt_project_path=DBT_PROJECTS_ROOT_DIR / project_name, manifest_path=SAMPLE_MANIFEST_SOURCE if load_method == "load_from_dbt_manifest" else None, ), - render_config=RenderConfig(dbt_project_path=DBT_PROJECTS_ROOT_DIR / project_name, dbt_deps=False), + render_config=RenderConfig( + dbt_project_path=DBT_PROJECTS_ROOT_DIR / project_name, + dbt_deps=False, + env_vars={"DBT_SQLITE_PATH": str(DBT_PROJECTS_ROOT_DIR / "data")}, + ), execution_config=ExecutionConfig(dbt_project_path=DBT_PROJECTS_ROOT_DIR / project_name), profile_config=ProfileConfig( profile_name="simple", diff --git a/tests/dbt/test_project.py b/tests/dbt/test_project.py index bd2555c98e..ec5612904b 100644 --- a/tests/dbt/test_project.py +++ b/tests/dbt/test_project.py @@ -1,5 +1,7 @@ from pathlib import Path -from cosmos.dbt.project import create_symlinks +from cosmos.dbt.project import create_symlinks, environ +import os +from unittest.mock import patch DBT_PROJECTS_ROOT_DIR = Path(__file__).parent.parent.parent / "dev/dags/dbt" @@ -13,3 +15,21 @@ def test_create_symlinks(tmp_path): for child in tmp_dir.iterdir(): assert child.is_symlink() assert child.name not in ("logs", "target", "profiles.yml", "dbt_packages") + + +@patch.dict(os.environ, {"VAR1": "value1", "VAR2": "value2"}) +def test_environ_context_manager(): + # Define the expected environment variables + expected_env_vars = {"VAR2": "new_value2", "VAR3": "value3"} + # Use the environ context manager + with environ(expected_env_vars): + # Check if the environment variables are set correctly + for key, value in expected_env_vars.items(): + assert value == os.environ.get(key) + # Check if the original non-overlapping environment variable is still set + assert "value1" == os.environ.get("VAR1") + # Check if the environment variables are unset after exiting the context manager + assert os.environ.get("VAR3") is None + # Check if the original environment variables are still set + assert "value1" == os.environ.get("VAR1") + assert "value2" == os.environ.get("VAR2") From e594cfac737edc85f7dbd28abfc1f5a7486abe3d Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Fri, 24 Nov 2023 12:48:23 +0000 Subject: [PATCH 032/223] Release 1.2.5 (#710) Bug fixes * Fix running models that use alias while supporting dbt versions by @binhnq94 in #662 * Make profiles_yml_path optional for ExecutionMode.DOCKER and KUBERNETES by @MrBones757 in #681 * Prevent overriding dbt profile fields with profile args of type or method by @jbandoro in #702 * Fix LoadMode.DBT_LS fail when dbt outputs WarnErrorOptions by @adammarples in #692 * Add support for env vars in RenderConfig for dbt ls parsing by @jbandoro in #690 * Add support for Kubernetes on_warning_callback by @david-mag in #673 * Fix ExecutionConfig.dbt_executable_path to use ``default_factory`` by @jbandoro in #678 Others * Docs fix: example DAG in the README and docs/index by @tatiana in #705 * Docs improvement: highlight DAG examples in README by @iancmoritz and @jlaneve in #695 (cherry picked from commit 2878d6accc2a725502158247dbd1af7ac37b4449) --- CHANGELOG.rst | 23 +++++++++++++++++++++-- cosmos/__init__.py | 2 +- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 590b864b0e..2812909701 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -11,6 +11,25 @@ Features * Add ``DbtDocsGCSOperator`` for uploading dbt docs to GCS by @jbandoro in #616 +1.2.5 (2023-11-23) +------------------ + +Bug fixes + +* Fix running models that use alias while supporting dbt versions by @binhnq94 in #662 +* Make ``profiles_yml_path`` optional for ``ExecutionMode.DOCKER`` and ``KUBERNETES`` by @MrBones757 in #681 +* Prevent overriding dbt profile fields with profile args of "type" or "method" by @jbandoro in #702 +* Fix ``LoadMode.DBT_LS`` fail when dbt outputs ``WarnErrorOptions`` by @adammarples in #692 +* Add support for env vars in ``RenderConfig`` for dbt ls parsing by @jbandoro in #690 +* Add support for Kubernetes ``on_warning_callback`` by @david-mag in #673 +* Fix ``ExecutionConfig.dbt_executable_path`` to use ``default_factory`` by @jbandoro in #678 + +Others + +* Docs fix: example DAG in the README and docs/index by @tatiana in #705 +* Docs improvement: highlight DAG examples in README by @iancmoritz and @jlaneve in #695 + + 1.2.4 (2023-11-14) ------------------ @@ -23,8 +42,8 @@ Bug fixes Others -* Docs fix: add execution config to MWAA code example by @ugmuka in #674 - +* Docs: add execution config to MWAA code example by @ugmuka in #674 +* Docs: highlight DAG examples in docs by @iancmoritz and @jlaneve in #695 1.2.3 (2023-11-09) ------------------ diff --git a/cosmos/__init__.py b/cosmos/__init__.py index f4af1bd632..0fc8c28f83 100644 --- a/cosmos/__init__.py +++ b/cosmos/__init__.py @@ -5,7 +5,7 @@ Contains dags, task groups, and operators. """ -__version__ = "1.2.4" +__version__ = "1.2.5" from cosmos.airflow.dag import DbtDag from cosmos.airflow.task_group import DbtTaskGroup From d7246def7915f530ce6b472af67adf88fcdb16e8 Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Fri, 24 Nov 2023 12:50:12 +0000 Subject: [PATCH 033/223] Release 1.3.0a2 (#711) Features * Add ProfileMapping for Vertica by @perttus in #540 and #688 * Add ProfileMapping for Snowflake encrypted private key path by @ivanstillfront in #608 * Add support for Snowflake encrypted private key environment variable by @DanMawdsleyBA in #649 * Add DbtDocsGCSOperator for uploading dbt docs to GCS by @jbandoro in #616 Others Rebased on changes released on 1.2.5 (1.3.0a1 was based on 1.2.4) --- CHANGELOG.rst | 5 +++-- cosmos/__init__.py | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 2812909701..ecade71122 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,13 +1,14 @@ Changelog ========= -1.3.0a1 (2023-10-26) +1.3.0a2 (2023-11-23) -------------------- Features -* Add ``ProfileMapping`` for Vertica by @perttus in #540 +* Add ``ProfileMapping`` for Vertica by @perttus in #540 and #688 * Add ``ProfileMapping`` for Snowflake encrypted private key path by @ivanstillfront in #608 +* Add support for Snowflake encrypted private key environment variable by @DanMawdsleyBA in #649 * Add ``DbtDocsGCSOperator`` for uploading dbt docs to GCS by @jbandoro in #616 diff --git a/cosmos/__init__.py b/cosmos/__init__.py index 0fc8c28f83..f1f2046347 100644 --- a/cosmos/__init__.py +++ b/cosmos/__init__.py @@ -5,7 +5,8 @@ Contains dags, task groups, and operators. """ -__version__ = "1.2.5" +__version__ = "1.3.0a2" + from cosmos.airflow.dag import DbtDag from cosmos.airflow.task_group import DbtTaskGroup From da57bcd39863f86c57be8c544638bc937eafe7f9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 27 Nov 2023 14:19:36 -0500 Subject: [PATCH 034/223] =?UTF-8?q?=E2=AC=86=20[pre-commit.ci]=20pre-commi?= =?UTF-8?q?t=20autoupdate=20(#716)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/pre-commit/mirrors-mypy: v1.7.0 → v1.7.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.7.0...v1.7.1) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a631ab91c8..890afb0b74 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -70,7 +70,7 @@ repos: alias: black additional_dependencies: [black>=22.10.0] - repo: https://github.com/pre-commit/mirrors-mypy - rev: 'v1.7.0' + rev: 'v1.7.1' hooks: - id: mypy name: mypy-python From 717a7093f38f1933f011da66d018e977e9ff13e2 Mon Sep 17 00:00:00 2001 From: Justin Bandoro <79104794+jbandoro@users.noreply.github.com> Date: Mon, 4 Dec 2023 03:14:49 -0800 Subject: [PATCH 035/223] Speed up integration tests (#732) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Speed up the integration tests by caching the dag bag result. Previously, for each parametrized dag run test, it would reparse all of the dags, which takes a non-trivial amount of time to parse all of the cosmos example dags. On my local machine, the total time went from 1616s to 540s, which will save a good amount of GH minutes 😃. --- tests/test_example_dags.py | 2 ++ tests/test_example_dags_no_connections.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/tests/test_example_dags.py b/tests/test_example_dags.py index 63abca541c..41b967e921 100644 --- a/tests/test_example_dags.py +++ b/tests/test_example_dags.py @@ -1,6 +1,7 @@ from __future__ import annotations from pathlib import Path +from functools import cache import airflow import pytest @@ -37,6 +38,7 @@ def session(): return get_session() +@cache def get_dag_bag() -> DagBag: """Create a DagBag by adding the files that are not supported to .airflowignore""" with open(AIRFLOW_IGNORE_FILE, "w+") as file: diff --git a/tests/test_example_dags_no_connections.py b/tests/test_example_dags_no_connections.py index 7048b1b0eb..97f39bf991 100644 --- a/tests/test_example_dags_no_connections.py +++ b/tests/test_example_dags_no_connections.py @@ -1,6 +1,7 @@ from __future__ import annotations from pathlib import Path +from functools import cache import airflow import pytest @@ -23,6 +24,7 @@ } +@cache def get_dag_bag() -> DagBag: """Create a DagBag by adding the files that are not supported to .airflowignore""" with open(AIRFLOW_IGNORE_FILE, "w+") as file: From d1d871aebd84dc2fe921893f0bf67658c36bd57d Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Mon, 4 Dec 2023 12:42:12 +0000 Subject: [PATCH 036/223] Fix functools cache test issue (#742) Fix exceptions raised for some versions of Python (e.g. 2.4) after prematurely merging #732, leading to the main branch becoming red ``` ../../../.local/share/hatch/env/virtual/astronomer-cosmos/Za_bFbg4/tests.py3.8-2.5/lib/python3.8/site-packages/_pytest/assertion/rewrite.py:186: in exec_module exec(co, module.__dict__) tests/test_example_dags_no_connections.py:4: in from functools import cache E ImportError: cannot import name 'cache' from 'functools' ``` --- tests/test_example_dags.py | 7 ++++++- tests/test_example_dags_no_connections.py | 6 +++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/test_example_dags.py b/tests/test_example_dags.py index 41b967e921..11655a31dc 100644 --- a/tests/test_example_dags.py +++ b/tests/test_example_dags.py @@ -1,7 +1,12 @@ from __future__ import annotations from pathlib import Path -from functools import cache + +try: + from functools import cache +except ImportError: + from functools import lru_cache as cache + import airflow import pytest diff --git a/tests/test_example_dags_no_connections.py b/tests/test_example_dags_no_connections.py index 97f39bf991..5356c4ea6e 100644 --- a/tests/test_example_dags_no_connections.py +++ b/tests/test_example_dags_no_connections.py @@ -1,7 +1,11 @@ from __future__ import annotations from pathlib import Path -from functools import cache + +try: + from functools import cache +except ImportError: + from functools import lru_cache as cache import airflow import pytest From 7456681ef69ebcabf982ba60d91b7836cbbcbb73 Mon Sep 17 00:00:00 2001 From: Mark Olliver Date: Mon, 4 Dec 2023 13:16:38 +0000 Subject: [PATCH 037/223] Optional pydantic (#736) Converts pydantic to being optional, as it is required for Airflow 2.6 but not others. Closes #725 Closes #654 --- pyproject.toml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index df304c3b54..fe399daee6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,10 +34,8 @@ classifiers = [ "Programming Language :: Python :: 3.10", ] dependencies = [ - # Airflow & Pydantic issue: https://github.com/apache/airflow/issues/32311 "aenum", "attrs", - "pydantic>=1.10.0,<2.0.0", "apache-airflow>=2.3.0", "importlib-metadata; python_version < '3.8'", "Jinja2>=3.0.0", @@ -119,6 +117,9 @@ docker = [ kubernetes = [ "apache-airflow-providers-cncf-kubernetes>=5.1.1", ] +pydantic = [ + "pydantic>=1.10.0,<2.0.0", +] [project.entry-points.cosmos] @@ -164,6 +165,7 @@ matrix.airflow.dependencies = [ { value = "apache-airflow==2.4", if = ["2.4"] }, { value = "apache-airflow==2.5", if = ["2.5"] }, { value = "apache-airflow==2.6", if = ["2.6"] }, + { value = "pydantic>=1.10.0,<2.0.0", if = ["2.6"]}, { value = "apache-airflow==2.7", if = ["2.7"] }, ] From cc717d1eeee6f8f048e9e23a27fbf189ef4b3c71 Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Mon, 4 Dec 2023 15:35:03 +0000 Subject: [PATCH 038/223] Update doc with conflicts between Airflow and dbt doc (#731) Update the docs describing conflicts between Airflow and dbt until Airflow 2.7 and dbt 1.7. --- .pre-commit-config.yaml | 1 + .../execution-modes-local-conflicts.rst | 52 +++++++++++++------ 2 files changed, 37 insertions(+), 16 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 890afb0b74..0ea06541bd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -34,6 +34,7 @@ repos: args: - --exclude-file=tests/sample/manifest_model_version.json - --skip=**/manifest.json + - -L connexion - repo: https://github.com/pre-commit/pygrep-hooks rev: v1.10.0 hooks: diff --git a/docs/getting_started/execution-modes-local-conflicts.rst b/docs/getting_started/execution-modes-local-conflicts.rst index 0f23ef8245..3e201bef8f 100644 --- a/docs/getting_started/execution-modes-local-conflicts.rst +++ b/docs/getting_started/execution-modes-local-conflicts.rst @@ -4,25 +4,27 @@ Airflow and DBT dependencies conflicts ====================================== When using the `Local Execution Mode `__, users may face dependency conflicts between -Apache Airflow and DBT. The amount of conflicts may increase depending on the Airflow providers and DBT plugins being used. +Apache Airflow and DBT. The conflicts may increase depending on the Airflow providers and DBT plugins being used. If you find errors, we recommend users look into using `alternative execution modes `__. In the following table, ``x`` represents combinations that lead to conflicts (vanilla ``apache-airflow`` and ``dbt-core`` packages): -+---------------+-----+-----+-----+-----+-----+-----+---------+ -| Airflow \ DBT | 1.0 | 1.1 | 1.2 | 1.3 | 1.4 | 1.5 | 1.6.0b6 | -+===============+=====+=====+=====+=====+=====+=====+=========+ -| 2.2 | | | | x | x | x | x | -+---------------+-----+-----+-----+-----+-----+-----+---------+ -| 2.3 | x | x | | x | x | x | x | -+---------------+-----+-----+-----+-----+-----+-----+---------+ -| 2.4 | x | x | x | | | | | -+---------------+-----+-----+-----+-----+-----+-----+---------+ -| 2.5 | x | x | x | | | | | -+---------------+-----+-----+-----+-----+-----+-----+---------+ -| 2.6 | x | x | x | x | x | | x | -+---------------+-----+-----+-----+-----+-----+-----+---------+ ++---------------+-----+-----+-----+-----+-----+-----+-----+-----+ +| Airflow / DBT | 1.0 | 1.1 | 1.2 | 1.3 | 1.4 | 1.5 | 1.6 | 1.7 | ++===============+=====+=====+=====+=====+=====+=====+=====+=====+ +| 2.2 | | | | x | x | x | x | x | ++---------------+-----+-----+-----+-----+-----+-----+-----+-----+ +| 2.3 | x | x | | x | x | x | x | x | ++---------------+-----+-----+-----+-----+-----+-----+-----+-----+ +| 2.4 | x | x | x | | | | | | ++---------------+-----+-----+-----+-----+-----+-----+-----+-----+ +| 2.5 | x | x | x | | | | | | ++---------------+-----+-----+-----+-----+-----+-----+-----+-----+ +| 2.6 | x | x | x | x | x | | | | ++---------------+-----+-----+-----+-----+-----+-----+-----+-----+ +| 2.7 | x | x | x | x | x | | | | ++---------------+-----+-----+-----+-----+-----+-----+-----+-----+ Examples of errors ----------------------------------- @@ -41,6 +43,22 @@ Examples of errors apache-airflow 2.6.0 depends on importlib-metadata<5.0.0 and >=1.7; python_version < "3.9" dbt-semantic-interfaces 0.1.0.dev7 depends on importlib-metadata==6.6.0 +.. code-block:: bash + + ERROR: Cannot install apache-airflow, apache-airflow==2.7.0 and dbt-core==1.4.0 because these package versions have conflicting dependencies. + + The conflict is caused by: + dbt-core 1.4.0 depends on pyyaml>=6.0 + connexion 2.12.0 depends on PyYAML<6 and >=5.1 + dbt-core 1.4.0 depends on pyyaml>=6.0 + connexion 2.11.2 depends on PyYAML<6 and >=5.1 + dbt-core 1.4.0 depends on pyyaml>=6.0 + connexion 2.11.1 depends on PyYAML<6 and >=5.1 + dbt-core 1.4.0 depends on pyyaml>=6.0 + connexion 2.11.0 depends on PyYAML<6 and >=5.1 + apache-airflow 2.7.0 depends on jsonschema>=4.18.0 + flask-appbuilder 4.3.3 depends on jsonschema<5 and >=3 + connexion 2.10.0 depends on jsonschema<4 and >=2.5.1 How to reproduce ---------------- @@ -57,8 +75,10 @@ The table was created by running `nox `__ wi @nox.session(python=["3.10"]) - @nox.parametrize("dbt_version", ["1.0", "1.1", "1.2", "1.3", "1.4", "1.5", "1.6.0b6"]) - @nox.parametrize("airflow_version", ["2.2.4", "2.3", "2.4", "2.5", "2.6"]) + @nox.parametrize( + "dbt_version", ["1.0", "1.1", "1.2", "1.3", "1.4", "1.5", "1.6", "1.7"] + ) + @nox.parametrize("airflow_version", ["2.2.4", "2.3", "2.4", "2.5", "2.6", "2.7"]) def compatibility(session: nox.Session, airflow_version, dbt_version) -> None: """Run both unit and integration tests.""" session.run( From 94da3198136f6a91da4eec78fada19bdc777fb2e Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Mon, 4 Dec 2023 15:43:12 +0000 Subject: [PATCH 039/223] Document contributor roles and add maintainers list (#734) Add documentation about Astronomer Cosmos contributor roles and maintainers until now. --------- Co-authored-by: Harel Shein Co-authored-by: Julian LaNeve --- docs/contributing.rst | 4 +++ docs/contributors-roles.rst | 56 +++++++++++++++++++++++++++++++++++++ docs/contributors.rst | 26 +++++++++++++++++ docs/index.rst | 3 +- 4 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 docs/contributors-roles.rst create mode 100644 docs/contributors.rst diff --git a/docs/contributing.rst b/docs/contributing.rst index f875538839..3e2ab6fe35 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -1,3 +1,5 @@ +.. _contributing: + Cosmos Contributing Guide ========================= @@ -6,6 +8,8 @@ All contributions, bug reports, bug fixes, documentation improvements, enhanceme As contributors and maintainers to this project, you are expected to abide by the `Contributor Code of Conduct `_. +Learn more about the contributors roles in :ref:`contributors-roles`. + Overview ________ diff --git a/docs/contributors-roles.rst b/docs/contributors-roles.rst new file mode 100644 index 0000000000..7cddfb95fd --- /dev/null +++ b/docs/contributors-roles.rst @@ -0,0 +1,56 @@ +.. _contributors-roles: + +Contributor roles +================== + +Contributors are welcome and are greatly appreciated! Every little bit helps, and we give credit to them. + +This document aims to explain the current roles in the Astronomer Cosmos project. +For more information, check :ref:`contributing` and :ref:`contributors`. + + +Contributors +------------ + +A contributor is anyone who wants to contribute code, documentation, tests, ideas, or anything to the Astronomer Cosmos project. + +Cosmos contributors can be found in the Astronomer Cosmos Github `insights page `_ and in the `#airflow-dbt `_ Slack channel. + +Contributors are responsible for: + +* Fixing bugs +* Refactoring code +* Improving processes and tooling +* Adding features +* Improving the documentation +* Making/answering questions in the #airflow-dbt Slack channel + + +Committers +---------------------- + +Committers are community members with write access to the `Astronomer Cosmos Github repository `_. +They can modify the code and the documentation and accept others' contributions to the repo. + +Check :ref:`contributors` for the official list of Astronomer Cosmos committers. + +Committers have the same responsibilities as standard contributors and also perform the following actions: + +* Reviewing & merging pull-requests +* Scanning and responding to GitHub issues, helping triaging them + +If you know you are not going to be able to contribute for a long time (for instance, due to a change of job or circumstances), you should inform other maintainers, and we will mark you as "emeritus". +Emeritus committers will no longer have write access to the repo. +As merit earned never expires, once an emeritus committer becomes active again, they can simply email another maintainer from Astronomer and ask to be reinstated. + +Pre-requisites to becoming a committer +....................................... + +General prerequisites that we look for in all candidates: + +1. Consistent contribution over last few months +2. Visibility on discussions on the Slack channel or GitHub issues/discussions +3. Contributions to community health and project's sustainability for the long-term +4. Understands the project's contributors guidelines :ref:`contributing`. +Astronomer is responsible and accountable for releasing new versions of Cosmos in PyPI , following the milestones . +Astronomer has the right to grant and revoke write access permissions to the project's official repository for any reason it sees fit. diff --git a/docs/contributors.rst b/docs/contributors.rst new file mode 100644 index 0000000000..273358d3c4 --- /dev/null +++ b/docs/contributors.rst @@ -0,0 +1,26 @@ +.. _contributors: +Contributors +============ + +There are different ways people can contribute to Astronomer Cosmos. +Learn more about the project contributors roles in :ref:`contributors-roles`. + +Committers +---------------------- + +* Chris Hronek (`@chrishronek `_) +* Harel Shein (`@harels `_) +* Julian LaNeve (`@jlaneve `_) +* Tatiana Al-Chueyr (`@tatiana `_) + + +Emeritus Committers +------------------------------- + +(none at the moment) + +Contributors +------------ + +Many people are improving Astronomer Cosmos each day. +Find more contributors `in our Github page `_ and in the `#airflow-dbt `_ Slack channel. diff --git a/docs/index.rst b/docs/index.rst index f5cd3673d8..0c7ab506cf 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -73,7 +73,8 @@ __________________ All contributions, bug reports, bug fixes, documentation improvements, enhancements are welcome. -A detailed overview an how to contribute can be found in the `Contributing Guide `_. +A detailed overview on how to contribute can be found in the `Contributing Guide `_. +Find out more about `our contributors `_. As contributors and maintainers to this project, you are expected to abide by the `Contributor Code of Conduct `_. From 1373cf73a2690c9d628df7a4c844808c8685163d Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Tue, 5 Dec 2023 11:18:24 +0000 Subject: [PATCH 040/223] Support no profile_config for K8s & Docker (#721) Make `profile_config` optional for users using ExecutionMode.KUBERTES and DOCKER. Solve the issue raised by @david-mag in the #airflow-dbt slack channel: https://apache-airflow.slack.com/archives/C059CC42E9W/p1700814078752829 --- cosmos/converter.py | 76 ++++++++++++++++++++++++++++++----------- tests/test_converter.py | 27 ++++++++++++++- 2 files changed, 82 insertions(+), 21 deletions(-) diff --git a/cosmos/converter.py b/cosmos/converter.py index 2142cc6e42..45c95c2e7c 100644 --- a/cosmos/converter.py +++ b/cosmos/converter.py @@ -83,6 +83,59 @@ def validate_arguments( profile_config.validate_profiles_yml() +def validate_initial_user_config( + execution_config: ExecutionConfig | None, + profile_config: ProfileConfig | None, + project_config: ProjectConfig, + render_config: RenderConfig | None, +): + """ + Validates if the user set the fields as expected. + + :param execution_config: Configuration related to how to run dbt in Airflow tasks + :param profile_config: Configuration related to dbt database configuration (profile) + :param project_config: Configuration related to the overall dbt project + :param render_config: Configuration related to how to convert the dbt workflow into an Airflow DAG + """ + if profile_config is None and execution_config.execution_mode not in ( + ExecutionMode.KUBERNETES, + ExecutionMode.DOCKER, + ): + raise CosmosValueError(f"The profile_config is mandatory when using {execution_config.execution_mode}") + + # Since we now support both project_config.dbt_project_path, render_config.project_path and execution_config.project_path + # We need to ensure that only one interface is being used. + if project_config.dbt_project_path and (render_config.project_path or execution_config.project_path): + raise CosmosValueError( + "ProjectConfig.dbt_project_path is mutually exclusive with RenderConfig.dbt_project_path and ExecutionConfig.dbt_project_path." + + "If using RenderConfig.dbt_project_path or ExecutionConfig.dbt_project_path, ProjectConfig.dbt_project_path should be None" + ) + + +def validate_adapted_user_config( + execution_config: ExecutionConfig | None, project_config: ProjectConfig, render_config: RenderConfig | None +): + """ + Validates if all the necessary fields required by Cosmos to render the DAG are set. + + :param execution_config: Configuration related to how to run dbt in Airflow tasks + :param project_config: Configuration related to the overall dbt project + :param render_config: Configuration related to how to convert the dbt workflow into an Airflow DAG + """ + # At this point, execution_config.project_path should always be non-null + if not execution_config.project_path: + raise CosmosValueError( + "ExecutionConfig.dbt_project_path is required for the execution of dbt tasks in all execution modes." + ) + + # We now have a guaranteed execution_config.project_path, but still need to process render_config.project_path + # We require render_config.project_path when we dont have a manifest + if not project_config.manifest_path and not render_config.project_path: + raise CosmosValueError( + "RenderConfig.dbt_project_path is required for rendering an airflow DAG from a DBT Graph if no manifest is provided." + ) + + class DbtToAirflowConverter: """ Logic common to build an Airflow DbtDag and DbtTaskGroup from a DBT project. @@ -101,7 +154,7 @@ class DbtToAirflowConverter: def __init__( self, project_config: ProjectConfig, - profile_config: ProfileConfig, + profile_config: ProfileConfig | None = None, execution_config: ExecutionConfig | None = None, render_config: RenderConfig | None = None, dag: DAG | None = None, @@ -118,13 +171,7 @@ def __init__( if not render_config: render_config = RenderConfig() - # Since we now support both project_config.dbt_project_path, render_config.project_path and execution_config.project_path - # We need to ensure that only one interface is being used. - if project_config.dbt_project_path and (render_config.project_path or execution_config.project_path): - raise CosmosValueError( - "ProjectConfig.dbt_project_path is mutually exclusive with RenderConfig.dbt_project_path and ExecutionConfig.dbt_project_path." - + "If using RenderConfig.dbt_project_path or ExecutionConfig.dbt_project_path, ProjectConfig.dbt_project_path should be None" - ) + validate_initial_user_config(execution_config, profile_config, project_config, render_config) # If we are using the old interface, we should migrate it to the new interface # This is safe to do now since we have validated which config interface we're using @@ -136,18 +183,7 @@ def __init__( render_config.project_path = project_config.dbt_project_path execution_config.project_path = project_config.dbt_project_path - # At this point, execution_config.project_path should always be non-null - if not execution_config.project_path: - raise CosmosValueError( - "ExecutionConfig.dbt_project_path is required for the execution of dbt tasks in all execution modes." - ) - - # We now have a guaranteed execution_config.project_path, but still need to process render_config.project_path - # We require render_config.project_path when we dont have a manifest - if not project_config.manifest_path and not render_config.project_path: - raise CosmosValueError( - "RenderConfig.dbt_project_path is required for rendering an airflow DAG from a DBT Graph if no manifest is provided." - ) + validate_adapted_user_config(execution_config, project_config, render_config) if not operator_args: operator_args = {} diff --git a/tests/test_converter.py b/tests/test_converter.py index 3bb5af163e..c04da2c3a6 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -6,7 +6,7 @@ import pytest from airflow.models import DAG -from cosmos.converter import DbtToAirflowConverter, validate_arguments +from cosmos.converter import DbtToAirflowConverter, validate_arguments, validate_initial_user_config from cosmos.constants import DbtResourceType, ExecutionMode from cosmos.config import ProjectConfig, ProfileConfig, ExecutionConfig, RenderConfig, CosmosConfigException from cosmos.dbt.graph import DbtNode @@ -35,6 +35,31 @@ def test_validate_arguments_tags(argument_key): assert err.value.args[0] == expected +@pytest.mark.parametrize( + "execution_mode", + (ExecutionMode.LOCAL, ExecutionMode.VIRTUALENV), +) +def test_validate_initial_user_config_no_profile(execution_mode): + execution_config = ExecutionConfig(execution_mode=execution_mode) + profile_config = None + project_config = ProjectConfig() + with pytest.raises(CosmosValueError) as err_info: + validate_initial_user_config(execution_config, profile_config, project_config, None) + err_msg = f"The profile_config is mandatory when using {execution_mode}" + assert err_info.value.args[0] == err_msg + + +@pytest.mark.parametrize( + "execution_mode", + (ExecutionMode.DOCKER, ExecutionMode.KUBERNETES), +) +def test_validate_initial_user_config_expects_profile(execution_mode): + execution_config = ExecutionConfig(execution_mode=execution_mode) + profile_config = None + project_config = ProjectConfig() + assert validate_initial_user_config(execution_config, profile_config, project_config, None) is None + + def test_validate_arguments_schema_in_task_args(): profile_config = ProfileConfig( profile_name="test", From f3c21b52edab441c6695f285bec35d7237f82699 Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Tue, 5 Dec 2023 11:19:27 +0000 Subject: [PATCH 041/223] Add support to select using graph-operators when using `LoadMode.CUSTOM` or `LoadMode.DBT_MANIFEST` (#728) Add support for the following when using `LoadMode.CUSTOM` or `LoadMode.DBT_MANIFEST`: * Support selection of model by name * Support the selection of models by name & their children (with or without degrees) * Support the selection of models by name & their parents (with or without degrees) * Support intersections and unions involving graph selectors (with or without other supported selectors, eg. tags) Examples of select/exclusion statements that now work regardless of the `LoadMode` being used: ``` model_a +model_b model_c+ +model_d+ 2+model_e model_f+3 model_f+,tag:nightly ``` Related dbt documentation: https://docs.getdbt.com/reference/node-selection/graph-operators https://docs.getdbt.com/reference/node-selection/set-operators Limitations: * The at operator is not supported yet (`@`) * If users opt to use graph selector, it will increase the DAG parsing time and the task execution time when using `LoadMode.CUSTOM` or `LoadMode.DBT_MANIFEST` This PR improves and extends the original implementation proposed by @tseruga in #429. Some of the changes that were introduced on top of the original PR: * Add support to descendants (before only precursors were supported) * Add support to different depths/degrees of precursors/descendants * Add support to the union between graph operators and graph/non-graph operators * Add support to the intersection between graph operators and graph/non-graph operators Closes: #684 Co-authored-by: Tyler Seruga --- cosmos/dbt/selector.py | 215 ++++++++++++++++++-- docs/configuration/selecting-excluding.rst | 35 +++- tests/dbt/test_selector.py | 219 ++++++++++++++++++--- 3 files changed, 424 insertions(+), 45 deletions(-) diff --git a/cosmos/dbt/selector.py b/cosmos/dbt/selector.py index c7316dc75e..c7eb893075 100644 --- a/cosmos/dbt/selector.py +++ b/cosmos/dbt/selector.py @@ -1,7 +1,9 @@ from __future__ import annotations -from pathlib import Path import copy - +import re +from collections import defaultdict +from dataclasses import dataclass +from pathlib import Path from typing import TYPE_CHECKING, Any from cosmos.constants import DbtResourceType @@ -16,11 +18,154 @@ PATH_SELECTOR = "path:" TAG_SELECTOR = "tag:" CONFIG_SELECTOR = "config." - +PLUS_SELECTOR = "+" +GRAPH_SELECTOR_REGEX = r"^([0-9]*\+)?([^\+]+)(\+[0-9]*)?$|" logger = get_logger(__name__) +@dataclass +class GraphSelector: + """ + Implements dbt graph operator selectors: + model_a + +model_b + model_c+ + +model_d+ + 2+model_e + model_f+3 + + https://docs.getdbt.com/reference/node-selection/graph-operators + """ + + node_name: str + precursors: str | None + descendants: str | None + + @property + def precursors_depth(self) -> int: + """ + Calculates the depth/degrees/generations of precursors (parents). + Return: + -1: if it should return all the generations of precursors + 0: if it shouldn't return any precursors + >0: upperbound number of parent generations + """ + if not self.precursors: + return 0 + if self.precursors == "+": + return -1 + else: + return int(self.precursors[:-1]) + + @property + def descendants_depth(self) -> int: + """ + Calculates the depth/degrees/generations of descendants (children). + Return: + -1: if it should return all the generations of children + 0: if it shouldn't return any children + >0: upperbound of children generations + """ + if not self.descendants: + return 0 + if self.descendants == "+": + return -1 + else: + return int(self.descendants[1:]) + + @staticmethod + def parse(text: str) -> GraphSelector | None: + """ + Parse a string and identify if there are graph selectors, including the desired node name, descendants and + precursors. Return a GraphSelector instance if the pattern matches. + """ + regex_match = re.search(GRAPH_SELECTOR_REGEX, text) + if regex_match: + precursors, node_name, descendants = regex_match.groups() + return GraphSelector(node_name, precursors, descendants) + return None + + def select_node_precursors(self, nodes: dict[str, DbtNode], root_id: str, selected_nodes: set[str]) -> None: + """ + Parse original nodes and add the precursor nodes related to this config to the selected_nodes set. + + :param nodes: Original dbt nodes list + :param root_id: Unique identifier of self.node_name + :param selected_nodes: Set where precursor nodes will be added to. + """ + if self.precursors: + depth = self.precursors_depth + previous_generation = {root_id} + processed_nodes = set() + while depth and previous_generation: + new_generation: set[str] = set() + for node_id in previous_generation: + if node_id not in processed_nodes: + new_generation.update(set(nodes[node_id].depends_on)) + processed_nodes.add(node_id) + selected_nodes.update(new_generation) + previous_generation = new_generation + depth -= 1 + + def select_node_descendants(self, nodes: dict[str, DbtNode], root_id: str, selected_nodes: set[str]) -> None: + """ + Parse original nodes and add the descendant nodes related to this config to the selected_nodes set. + + :param nodes: Original dbt nodes list + :param root_id: Unique identifier of self.node_name + :param selected_nodes: Set where descendant nodes will be added to. + """ + if self.descendants: + children_by_node = defaultdict(set) + # Index nodes by parent id + # We could optimize by doing this only once for the dbt project and giving it + # as a parameter to the GraphSelector + for node_id, node in nodes.items(): + for parent_id in node.depends_on: + children_by_node[parent_id].add(node_id) + + depth = self.descendants_depth + previous_generation = {root_id} + processed_nodes = set() + while depth and previous_generation: + new_generation: set[str] = set() + for node_id in previous_generation: + if node_id not in processed_nodes: + new_generation.update(children_by_node[node_id]) + processed_nodes.add(node_id) + selected_nodes.update(new_generation) + previous_generation = new_generation + depth -= 1 + + def filter_nodes(self, nodes: dict[str, DbtNode]) -> set[str]: + """ + Given a dictionary with the original dbt project nodes, applies the current graph selector to + identify the subset of nodes that matches the selection criteria. + + :param nodes: dbt project nodes + :return: set of node ids that matches current graph selector + """ + selected_nodes: set[str] = set() + + # Index nodes by name, we can improve performance by doing this once + # for multiple GraphSelectors + node_by_name = {} + for node_id, node in nodes.items(): + node_by_name[node.name] = node_id + + if self.node_name in node_by_name: + root_id = node_by_name[self.node_name] + else: + logger.warn(f"Selector {self.node_name} not found.") + return selected_nodes + + selected_nodes.add(root_id) + self.select_node_precursors(nodes, root_id, selected_nodes) + self.select_node_descendants(nodes, root_id, selected_nodes) + return selected_nodes + + class SelectorConfig: """ Represents a select/exclude statement. @@ -43,11 +188,12 @@ def __init__(self, project_dir: Path | None, statement: str): self.tags: list[str] = [] self.config: dict[str, str] = {} self.other: list[str] = [] + self.graph_selectors: list[GraphSelector] = [] self.load_from_statement(statement) @property def is_empty(self) -> bool: - return not (self.paths or self.tags or self.config or self.other) + return not (self.paths or self.tags or self.config or self.graph_selectors or self.other) def load_from_statement(self, statement: str) -> None: """ @@ -61,6 +207,7 @@ def load_from_statement(self, statement: str) -> None: https://docs.getdbt.com/reference/node-selection/yaml-selectors """ items = statement.split(",") + for item in items: if item.startswith(PATH_SELECTOR): index = len(PATH_SELECTOR) @@ -77,11 +224,16 @@ def load_from_statement(self, statement: str) -> None: if key in SUPPORTED_CONFIG: self.config[key] = value else: - self.other.append(item) - logger.warning("Unsupported select statement: %s", item) + if item: + graph_selector = GraphSelector.parse(item) + if graph_selector is not None: + self.graph_selectors.append(graph_selector) + else: + self.other.append(item) + logger.warning("Unsupported select statement: %s", item) def __repr__(self) -> str: - return f"SelectorConfig(paths={self.paths}, tags={self.tags}, config={self.config}, other={self.other})" + return f"SelectorConfig(paths={self.paths}, tags={self.tags}, config={self.config}, other={self.other}, graph_selectors={self.graph_selectors})" class NodeSelector: @@ -95,7 +247,9 @@ class NodeSelector: def __init__(self, nodes: dict[str, DbtNode], config: SelectorConfig) -> None: self.nodes = nodes self.config = config + self.selected_nodes: set[str] = set() + @property def select_nodes_ids_by_intersection(self) -> set[str]: """ Return a list of node ids which matches the configuration defined in config. @@ -107,14 +261,19 @@ def select_nodes_ids_by_intersection(self) -> set[str]: if self.config.is_empty: return set(self.nodes.keys()) - self.selected_nodes: set[str] = set() + selected_nodes: set[str] = set() self.visited_nodes: set[str] = set() for node_id, node in self.nodes.items(): if self._should_include_node(node_id, node): - self.selected_nodes.add(node_id) + selected_nodes.add(node_id) + + if self.config.graph_selectors: + nodes_by_graph_selector = self.select_by_graph_operator() + selected_nodes = selected_nodes.intersection(nodes_by_graph_selector) - return self.selected_nodes + self.selected_nodes = selected_nodes + return selected_nodes def _should_include_node(self, node_id: str, node: DbtNode) -> bool: "Checks if a single node should be included. Only runs once per node with caching." @@ -175,6 +334,22 @@ def _is_path_matching(self, node: DbtNode) -> bool: return self._should_include_node(node.depends_on[0], model_node) return False + def select_by_graph_operator(self) -> set[str]: + """ + Return a list of node ids which match the configuration defined in the config. + + Return all nodes that are parents (or parents from parents) of the root defined in the configuration. + + References: + https://docs.getdbt.com/reference/node-selection/syntax + https://docs.getdbt.com/reference/node-selection/yaml-selectors + """ + selected_nodes_by_selector: list[set[str]] = [] + + for graph_selector in self.config.graph_selectors: + selected_nodes_by_selector.append(graph_selector.filter_nodes(self.nodes)) + return set.intersection(*selected_nodes_by_selector) + def retrieve_by_label(statement_list: list[str], label: str) -> set[str]: """ @@ -189,7 +364,7 @@ def retrieve_by_label(statement_list: list[str], label: str) -> set[str]: for statement in statement_list: config = SelectorConfig(Path(), statement) item_values = getattr(config, label) - label_values = label_values.union(item_values) + label_values.update(item_values) return label_values @@ -217,11 +392,14 @@ def select_nodes( filters = [["select", select], ["exclude", exclude]] for filter_type, filter in filters: for filter_parameter in filter: - if filter_parameter.startswith(PATH_SELECTOR) or filter_parameter.startswith(TAG_SELECTOR): + if ( + filter_parameter.startswith(PATH_SELECTOR) + or filter_parameter.startswith(TAG_SELECTOR) + or PLUS_SELECTOR in filter_parameter + or any([filter_parameter.startswith(CONFIG_SELECTOR + config + ":") for config in SUPPORTED_CONFIG]) + ): continue - elif any([filter_parameter.startswith(CONFIG_SELECTOR + config + ":") for config in SUPPORTED_CONFIG]): - continue - else: + elif ":" in filter_parameter: raise CosmosValueError(f"Invalid {filter_type} filter: {filter_parameter}") subset_ids: set[str] = set() @@ -229,8 +407,9 @@ def select_nodes( for statement in select: config = SelectorConfig(project_dir, statement) node_selector = NodeSelector(nodes, config) - select_ids = node_selector.select_nodes_ids_by_intersection() - subset_ids = subset_ids.union(set(select_ids)) + + select_ids = node_selector.select_nodes_ids_by_intersection + subset_ids.update(set(select_ids)) if select: nodes = {id_: nodes[id_] for id_ in subset_ids} @@ -241,7 +420,7 @@ def select_nodes( for statement in exclude: config = SelectorConfig(project_dir, statement) node_selector = NodeSelector(nodes, config) - exclude_ids = exclude_ids.union(set(node_selector.select_nodes_ids_by_intersection())) + exclude_ids.update(set(node_selector.select_nodes_ids_by_intersection)) subset_ids = set(nodes_ids) - set(exclude_ids) return {id_: nodes[id_] for id_ in subset_ids} diff --git a/docs/configuration/selecting-excluding.rst b/docs/configuration/selecting-excluding.rst index fadea1485f..dfa4a96c59 100644 --- a/docs/configuration/selecting-excluding.rst +++ b/docs/configuration/selecting-excluding.rst @@ -10,7 +10,9 @@ The ``select`` and ``exclude`` parameters are lists, with values like the follow - ``tag:my_tag``: include/exclude models with the tag ``my_tag`` - ``config.materialized:table``: include/exclude models with the config ``materialized: table`` - ``path:analytics/tables``: include/exclude models in the ``analytics/tables`` directory - +- ``+node_name+1`` (graph operators): include/exclude the node with name ``node_name``, all its parents, and its first generation of children (`dbt graph selector docs `_) +- ``tag:my_tag,+node_name`` (intersection): include/exclude ``node_name`` and its parents if they have the tag ``my_tag`` (`dbt set operator docs `_) +- ``['tag:first_tag', 'tag:second_tag']`` (union): include/exclude nodes that have either ``tag:first_tag`` or ``tag:second_tag`` .. note:: @@ -51,3 +53,34 @@ Examples: select=["path:analytics/tables"], ) ) + + +.. code-block:: python + + from cosmos import DbtDag, RenderConfig + + jaffle_shop = DbtDag( + render_config=RenderConfig( + select=["tag:include_tag1", "tag:include_tag2"], # union + ) + ) + +.. code-block:: python + + from cosmos import DbtDag, RenderConfig + + jaffle_shop = DbtDag( + render_config=RenderConfig( + select=["tag:include_tag1,tag:include_tag2"], # intersection + ) + ) + +.. code-block:: python + + from cosmos import DbtDag, RenderConfig + + jaffle_shop = DbtDag( + render_config=RenderConfig( + exclude=["node_name+"], # node_name and its children + ) + ) diff --git a/tests/dbt/test_selector.py b/tests/dbt/test_selector.py index f7ece63910..1cf9871248 100644 --- a/tests/dbt/test_selector.py +++ b/tests/dbt/test_selector.py @@ -46,47 +46,69 @@ def test_is_empty_config(selector_config, paths, tags, config, other, expected): tags=["has_child"], config={"materialized": "view", "tags": ["has_child"]}, ) + +another_grandparent_node = DbtNode( + unique_id=f"{DbtResourceType.MODEL.value}.{SAMPLE_PROJ_PATH.stem}.another_grandparent_node", + resource_type=DbtResourceType.MODEL, + depends_on=[], + file_path=SAMPLE_PROJ_PATH / "gen1/models/another_grandparent_node.sql", + tags=[], + config={}, +) + parent_node = DbtNode( unique_id=f"{DbtResourceType.MODEL.value}.{SAMPLE_PROJ_PATH.stem}.parent", resource_type=DbtResourceType.MODEL, - depends_on=["grandparent"], + depends_on=[grandparent_node.unique_id, another_grandparent_node.unique_id], file_path=SAMPLE_PROJ_PATH / "gen2/models/parent.sql", tags=["has_child", "is_child"], config={"materialized": "view", "tags": ["has_child", "is_child"]}, ) + child_node = DbtNode( unique_id=f"{DbtResourceType.MODEL.value}.{SAMPLE_PROJ_PATH.stem}.child", resource_type=DbtResourceType.MODEL, - depends_on=["parent"], + depends_on=[parent_node.unique_id], file_path=SAMPLE_PROJ_PATH / "gen3/models/child.sql", tags=["nightly", "is_child"], config={"materialized": "table", "tags": ["nightly", "is_child"]}, ) -grandchild_1_test_node = DbtNode( - unique_id=f"{DbtResourceType.MODEL.value}.{SAMPLE_PROJ_PATH.stem}.grandchild_1", +sibling1_node = DbtNode( + unique_id=f"{DbtResourceType.MODEL.value}.{SAMPLE_PROJ_PATH.stem}.sibling1", resource_type=DbtResourceType.MODEL, - depends_on=["parent"], - file_path=SAMPLE_PROJ_PATH / "gen3/models/grandchild_1.sql", + depends_on=[parent_node.unique_id], + file_path=SAMPLE_PROJ_PATH / "gen3/models/sibling1.sql", tags=["nightly", "deprecated", "test"], config={"materialized": "table", "tags": ["nightly", "deprecated", "test"]}, ) -grandchild_2_test_node = DbtNode( - unique_id=f"{DbtResourceType.MODEL.value}.{SAMPLE_PROJ_PATH.stem}.grandchild_2", +sibling2_node = DbtNode( + unique_id=f"{DbtResourceType.MODEL.value}.{SAMPLE_PROJ_PATH.stem}.sibling2", resource_type=DbtResourceType.MODEL, - depends_on=["parent"], - file_path=SAMPLE_PROJ_PATH / "gen3/models/grandchild_2.sql", + depends_on=[parent_node.unique_id], + file_path=SAMPLE_PROJ_PATH / "gen3/models/sibling2.sql", tags=["nightly", "deprecated", "test2"], config={"materialized": "table", "tags": ["nightly", "deprecated", "test2"]}, ) +orphaned_node = DbtNode( + unique_id=f"{DbtResourceType.MODEL.value}.{SAMPLE_PROJ_PATH.stem}.orphaned", + resource_type=DbtResourceType.MODEL, + depends_on=[], + file_path=SAMPLE_PROJ_PATH / "gen3/models/orphaned.sql", + tags=[], + config={}, +) + sample_nodes = { grandparent_node.unique_id: grandparent_node, + another_grandparent_node.unique_id: another_grandparent_node, parent_node.unique_id: parent_node, child_node.unique_id: child_node, - grandchild_1_test_node.unique_id: grandchild_1_test_node, - grandchild_2_test_node.unique_id: grandchild_2_test_node, + sibling1_node.unique_id: sibling1_node, + sibling2_node.unique_id: sibling2_node, + orphaned_node.unique_id: orphaned_node, } @@ -100,8 +122,8 @@ def test_select_nodes_by_select_config(): selected = select_nodes(project_dir=SAMPLE_PROJ_PATH, nodes=sample_nodes, select=["config.materialized:table"]) expected = { child_node.unique_id: child_node, - grandchild_1_test_node.unique_id: grandchild_1_test_node, - grandchild_2_test_node.unique_id: grandchild_2_test_node, + sibling1_node.unique_id: sibling1_node, + sibling2_node.unique_id: sibling2_node, } assert selected == expected @@ -136,8 +158,8 @@ def test_select_nodes_by_select_union_config_test_tags(): expected = { grandparent_node.unique_id: grandparent_node, parent_node.unique_id: parent_node, - grandchild_1_test_node.unique_id: grandchild_1_test_node, - grandchild_2_test_node.unique_id: grandchild_2_test_node, + sibling1_node.unique_id: sibling1_node, + sibling2_node.unique_id: sibling2_node, } assert selected == expected @@ -176,8 +198,8 @@ def test_select_nodes_by_select_union(): grandparent_node.unique_id: grandparent_node, parent_node.unique_id: parent_node, child_node.unique_id: child_node, - grandchild_1_test_node.unique_id: grandchild_1_test_node, - grandchild_2_test_node.unique_id: grandchild_2_test_node, + sibling1_node.unique_id: sibling1_node, + sibling2_node.unique_id: sibling2_node, } assert selected == expected @@ -191,8 +213,10 @@ def test_select_nodes_by_exclude_tag(): selected = select_nodes(project_dir=SAMPLE_PROJ_PATH, nodes=sample_nodes, exclude=["tag:has_child"]) expected = { child_node.unique_id: child_node, - grandchild_1_test_node.unique_id: grandchild_1_test_node, - grandchild_2_test_node.unique_id: grandchild_2_test_node, + sibling1_node.unique_id: sibling1_node, + sibling2_node.unique_id: sibling2_node, + another_grandparent_node.unique_id: another_grandparent_node, + orphaned_node.unique_id: orphaned_node, } assert selected == expected @@ -217,8 +241,10 @@ def test_select_nodes_by_exclude_union_config_test_tags(): ) expected = { grandparent_node.unique_id: grandparent_node, + another_grandparent_node.unique_id: another_grandparent_node, parent_node.unique_id: parent_node, child_node.unique_id: child_node, + orphaned_node.unique_id: orphaned_node, } assert selected == expected @@ -227,15 +253,156 @@ def test_select_nodes_by_path_dir(): selected = select_nodes(project_dir=SAMPLE_PROJ_PATH, nodes=sample_nodes, select=["path:gen3/models"]) expected = { child_node.unique_id: child_node, - grandchild_1_test_node.unique_id: grandchild_1_test_node, - grandchild_2_test_node.unique_id: grandchild_2_test_node, + sibling1_node.unique_id: sibling1_node, + sibling2_node.unique_id: sibling2_node, + orphaned_node.unique_id: orphaned_node, } assert selected == expected def test_select_nodes_by_path_file(): selected = select_nodes(project_dir=SAMPLE_PROJ_PATH, nodes=sample_nodes, select=["path:gen2/models/parent.sql"]) - expected = { - parent_node.unique_id: parent_node, - } - assert selected == expected + expected = [parent_node.unique_id] + assert list(selected.keys()) == expected + + +def test_select_nodes_by_child_and_precursors(): + selected = select_nodes(project_dir=SAMPLE_PROJ_PATH, nodes=sample_nodes, select=["+child"]) + expected = [ + another_grandparent_node.unique_id, + child_node.unique_id, + grandparent_node.unique_id, + parent_node.unique_id, + ] + assert sorted(selected.keys()) == expected + + +def test_select_nodes_by_child_and_precursors_exclude_tags(): + selected = select_nodes( + project_dir=SAMPLE_PROJ_PATH, nodes=sample_nodes, select=["+child"], exclude=["tag:has_child"] + ) + expected = [another_grandparent_node.unique_id, child_node.unique_id] + assert sorted(selected.keys()) == expected + + +def test_select_node_by_child_and_precursors_partial_tree(): + selected = select_nodes(project_dir=SAMPLE_PROJ_PATH, nodes=sample_nodes, select=["+parent"]) + expected = [another_grandparent_node.unique_id, grandparent_node.unique_id, parent_node.unique_id] + assert sorted(selected.keys()) == expected + + +def test_select_node_by_precursors_with_orphaned_node(): + selected = select_nodes(project_dir=SAMPLE_PROJ_PATH, nodes=sample_nodes, select=["+orphaned"]) + expected = [orphaned_node.unique_id] + assert list(selected.keys()) == expected + + +def test_select_nodes_by_child_and_first_degree_precursors(): + selected = select_nodes(project_dir=SAMPLE_PROJ_PATH, nodes=sample_nodes, select=["1+child"]) + expected = [ + child_node.unique_id, + parent_node.unique_id, + ] + assert sorted(selected.keys()) == expected + + +def test_select_nodes_by_child_and_second_degree_precursors(): + selected = select_nodes(project_dir=SAMPLE_PROJ_PATH, nodes=sample_nodes, select=["2+child"]) + expected = [ + another_grandparent_node.unique_id, + child_node.unique_id, + grandparent_node.unique_id, + parent_node.unique_id, + ] + assert sorted(selected.keys()) == expected + + +def test_select_node_by_exact_node_name(): + selected = select_nodes(project_dir=SAMPLE_PROJ_PATH, nodes=sample_nodes, select=["child"]) + expected = [child_node.unique_id] + assert list(selected.keys()) == expected + + +def test_select_node_by_child_and_precursors_no_node(): + selected = select_nodes(project_dir=SAMPLE_PROJ_PATH, nodes=sample_nodes, select=["+modelDoesntExist"]) + expected = [] + assert list(selected.keys()) == expected + + +def test_select_node_by_descendants(): + selected = select_nodes(project_dir=SAMPLE_PROJ_PATH, nodes=sample_nodes, select=["grandparent+"]) + expected = [ + "model.dbt-proj.child", + "model.dbt-proj.grandparent", + "model.dbt-proj.parent", + "model.dbt-proj.sibling1", + "model.dbt-proj.sibling2", + ] + assert sorted(selected.keys()) == expected + + +def test_select_node_by_descendants_depth_first_degree(): + selected = select_nodes(project_dir=SAMPLE_PROJ_PATH, nodes=sample_nodes, select=["grandparent+1"]) + expected = [ + "model.dbt-proj.grandparent", + "model.dbt-proj.parent", + ] + assert sorted(selected.keys()) == expected + + +def test_select_node_by_descendants_union(): + selected = select_nodes(project_dir=SAMPLE_PROJ_PATH, nodes=sample_nodes, select=["grandparent+1", "parent+1"]) + expected = [ + "model.dbt-proj.child", + "model.dbt-proj.grandparent", + "model.dbt-proj.parent", + "model.dbt-proj.sibling1", + "model.dbt-proj.sibling2", + ] + assert sorted(selected.keys()) == expected + + +def test_select_node_by_descendants_intersection(): + selected = select_nodes(project_dir=SAMPLE_PROJ_PATH, nodes=sample_nodes, select=["grandparent+1,parent+1"]) + expected = [ + "model.dbt-proj.parent", + ] + assert sorted(selected.keys()) == expected + + +def test_select_node_by_descendants_intersection_with_tag(): + selected = select_nodes(project_dir=SAMPLE_PROJ_PATH, nodes=sample_nodes, select=["parent+1,tag:has_child"]) + expected = [ + "model.dbt-proj.parent", + ] + assert sorted(selected.keys()) == expected + + +def test_select_node_by_descendants_and_tag_union(): + selected = select_nodes(project_dir=SAMPLE_PROJ_PATH, nodes=sample_nodes, select=["child", "tag:has_child"]) + expected = [ + "model.dbt-proj.child", + "model.dbt-proj.grandparent", + "model.dbt-proj.parent", + ] + assert sorted(selected.keys()) == expected + + +def test_exclude_by_graph_selector(): + selected = select_nodes(project_dir=SAMPLE_PROJ_PATH, nodes=sample_nodes, exclude=["+parent"]) + expected = [ + "model.dbt-proj.child", + "model.dbt-proj.orphaned", + "model.dbt-proj.sibling1", + "model.dbt-proj.sibling2", + ] + assert sorted(selected.keys()) == expected + + +def test_exclude_by_union_graph_selector_and_tag(): + selected = select_nodes(project_dir=SAMPLE_PROJ_PATH, nodes=sample_nodes, exclude=["+parent", "tag:deprecated"]) + expected = [ + "model.dbt-proj.child", + "model.dbt-proj.orphaned", + ] + assert sorted(selected.keys()) == expected From 4380faa2eb61be96bd4c82e88765bf48a67eb168 Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Tue, 5 Dec 2023 11:22:34 +0000 Subject: [PATCH 042/223] Promote @jbandoro to committer (#744) [Justin Bandoro](https://www.linkedin.com/in/justin-bandoro-592b14a7/) (@jbandoro) is a Data Engineer at Kevala Inc. He's based in San Francisco (USA) and has been an early adopter of Cosmos, using it regularly at his company. Not only has he been using Cosmos since the early stages, but he has consistently improved Cosmos since January 2023: ![Screenshot 2023-12-04 at 16 28 29](https://github.com/astronomer/astronomer-cosmos/assets/272048/43197938-d1ab-431f-b101-b6026e5cd3ab) Some of his contributions include new features, code quality, documentation and overall improvements. Some examples: * Speed up integration tests in 67% #732 * Prevent override of dbt profile fields #702 * Add support for env vars in `RenderConfig` in #690 * Use symbolic links to run local tasks, avoiding to copy potentially huge dbt project folders in #660 * Improve documentation in #638 * Automated and improved the code complexity checks in #629 * Added `DbtDocsGCSOperator` in #616 * Added support for Python 3.7 in #88 and #214 Additionally, he has been interacting with users in the #airflow-dbt Slack channel in a very collaborative and supportive way. We want to promote him as a Cosmos committer and maintainer for all these, recognising his constant efforts and achievements towards our community. Thank you very much, @jbandoro ! --- docs/contributors.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/contributors.rst b/docs/contributors.rst index 273358d3c4..16f7dba17c 100644 --- a/docs/contributors.rst +++ b/docs/contributors.rst @@ -11,6 +11,7 @@ Committers * Chris Hronek (`@chrishronek `_) * Harel Shein (`@harels `_) * Julian LaNeve (`@jlaneve `_) +* Justin Bandoro (`@jbandoro `_) * Tatiana Al-Chueyr (`@tatiana `_) From f5274b12de4268e672d72e5f08b5b4585e0faf91 Mon Sep 17 00:00:00 2001 From: Joppe Vos <44348300+joppevos@users.noreply.github.com> Date: Tue, 5 Dec 2023 14:32:32 +0100 Subject: [PATCH 043/223] Reduce Mccabe code complexity to 8 (#738) Reduce the maccabe score from 10 to 8 and refactored the code flagged as too complex. I removed flake8, since we can use ruffs maccabe linter. The following functions have been addressed ```shell > pre-commit run flake8 --all-files flake8...................................................................Failed - hook id: flake8 - exit code: 1 cosmos/airflow/graph.py:196:1: C901 'build_airflow_graph' is too complex (9) cosmos/converter.py:101:5: C901 'DbtToAirflowConverter.__init__' is too complex (9) cosmos/dbt/parser/project.py:277:5: C901 'LegacyDbtProject.__post_init__' is too complex (10) cosmos/dbt/selector.py:197:1: C901 'select_nodes' is too complex (9) ``` I picked out the functions which stood out to me as complex. If we want to reduce it to 6, there are plenty more functions to tackle. For some functions I would consider it unnecessary to drop it to 6. ```shell cosmos/config.py:108:9: C901 `__init__` is too complex (7 > 6) cosmos/dbt/parser/project.py:63:9: C901 `_config_selector_ooo` is too complex (7 > 6) cosmos/dbt/parser/project.py:98:5: C901 `extract_python_file_upstream_requirements` is too complex (7 > 6) cosmos/dbt/parser/project.py:165:9: C901 `extract_sql_file_requirements` is too complex (7 > 6) cosmos/dbt/selector.py:52:9: C901 `load_from_statement` is too complex (7 > 6) cosmos/dbt/selector.py:119:9: C901 `_should_include_node` is too complex (7 > 6) cosmos/hooks/subprocess.py:34:9: C901 `run_command` is too complex (8 > 6) cosmos/operators/base.py:137:9: C901 `get_env` is too complex (7 > 6) cosmos/operators/base.py:186:9: C901 `add_global_flags` is too complex (7 > 6) cosmos/operators/local.py:136:9: C901 `store_compiled_sql` is too complex (7 > 6) cosmos/profiles/base.py:183:9: C901 `get_dbt_value` is too complex (8 > 6) Found 11 errors. ``` This PR continues on the amazing work of @jbandoro on #525 Related issue #641 --- cosmos/airflow/graph.py | 13 ++++- cosmos/converter.py | 28 +++++---- cosmos/dbt/parser/project.py | 12 ++-- cosmos/dbt/selector.py | 110 ++++++++++++++++++++++------------- pyproject.toml | 2 +- 5 files changed, 102 insertions(+), 63 deletions(-) diff --git a/cosmos/airflow/graph.py b/cosmos/airflow/graph.py index 0288b9e8db..9d9dba83ae 100644 --- a/cosmos/airflow/graph.py +++ b/cosmos/airflow/graph.py @@ -18,6 +18,7 @@ from cosmos.core.graph.entities import Task as TaskMetadata from cosmos.dbt.graph import DbtNode from cosmos.log import get_logger +from typing import Union logger = get_logger(__name__) @@ -284,7 +285,17 @@ def build_airflow_graph( for leaf_node_id in leaves_ids: tasks_map[leaf_node_id] >> test_task - # Create the Airflow task dependencies between non-test nodes + create_airflow_task_dependencies(nodes, tasks_map) + + +def create_airflow_task_dependencies( + nodes: dict[str, DbtNode], tasks_map: dict[str, Union[TaskGroup, BaseOperator]] +) -> None: + """ + Create the Airflow task dependencies between non-test nodes. + :param nodes: Dictionary mapping dbt nodes (node.unique_id to node) + :param tasks_map: Dictionary mapping dbt nodes (node.unique_id to Airflow task) + """ for node_id, node in nodes.items(): for parent_node_id in node.depends_on: # depending on the node type, it will not have mapped 1:1 to tasks_map diff --git a/cosmos/converter.py b/cosmos/converter.py index 45c95c2e7c..637ef98269 100644 --- a/cosmos/converter.py +++ b/cosmos/converter.py @@ -3,9 +3,9 @@ from __future__ import annotations -import copy import inspect from typing import Any, Callable +import copy from airflow.models.dag import DAG from airflow.utils.task_group import TaskGroup @@ -21,6 +21,18 @@ logger = get_logger(__name__) +def migrate_to_new_interface( + execution_config: ExecutionConfig, project_config: ProjectConfig, render_config: RenderConfig +): + # We copy the configuration so the change does not affect other DAGs or TaskGroups + # that may reuse the same original configuration + render_config = copy.deepcopy(render_config) + execution_config = copy.deepcopy(execution_config) + render_config.project_path = project_config.dbt_project_path + execution_config.project_path = project_config.dbt_project_path + return execution_config, render_config + + def specific_kwargs(**kwargs: dict[str, Any]) -> dict[str, Any]: """ Extract kwargs specific to the cosmos.converter.DbtToAirflowConverter class initialization method. @@ -166,22 +178,16 @@ def __init__( ) -> None: project_config.validate_project() - if not execution_config: - execution_config = ExecutionConfig() - if not render_config: - render_config = RenderConfig() + execution_config = execution_config or ExecutionConfig() + render_config = render_config or RenderConfig() + operator_args = operator_args or {} validate_initial_user_config(execution_config, profile_config, project_config, render_config) # If we are using the old interface, we should migrate it to the new interface # This is safe to do now since we have validated which config interface we're using if project_config.dbt_project_path: - # We copy the configuration so the change does not affect other DAGs or TaskGroups - # that may reuse the same original configuration - render_config = copy.deepcopy(render_config) - execution_config = copy.deepcopy(execution_config) - render_config.project_path = project_config.dbt_project_path - execution_config.project_path = project_config.dbt_project_path + execution_config, render_config = migrate_to_new_interface(execution_config, project_config, render_config) validate_adapted_user_config(execution_config, project_config, render_config) diff --git a/cosmos/dbt/parser/project.py b/cosmos/dbt/parser/project.py index 278b1a0f73..cadedef6c2 100644 --- a/cosmos/dbt/parser/project.py +++ b/cosmos/dbt/parser/project.py @@ -278,14 +278,10 @@ def __post_init__(self) -> None: """ Initializes the parser. """ - if self.dbt_root_path is None: - self.dbt_root_path = "/usr/local/airflow/dags/dbt" - if self.dbt_models_dir is None: - self.dbt_models_dir = "models" - if self.dbt_snapshots_dir is None: - self.dbt_snapshots_dir = "snapshots" - if self.dbt_seeds_dir is None: - self.dbt_seeds_dir = "seeds" + self.dbt_root_path = self.dbt_root_path or "/usr/local/airflow/dags/dbt" + self.dbt_models_dir = self.dbt_models_dir or "models" + self.dbt_snapshots_dir = self.dbt_snapshots_dir or "snapshots" + self.dbt_seeds_dir = self.dbt_seeds_dir or "seeds" # set the project and model dirs self.project_dir = Path(os.path.join(self.dbt_root_path, self.project_name)) diff --git a/cosmos/dbt/selector.py b/cosmos/dbt/selector.py index c7eb893075..76ec31a54b 100644 --- a/cosmos/dbt/selector.py +++ b/cosmos/dbt/selector.py @@ -210,27 +210,39 @@ def load_from_statement(self, statement: str) -> None: for item in items: if item.startswith(PATH_SELECTOR): - index = len(PATH_SELECTOR) - if self.project_dir: - self.paths.append(self.project_dir / Path(item[index:])) - else: - self.paths.append(Path(item[index:])) + self._parse_path_selector(item) elif item.startswith(TAG_SELECTOR): - index = len(TAG_SELECTOR) - self.tags.append(item[index:]) + self._parse_tag_selector(item) elif item.startswith(CONFIG_SELECTOR): - index = len(CONFIG_SELECTOR) - key, value = item[index:].split(":") - if key in SUPPORTED_CONFIG: - self.config[key] = value + self._parse_config_selector(item) else: - if item: - graph_selector = GraphSelector.parse(item) - if graph_selector is not None: - self.graph_selectors.append(graph_selector) - else: - self.other.append(item) - logger.warning("Unsupported select statement: %s", item) + self._parse_unknown_selector(item) + + def _parse_unknown_selector(self, item: str) -> None: + if item: + graph_selector = GraphSelector.parse(item) + if graph_selector is not None: + self.graph_selectors.append(graph_selector) + else: + self.other.append(item) + logger.warning("Unsupported select statement: %s", item) + + def _parse_config_selector(self, item: str) -> None: + index = len(CONFIG_SELECTOR) + key, value = item[index:].split(":") + if key in SUPPORTED_CONFIG: + self.config[key] = value + + def _parse_tag_selector(self, item: str) -> None: + index = len(TAG_SELECTOR) + self.tags.append(item[index:]) + + def _parse_path_selector(self, item: str) -> None: + index = len(PATH_SELECTOR) + if self.project_dir: + self.paths.append(self.project_dir / Path(item[index:])) + else: + self.paths.append(Path(item[index:])) def __repr__(self) -> str: return f"SelectorConfig(paths={self.paths}, tags={self.tags}, config={self.config}, other={self.other}, graph_selectors={self.graph_selectors})" @@ -388,7 +400,44 @@ def select_nodes( if not select and not exclude: return nodes - # validates select and exclude filters + validate_filters(exclude, select) + subset_ids = apply_select_filter(nodes, project_dir, select) + if select: + nodes = get_nodes_from_subset(nodes, subset_ids) + exclude_ids = apply_exclude_filter(nodes, project_dir, exclude) + subset_ids = set(nodes.keys()) - exclude_ids + + return get_nodes_from_subset(nodes, subset_ids) + + +def get_nodes_from_subset(nodes: dict[str, DbtNode], subset_ids: set[str]) -> dict[str, DbtNode]: + nodes = {id_: nodes[id_] for id_ in subset_ids} + return nodes + + +def apply_exclude_filter(nodes: dict[str, DbtNode], project_dir: Path | None, exclude: list[str]) -> set[str]: + exclude_ids: set[str] = set() + for statement in exclude: + config = SelectorConfig(project_dir, statement) + node_selector = NodeSelector(nodes, config) + exclude_ids.update(node_selector.select_nodes_ids_by_intersection) + return exclude_ids + + +def apply_select_filter(nodes: dict[str, DbtNode], project_dir: Path | None, select: list[str]) -> set[str]: + subset_ids: set[str] = set() + for statement in select: + config = SelectorConfig(project_dir, statement) + node_selector = NodeSelector(nodes, config) + select_ids = node_selector.select_nodes_ids_by_intersection + subset_ids.update(select_ids) + return subset_ids + + +def validate_filters(exclude: list[str], select: list[str]) -> None: + """ + Validate select and exclude filters. + """ filters = [["select", select], ["exclude", exclude]] for filter_type, filter in filters: for filter_parameter in filter: @@ -401,26 +450,3 @@ def select_nodes( continue elif ":" in filter_parameter: raise CosmosValueError(f"Invalid {filter_type} filter: {filter_parameter}") - - subset_ids: set[str] = set() - - for statement in select: - config = SelectorConfig(project_dir, statement) - node_selector = NodeSelector(nodes, config) - - select_ids = node_selector.select_nodes_ids_by_intersection - subset_ids.update(set(select_ids)) - - if select: - nodes = {id_: nodes[id_] for id_ in subset_ids} - - nodes_ids = set(nodes.keys()) - - exclude_ids: set[str] = set() - for statement in exclude: - config = SelectorConfig(project_dir, statement) - node_selector = NodeSelector(nodes, config) - exclude_ids.update(set(node_selector.select_nodes_ids_by_intersection)) - subset_ids = set(nodes_ids) - set(exclude_ids) - - return {id_: nodes[id_] for id_ in subset_ids} diff --git a/pyproject.toml b/pyproject.toml index fe399daee6..336c460600 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -260,5 +260,5 @@ line-length = 120 universal = true [tool.flake8] -max-complexity = 10 +max-complexity = 8 select = "C" From fec20ee972396d54bbe4e64f7a205d7d05930e19 Mon Sep 17 00:00:00 2001 From: DanMawdsleyBA <134394384+DanMawdsleyBA@users.noreply.github.com> Date: Tue, 5 Dec 2023 14:41:30 +0000 Subject: [PATCH 044/223] Symlink dbt packages when RenderConfig.dbt_deps=False (#730) Closes: #727 --- cosmos/dbt/graph.py | 2 +- cosmos/dbt/project.py | 7 ++++-- cosmos/operators/local.py | 2 +- tests/dbt/test_graph.py | 45 +++++++++++++++++++++++++++++++++++++++ tests/dbt/test_project.py | 2 +- 5 files changed, 53 insertions(+), 5 deletions(-) diff --git a/cosmos/dbt/graph.py b/cosmos/dbt/graph.py index 33c1d07b09..ece26baad4 100644 --- a/cosmos/dbt/graph.py +++ b/cosmos/dbt/graph.py @@ -269,7 +269,7 @@ def load_via_dbt_ls(self) -> None: f"Content of the dbt project dir {self.render_config.project_path}: `{os.listdir(self.render_config.project_path)}`" ) tmpdir_path = Path(tmpdir) - create_symlinks(self.render_config.project_path, tmpdir_path) + create_symlinks(self.render_config.project_path, tmpdir_path, self.render_config.dbt_deps) with self.profile_config.ensure_profile(use_mock_values=True) as profile_values, environ( self.render_config.env_vars diff --git a/cosmos/dbt/project.py b/cosmos/dbt/project.py index 14b2f5e4b0..aff6ed03ec 100644 --- a/cosmos/dbt/project.py +++ b/cosmos/dbt/project.py @@ -10,9 +10,12 @@ from typing import Generator -def create_symlinks(project_path: Path, tmp_dir: Path) -> None: +def create_symlinks(project_path: Path, tmp_dir: Path, ignore_dbt_packages: bool) -> None: """Helper function to create symlinks to the dbt project files.""" - ignore_paths = (DBT_LOG_DIR_NAME, DBT_TARGET_DIR_NAME, "dbt_packages", "profiles.yml") + ignore_paths = [DBT_LOG_DIR_NAME, DBT_TARGET_DIR_NAME, "profiles.yml"] + if ignore_dbt_packages: + # this is linked to dbt deps so if dbt deps is true then ignore existing dbt_packages folder + ignore_paths.append("dbt_packages") for child_name in os.listdir(project_path): if child_name not in ignore_paths: os.symlink(project_path / child_name, tmp_dir / child_name) diff --git a/cosmos/operators/local.py b/cosmos/operators/local.py index 71a7f4b4c6..3b1751cd98 100644 --- a/cosmos/operators/local.py +++ b/cosmos/operators/local.py @@ -199,7 +199,7 @@ def run_command( self.project_dir, ) - create_symlinks(Path(self.project_dir), Path(tmp_project_dir)) + create_symlinks(Path(self.project_dir), Path(tmp_project_dir), self.install_deps) with self.profile_config.ensure_profile() as profile_values: (profile_path, env_vars) = profile_values diff --git a/tests/dbt/test_graph.py b/tests/dbt/test_graph.py index a424976a10..9d8300d697 100644 --- a/tests/dbt/test_graph.py +++ b/tests/dbt/test_graph.py @@ -16,6 +16,7 @@ run_command, ) from cosmos.profiles import PostgresUserPasswordProfileMapping +from subprocess import Popen, PIPE DBT_PROJECTS_ROOT_DIR = Path(__file__).parent.parent.parent / "dev/dags/dbt" DBT_PROJECT_NAME = "jaffle_shop" @@ -436,6 +437,50 @@ def test_load_via_dbt_ls_without_dbt_deps(): assert err_info.value.args[0] == expected +@pytest.mark.integration +def test_load_via_dbt_ls_without_dbt_deps_and_preinstalled_dbt_packages(): + local_flags = [ + "--project-dir", + DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME, + "--profiles-dir", + DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME, + "--profile", + "default", + "--target", + "dev", + ] + + deps_command = ["dbt", "deps"] + deps_command.extend(local_flags) + process = Popen( + deps_command, + stdout=PIPE, + stderr=PIPE, + cwd=DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME, + universal_newlines=True, + ) + stdout, stderr = process.communicate() + + project_config = ProjectConfig(dbt_project_path=DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME) + render_config = RenderConfig(dbt_project_path=DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME, dbt_deps=False) + execution_config = ExecutionConfig(dbt_project_path=DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME) + dbt_graph = DbtGraph( + project=project_config, + render_config=render_config, + execution_config=execution_config, + profile_config=ProfileConfig( + profile_name="default", + target_name="default", + profile_mapping=PostgresUserPasswordProfileMapping( + conn_id="airflow_db", + profile_args={"schema": "public"}, + ), + ), + ) + + dbt_graph.load_via_dbt_ls() # does not raise exception + + @pytest.mark.integration @patch("cosmos.dbt.graph.Popen") def test_load_via_dbt_ls_with_zero_returncode_and_non_empty_stderr(mock_popen, tmp_dbt_project_dir): diff --git a/tests/dbt/test_project.py b/tests/dbt/test_project.py index ec5612904b..000ad06bdc 100644 --- a/tests/dbt/test_project.py +++ b/tests/dbt/test_project.py @@ -11,7 +11,7 @@ def test_create_symlinks(tmp_path): tmp_dir = tmp_path / "dbt-project" tmp_dir.mkdir() - create_symlinks(DBT_PROJECTS_ROOT_DIR / "jaffle_shop", tmp_dir) + create_symlinks(DBT_PROJECTS_ROOT_DIR / "jaffle_shop", tmp_dir, False) for child in tmp_dir.iterdir(): assert child.is_symlink() assert child.name not in ("logs", "target", "profiles.yml", "dbt_packages") From e89095874ee3ef190081661454bcc58861661e3b Mon Sep 17 00:00:00 2001 From: Joppe Vos <44348300+joppevos@users.noreply.github.com> Date: Tue, 5 Dec 2023 15:44:03 +0100 Subject: [PATCH 045/223] Improve CI performance - replace flake8 for ruff (#743) We are using ruff, which has drop-in parity with flake8. We can continue using only ruff and shave a few seconds of CI. Related #738 --- .pre-commit-config.yaml | 25 ++++++++++++++----------- pyproject.toml | 8 ++++---- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0ea06541bd..25bfe1d52e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,7 +22,7 @@ repos: - id: end-of-file-fixer - id: mixed-line-ending - id: pretty-format-json - args: ['--autofix'] + args: ["--autofix"] - id: trailing-whitespace - repo: https://github.com/codespell-project/codespell rev: v2.2.6 @@ -63,7 +63,7 @@ repos: rev: 23.11.0 hooks: - id: black - args: [ "--config", "./pyproject.toml" ] + args: ["--config", "./pyproject.toml"] - repo: https://github.com/asottile/blacken-docs rev: 1.16.0 hooks: @@ -71,22 +71,25 @@ repos: alias: black additional_dependencies: [black>=22.10.0] - repo: https://github.com/pre-commit/mirrors-mypy - rev: 'v1.7.1' + rev: "v1.7.1" + hooks: - id: mypy name: mypy-python - additional_dependencies: [types-PyYAML, types-attrs, attrs, types-requests, types-python-dateutil, apache-airflow] + additional_dependencies: + [ + types-PyYAML, + types-attrs, + attrs, + types-requests, + types-python-dateutil, + apache-airflow, + ] files: ^cosmos - - repo: https://github.com/pycqa/flake8 - rev: 6.1.0 - hooks: - - id: flake8 - entry: pflake8 - additional_dependencies: [pyproject-flake8] ci: autofix_commit_msg: 🎨 [pre-commit.ci] Auto format from pre-commit.com hooks autoupdate_commit_msg: ⬆ [pre-commit.ci] pre-commit autoupdate skip: - - mypy # build of https://github.com/pre-commit/mirrors-mypy:types-PyYAML,types-attrs,attrs,types-requests, + - mypy # build of https://github.com/pre-commit/mirrors-mypy:types-PyYAML,types-attrs,attrs,types-requests, #types-python-dateutil,apache-airflow@v1.5.0 for python@python3 exceeds tier max size 250MiB: 262.6MiB diff --git a/pyproject.toml b/pyproject.toml index 336c460600..2f295c12e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -255,10 +255,10 @@ no_warn_unused_ignores = true [tool.ruff] line-length = 120 +[tool.ruff.lint] +select = ["C901"] +[tool.ruff.lint.mccabe] +max-complexity = 8 [tool.distutils.bdist_wheel] universal = true - -[tool.flake8] -max-complexity = 8 -select = "C" From f4d771b59c356c5dddd4fc150ca9519a8e056edf Mon Sep 17 00:00:00 2001 From: Justin Bandoro <79104794+jbandoro@users.noreply.github.com> Date: Wed, 6 Dec 2023 15:35:42 -0800 Subject: [PATCH 046/223] Tests: use temp dir for `test_load_via_dbt_ls_without_dbt_deps_and_preinstalled_dbt_packages` (#748) This is a follow up to #730 so that the fixture `tmp_dbt_project_dir` is used for the test instead of the actual project directory since it installs dbt packages that need to be cleaned up. --- tests/dbt/test_graph.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/dbt/test_graph.py b/tests/dbt/test_graph.py index 9d8300d697..58a7ef7426 100644 --- a/tests/dbt/test_graph.py +++ b/tests/dbt/test_graph.py @@ -438,12 +438,12 @@ def test_load_via_dbt_ls_without_dbt_deps(): @pytest.mark.integration -def test_load_via_dbt_ls_without_dbt_deps_and_preinstalled_dbt_packages(): +def test_load_via_dbt_ls_without_dbt_deps_and_preinstalled_dbt_packages(tmp_dbt_project_dir): local_flags = [ "--project-dir", - DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME, + tmp_dbt_project_dir / DBT_PROJECT_NAME, "--profiles-dir", - DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME, + tmp_dbt_project_dir / DBT_PROJECT_NAME, "--profile", "default", "--target", @@ -456,14 +456,14 @@ def test_load_via_dbt_ls_without_dbt_deps_and_preinstalled_dbt_packages(): deps_command, stdout=PIPE, stderr=PIPE, - cwd=DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME, + cwd=tmp_dbt_project_dir / DBT_PROJECT_NAME, universal_newlines=True, ) stdout, stderr = process.communicate() - project_config = ProjectConfig(dbt_project_path=DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME) - render_config = RenderConfig(dbt_project_path=DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME, dbt_deps=False) - execution_config = ExecutionConfig(dbt_project_path=DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME) + project_config = ProjectConfig(dbt_project_path=tmp_dbt_project_dir / DBT_PROJECT_NAME) + render_config = RenderConfig(dbt_project_path=tmp_dbt_project_dir / DBT_PROJECT_NAME, dbt_deps=False) + execution_config = ExecutionConfig(dbt_project_path=tmp_dbt_project_dir / DBT_PROJECT_NAME) dbt_graph = DbtGraph( project=project_config, render_config=render_config, From 5038c2c7e8c80d97b3e6cae2b6a93a45490356ac Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Thu, 7 Dec 2023 02:23:32 +0000 Subject: [PATCH 047/223] Release 1.3.0a3 (#753) **Features** * Add `ProfileMapping` for Snowflake encrypted private key path by @ivanstillfront in #608 * Add support for Snowflake encrypted private key environment variable by @DanMawdsleyBA in #649 * Add `DbtDocsGCSOperator` for uploading dbt docs to GCS by @jbandoro in #616 * Add support to select using (some) graph operators when using `LoadMode.CUSTOM` and `LoadMode.DBT_MANIFEST` by @tatiana in #728 * Add cosmos/propagate_logs Airflow config support for disabling log propagation by @agreenburg in #648 * Add operator_args ``full_refresh`` as a templated field by @joppevos in #623 * Expose environment variables and dbt variables in ``ProjectConfig`` by @jbandoro in #735 **Enhancements** * Make Pydantic an optional dependency by @pixie79 in #736 * Create a symbolic link to `dbt_packages` when `dbt_deps` is False when using `LoadMode.DBT_LS` by @DanMawdsleyBA in #730 * Support no `profile_config` for `ExecutionMode.KUBERNETES` and `ExecutionMode.DOCKER` by @MrBones757 and @tatiana in #681 and #731 * Add `aws_session_token` for Athena mapping by @benjamin-awd in #663 **Others** * Replace flake8 for Ruff by @joppevos in #743 * Reduce code complexity to 8 by @joppevos in #738 * Update conflict matrix between Airflow and dbt versions by @tatiana in #731 * Speed up integration tests by @jbandoro in #732 --- CHANGELOG.rst | 27 ++++++++++++++++++++++----- cosmos/__init__.py | 2 +- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ecade71122..252875cfc5 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,7 +1,7 @@ Changelog ========= -1.3.0a2 (2023-11-23) +1.3.0a3 (2023-12-07) -------------------- Features @@ -10,6 +10,24 @@ Features * Add ``ProfileMapping`` for Snowflake encrypted private key path by @ivanstillfront in #608 * Add support for Snowflake encrypted private key environment variable by @DanMawdsleyBA in #649 * Add ``DbtDocsGCSOperator`` for uploading dbt docs to GCS by @jbandoro in #616 +* Add support to select using (some) graph operators when using ``LoadMode.CUSTOM`` and ``LoadMode.DBT_MANIFEST`` by @tatiana in #728 +* Add cosmos/propagate_logs Airflow config support for disabling log pr… by @agreenburg in #648 +* Add operator_args ``full_refresh`` as a templated field by @joppevos in #623 +* Expose environment variables and dbt variables in ``ProjectConfig`` by @jbandoro in #735 + +Enhancements + +* Make Pydantic an optional dependency by @pixie79 in #736 +* Create a symbolic link to ``dbt_packages`` when ``dbt_deps`` is False when using ``LoadMode.DBT_LS`` by @DanMawdsleyBA in #730 +* Support no ``profile_config`` for ``ExecutionMode.KUBERNETES`` and ``ExecutionMode.DOCKER`` by @MrBones757 and @tatiana in #681 and #731 +* Add ``aws_session_token`` for Athena mapping by @benjamin-awd in #663 + +Others + +* Replace flake8 for Ruff by @joppevos in #743 +* Reduce code complexity to 8 by @joppevos in #738 +* Update conflict matrix between Airflow and dbt versions by @tatiana in #731 +* Speed up integration tests by @jbandoro in #732 1.2.5 (2023-11-23) @@ -46,14 +64,13 @@ Others * Docs: add execution config to MWAA code example by @ugmuka in #674 * Docs: highlight DAG examples in docs by @iancmoritz and @jlaneve in #695 + 1.2.3 (2023-11-09) ------------------ -Features +Bug fix -* Add ``ProfileMapping`` for Vertica by @perttus in #540 -* Add ``ProfileMapping`` for Snowflake encrypted private key path by @ivanstillfront in #608 -* Add ``DbtDocsGCSOperator`` for uploading dbt docs to GCS by @jbandoro in #616 +* Fix reusing config across TaskGroups/DAGs by @tatiana in #664 1.2.2 (2023-11-06) diff --git a/cosmos/__init__.py b/cosmos/__init__.py index f1f2046347..2d3c2f6ac7 100644 --- a/cosmos/__init__.py +++ b/cosmos/__init__.py @@ -5,7 +5,7 @@ Contains dags, task groups, and operators. """ -__version__ = "1.3.0a2" +__version__ = "1.3.0a3" from cosmos.airflow.dag import DbtDag From aff66be0cc53a290c787bc4131d8fdbeff202a80 Mon Sep 17 00:00:00 2001 From: Justin Bandoro <79104794+jbandoro@users.noreply.github.com> Date: Thu, 7 Dec 2023 09:55:06 -0800 Subject: [PATCH 048/223] Expose environment variables and dbt variables in `ProjectConfig` (#735) ## Description Currently users have to specify environment variables in both `RenderConfig` and `operator_args` for the dbt dag so that they're used during rendering and execution. dbt variables cannot currently be used in rendering, only during execution in `operator_args`. This PR exposes `env_vars` and `dbt_vars` in `ProjectConfig` and uses the dbt variables in dbt ls load mode. Updates in this PR: - Deprecates `operator_args` "env" and "var", raising warnings that they will be removed in Cosmos 2.x - Deprecates `RenderConfig.env_vars` raising warnings that it will be removed in Cosmos 2.x - Adds both `dbt_vars` and `env_vars` within `ProjectConfig` - dbt variables are used in dbt ls load method - Raises an exception if **both** operator_args and ProjectConfig variables are used. - Updates docs and example DAGs to use ProjectConfig args. ## Related Issue(s) Closes #712 Closes #544 ## Breaking Change? None ## Checklist - [x] I have made corresponding changes to the documentation (if required) - [x] I have added tests that prove my fix is effective or that my feature works --------- Co-authored-by: Tatiana Al-Chueyr --- .pre-commit-config.yaml | 1 + cosmos/config.py | 20 ++++- cosmos/converter.py | 44 ++++++++-- cosmos/dbt/graph.py | 13 ++- cosmos/dbt/parser/project.py | 15 ++-- dev/dags/dbt/simple/models/top_animations.sql | 6 +- dev/dags/example_cosmos_sources.py | 15 ++-- docs/configuration/operator-args.rst | 4 +- docs/configuration/project-config.rst | 18 +++- docs/configuration/render-config.rst | 2 +- tests/dbt/parser/test_project.py | 2 +- tests/dbt/test_graph.py | 86 +++++++++++++++++++ tests/test_config.py | 6 ++ tests/test_converter.py | 86 ++++++++++++++++++- 14 files changed, 281 insertions(+), 37 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 25bfe1d52e..b0c1aac6aa 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -76,6 +76,7 @@ repos: hooks: - id: mypy name: mypy-python + args: [--config-file, "./pyproject.toml"] additional_dependencies: [ types-PyYAML, diff --git a/cosmos/config.py b/cosmos/config.py index 40756d2bb7..c5e7a69a30 100644 --- a/cosmos/config.py +++ b/cosmos/config.py @@ -7,6 +7,7 @@ import tempfile from dataclasses import InitVar, dataclass, field from pathlib import Path +import warnings from typing import Any, Iterator, Callable from cosmos.constants import DbtResourceType, TestBehavior, ExecutionMode, LoadMode, TestIndirectSelection @@ -42,7 +43,7 @@ class RenderConfig: :param dbt_deps: Configure to run dbt deps when using dbt ls for dag parsing :param node_converters: a dictionary mapping a ``DbtResourceType`` into a callable. Users can control how to render dbt nodes in Airflow. Only supported when using ``load_method=LoadMode.DBT_MANIFEST`` or ``LoadMode.DBT_LS``. :param dbt_executable_path: The path to the dbt executable for dag generation. Defaults to dbt if available on the path. - :param env_vars: A dictionary of environment variables for rendering. Only supported when using ``LoadMode.DBT_LS``. + :param env_vars: (Deprecated since Cosmos 1.3 use ProjectConfig.env_vars) A dictionary of environment variables for rendering. Only supported when using ``LoadMode.DBT_LS``. :param dbt_project_path Configures the DBT project location accessible on the airflow controller for DAG rendering. Mutually Exclusive with ProjectConfig.dbt_project_path. Required when using ``load_method=LoadMode.DBT_LS`` or ``load_method=LoadMode.CUSTOM``. """ @@ -54,12 +55,17 @@ class RenderConfig: dbt_deps: bool = True node_converters: dict[DbtResourceType, Callable[..., Any]] | None = None dbt_executable_path: str | Path = get_system_dbt() - env_vars: dict[str, str] = field(default_factory=dict) + env_vars: dict[str, str] | None = None dbt_project_path: InitVar[str | Path | None] = None project_path: Path | None = field(init=False) def __post_init__(self, dbt_project_path: str | Path | None) -> None: + if self.env_vars: + warnings.warn( + "RenderConfig.env_vars is deprecated since Cosmos 1.3 and will be removed in Cosmos 2.0. Use ProjectConfig.env_vars instead.", + DeprecationWarning, + ) self.project_path = Path(dbt_project_path) if dbt_project_path else None def validate_dbt_command(self, fallback_cmd: str | Path = "") -> None: @@ -96,6 +102,11 @@ class ProjectConfig: :param manifest_path: The absolute path to the dbt manifest file. Defaults to None :param project_name: Allows the user to define the project name. Required if dbt_project_path is not defined. Defaults to the folder name of dbt_project_path. + :param env_vars: Dictionary of environment variables that are used for both rendering and execution. Rendering with + env vars is only supported when using ``RenderConfig.LoadMode.DBT_LS`` load mode. + :param dbt_vars: Dictionary of dbt variables for the project. This argument overrides variables defined in your dbt_project.yml + file. The dictionary is dumped to a yaml string and passed to dbt commands as the --vars argument. Variables are only + supported for rendering when using ``RenderConfig.LoadMode.DBT_LS`` and ``RenderConfig.LoadMode.CUSTOM`` load mode. """ dbt_project_path: Path | None = None @@ -113,6 +124,8 @@ def __init__( snapshots_relative_path: str | Path = "snapshots", manifest_path: str | Path | None = None, project_name: str | None = None, + env_vars: dict[str, str] | None = None, + dbt_vars: dict[str, str] | None = None, ): # Since we allow dbt_project_path to be defined in ExecutionConfig and RenderConfig # dbt_project_path may not always be defined here. @@ -136,6 +149,9 @@ def __init__( if manifest_path: self.manifest_path = Path(manifest_path) + self.env_vars = env_vars + self.dbt_vars = dbt_vars + def validate_project(self) -> None: """ Validates necessary context is present for a project. diff --git a/cosmos/converter.py b/cosmos/converter.py index 637ef98269..c2b31700b9 100644 --- a/cosmos/converter.py +++ b/cosmos/converter.py @@ -6,6 +6,7 @@ import inspect from typing import Any, Callable import copy +from warnings import warn from airflow.models.dag import DAG from airflow.utils.task_group import TaskGroup @@ -96,10 +97,11 @@ def validate_arguments( def validate_initial_user_config( - execution_config: ExecutionConfig | None, + execution_config: ExecutionConfig, profile_config: ProfileConfig | None, project_config: ProjectConfig, - render_config: RenderConfig | None, + render_config: RenderConfig, + operator_args: dict[str, Any], ): """ Validates if the user set the fields as expected. @@ -108,6 +110,7 @@ def validate_initial_user_config( :param profile_config: Configuration related to dbt database configuration (profile) :param project_config: Configuration related to the overall dbt project :param render_config: Configuration related to how to convert the dbt workflow into an Airflow DAG + :param operator_args: Arguments to pass to the underlying operators. """ if profile_config is None and execution_config.execution_mode not in ( ExecutionMode.KUBERNETES, @@ -123,6 +126,33 @@ def validate_initial_user_config( + "If using RenderConfig.dbt_project_path or ExecutionConfig.dbt_project_path, ProjectConfig.dbt_project_path should be None" ) + # Cosmos 2.0 will remove the ability to pass in operator_args with 'env' and 'vars' in place of ProjectConfig.env_vars and + # ProjectConfig.dbt_vars. + if "env" in operator_args: + warn( + "operator_args with 'env' is deprecated since Cosmos 1.3 and will be removed in Cosmos 2.0. Use ProjectConfig.env_vars instead.", + DeprecationWarning, + ) + if project_config.env_vars: + raise CosmosValueError( + "ProjectConfig.env_vars and operator_args with 'env' are mutually exclusive and only one can be used." + ) + if "vars" in operator_args: + warn( + "operator_args with 'vars' is deprecated since Cosmos 1.3 and will be removed in Cosmos 2.0. Use ProjectConfig.vars instead.", + DeprecationWarning, + ) + if project_config.dbt_vars: + raise CosmosValueError( + "ProjectConfig.dbt_vars and operator_args with 'vars' are mutually exclusive and only one can be used." + ) + # Cosmos 2.0 will remove the ability to pass RenderConfig.env_vars in place of ProjectConfig.env_vars, check that both are not set. + if project_config.env_vars and render_config.env_vars: + raise CosmosValueError( + "Both ProjectConfig.env_vars and RenderConfig.env_vars were provided. RenderConfig.env_vars is deprecated since Cosmos 1.3, " + "please use ProjectConfig.env_vars instead." + ) + def validate_adapted_user_config( execution_config: ExecutionConfig | None, project_config: ProjectConfig, render_config: RenderConfig | None @@ -182,7 +212,7 @@ def __init__( render_config = render_config or RenderConfig() operator_args = operator_args or {} - validate_initial_user_config(execution_config, profile_config, project_config, render_config) + validate_initial_user_config(execution_config, profile_config, project_config, render_config, operator_args) # If we are using the old interface, we should migrate it to the new interface # This is safe to do now since we have validated which config interface we're using @@ -191,8 +221,8 @@ def __init__( validate_adapted_user_config(execution_config, project_config, render_config) - if not operator_args: - operator_args = {} + env_vars = project_config.env_vars or operator_args.pop("env", None) + dbt_vars = project_config.dbt_vars or operator_args.pop("vars", None) # Previously, we were creating a cosmos.dbt.project.DbtProject # DbtProject has now been replaced with ProjectConfig directly @@ -209,7 +239,7 @@ def __init__( render_config=render_config, execution_config=execution_config, profile_config=profile_config, - operator_args=operator_args, + dbt_vars=dbt_vars, ) dbt_graph.load(method=render_config.load_method, execution_mode=execution_config.execution_mode) @@ -218,6 +248,8 @@ def __init__( "project_dir": execution_config.project_path, "profile_config": profile_config, "emit_datasets": render_config.emit_datasets, + "env": env_vars, + "vars": dbt_vars, } if execution_config.dbt_executable_path: task_args["dbt_executable_path"] = execution_config.dbt_executable_path diff --git a/cosmos/dbt/graph.py b/cosmos/dbt/graph.py index ece26baad4..0305d22577 100644 --- a/cosmos/dbt/graph.py +++ b/cosmos/dbt/graph.py @@ -4,6 +4,7 @@ import json import os import tempfile +import yaml from dataclasses import dataclass, field from pathlib import Path from subprocess import PIPE, Popen @@ -164,13 +165,14 @@ def __init__( render_config: RenderConfig = RenderConfig(), execution_config: ExecutionConfig = ExecutionConfig(), profile_config: ProfileConfig | None = None, - operator_args: dict[str, Any] | None = None, + # dbt_vars only supported for LegacyDbtProject + dbt_vars: dict[str, str] | None = None, ): self.project = project self.render_config = render_config self.profile_config = profile_config self.execution_config = execution_config - self.operator_args = operator_args or {} + self.dbt_vars = dbt_vars or {} def load( self, @@ -227,6 +229,9 @@ def run_dbt_ls( if self.render_config.select: ls_command.extend(["--select", *self.render_config.select]) + if self.project.dbt_vars: + ls_command.extend(["--vars", yaml.dump(self.project.dbt_vars)]) + ls_command.extend(self.local_flags) stdout = run_command(ls_command, tmp_dir, env_vars) @@ -272,7 +277,7 @@ def load_via_dbt_ls(self) -> None: create_symlinks(self.render_config.project_path, tmpdir_path, self.render_config.dbt_deps) with self.profile_config.ensure_profile(use_mock_values=True) as profile_values, environ( - self.render_config.env_vars + self.project.env_vars or self.render_config.env_vars or {} ): (profile_path, env_vars) = profile_values env = os.environ.copy() @@ -333,7 +338,7 @@ def load_via_custom_parser(self) -> None: dbt_root_path=self.render_config.project_path.parent.as_posix(), dbt_models_dir=self.project.models_path.stem if self.project.models_path else "models", dbt_seeds_dir=self.project.seeds_path.stem if self.project.seeds_path else "seeds", - operator_args=self.operator_args, + dbt_vars=self.dbt_vars, ) nodes = {} models = itertools.chain( diff --git a/cosmos/dbt/parser/project.py b/cosmos/dbt/parser/project.py index cadedef6c2..de506e02d0 100644 --- a/cosmos/dbt/parser/project.py +++ b/cosmos/dbt/parser/project.py @@ -130,7 +130,7 @@ class DbtModel: name: str type: DbtModelType path: Path - operator_args: Dict[str, Any] = field(default_factory=dict) + dbt_vars: Dict[str, str] = field(default_factory=dict) config: DbtModelConfig = field(default_factory=DbtModelConfig) def __post_init__(self) -> None: @@ -141,7 +141,6 @@ def __post_init__(self) -> None: return config = DbtModelConfig() - self.var_args: Dict[str, Any] = self.operator_args.get("vars", {}) code = self.path.read_text() if self.type == DbtModelType.DBT_SNAPSHOT: @@ -203,7 +202,7 @@ def _parse_jinja_ref_node(self, base_node: jinja2.nodes.Call) -> str | None: and isinstance(node.args[0], jinja2.nodes.Const) and node.node.name == "var" ): - value += self.var_args[node.args[0].value] + value += self.dbt_vars[node.args[0].value] # type: ignore elif isinstance(first_arg, jinja2.nodes.Const): # and add it to the config value = first_arg.value @@ -272,7 +271,7 @@ class LegacyDbtProject: snapshots_dir: Path = field(init=False) seeds_dir: Path = field(init=False) - operator_args: Dict[str, Any] = field(default_factory=dict) + dbt_vars: Dict[str, str] = field(default_factory=dict) def __post_init__(self) -> None: """ @@ -321,7 +320,7 @@ def _handle_csv_file(self, path: Path) -> None: name=model_name, type=DbtModelType.DBT_SEED, path=path, - operator_args=self.operator_args, + dbt_vars=self.dbt_vars, ) # add the model to the project self.seeds[model_name] = model @@ -339,7 +338,7 @@ def _handle_sql_file(self, path: Path) -> None: name=model_name, type=DbtModelType.DBT_MODEL, path=path, - operator_args=self.operator_args, + dbt_vars=self.dbt_vars, ) # add the model to the project self.models[model.name] = model @@ -349,7 +348,7 @@ def _handle_sql_file(self, path: Path) -> None: name=model_name, type=DbtModelType.DBT_SNAPSHOT, path=path, - operator_args=self.operator_args, + dbt_vars=self.dbt_vars, ) # add the snapshot to the project self.snapshots[model.name] = model @@ -410,7 +409,7 @@ def _extract_model_tests( name=f"{test}_{column['name']}_{model_name}", type=DbtModelType.DBT_TEST, path=path, - operator_args=self.operator_args, + dbt_vars=self.dbt_vars, config=DbtModelConfig(upstream_models=set({model_name})), ) tests[test_model.name] = test_model diff --git a/dev/dags/dbt/simple/models/top_animations.sql b/dev/dags/dbt/simple/models/top_animations.sql index 2b365b09cb..cfae1c5952 100644 --- a/dev/dags/dbt/simple/models/top_animations.sql +++ b/dev/dags/dbt/simple/models/top_animations.sql @@ -1,4 +1,8 @@ -{{ config(materialized='table') }} +{{ config( + materialized='table', + alias=var('animation_alias', 'top_animations') + ) +}} SELECT Title, Rating FROM {{ ref('movies_ratings_simplified') }} diff --git a/dev/dags/example_cosmos_sources.py b/dev/dags/example_cosmos_sources.py index 157b3adb39..0553b2f10d 100644 --- a/dev/dags/example_cosmos_sources.py +++ b/dev/dags/example_cosmos_sources.py @@ -62,19 +62,24 @@ def convert_exposure(dag: DAG, task_group: TaskGroup, node: DbtNode, **kwargs): node_converters={ DbtResourceType("source"): convert_source, # known dbt node type to Cosmos (part of DbtResourceType) DbtResourceType("exposure"): convert_exposure, # dbt node type new to Cosmos (will be added to DbtResourceType) - }, + } +) + +# `ProjectConfig` can pass dbt variables and environment variables to dbt commands. Below is an example of +# passing a required env var for the profiles.yml file and a dbt variable that is used for rendering and +# executing dbt models. +project_config = ProjectConfig( + DBT_ROOT_PATH / "simple", env_vars={"DBT_SQLITE_PATH": DBT_SQLITE_PATH}, + dbt_vars={"animation_alias": "top_5_animated_movies"}, ) example_cosmos_sources = DbtDag( # dbt/cosmos-specific parameters - project_config=ProjectConfig( - DBT_ROOT_PATH / "simple", - ), + project_config=project_config, profile_config=profile_config, render_config=render_config, - operator_args={"env": {"DBT_SQLITE_PATH": DBT_SQLITE_PATH}}, # normal dag parameters schedule_interval="@daily", start_date=datetime(2023, 1, 1), diff --git a/docs/configuration/operator-args.rst b/docs/configuration/operator-args.rst index 9d533bf137..5ddbe6565a 100644 --- a/docs/configuration/operator-args.rst +++ b/docs/configuration/operator-args.rst @@ -47,12 +47,12 @@ dbt-related - ``dbt_cmd_flags``: List of command flags to pass to ``dbt`` command, added after dbt subcommand - ``dbt_cmd_global_flags``: List of ``dbt`` `global flags `_ to be passed to the ``dbt`` command, before the subcommand - ``dbt_executable_path``: Path to dbt executable. -- ``env``: Declare, using a Python dictionary, values to be set as environment variables when running ``dbt`` commands. +- ``env``: (Deprecated since Cosmos 1.3 use ``ProjectConfig.env_vars`` instead) Declare, using a Python dictionary, values to be set as environment variables when running ``dbt`` commands. - ``fail_fast``: ``dbt`` exits immediately if ``dbt`` fails to process a resource. - ``models``: Specifies which nodes to include. - ``no_version_check``: If set, skip ensuring ``dbt``'s version matches the one specified in the ``dbt_project.yml``. - ``quiet``: run ``dbt`` in silent mode, only displaying its error logs. -- ``vars``: Supply variables to the project. This argument overrides variables defined in the ``dbt_project.yml``. +- ``vars``: (Deprecated since Cosmos 1.3 use ``ProjectConfig.dbt_vars`` instead) Supply variables to the project. This argument overrides variables defined in the ``dbt_project.yml``. - ``warn_error``: convert ``dbt`` warnings into errors. Airflow-related diff --git a/docs/configuration/project-config.rst b/docs/configuration/project-config.rst index c1d952f6e1..c062a1de52 100644 --- a/docs/configuration/project-config.rst +++ b/docs/configuration/project-config.rst @@ -1,8 +1,8 @@ Project Config ================ -The ``cosmos.config.ProjectConfig`` allows you to specify information about where your dbt project is located. It -takes the following arguments: +The ``cosmos.config.ProjectConfig`` allows you to specify information about where your dbt project is located and project +variables that should be used for rendering and execution. It takes the following arguments: - ``dbt_project_path``: The full path to your dbt project. This directory should have a ``dbt_project.yml`` file - ``models_relative_path``: The path to your models directory, relative to the ``dbt_project_path``. This defaults to @@ -16,7 +16,13 @@ takes the following arguments: - ``project_name`` : The name of the project. If ``dbt_project_path`` is provided, the ``project_name`` defaults to the folder name containing ``dbt_project.yml``. If ``dbt_project_path`` is not provided, and ``manifest_path`` is provided, ``project_name`` is required as the name can not be inferred from ``dbt_project_path`` - +- ``dbt_vars``: (new in v1.3) A dictionary of dbt variables for the project rendering and execution. This argument overrides variables + defined in the dbt_project.yml file. The dictionary of variables is dumped to a yaml string and passed to dbt commands + as the --vars argument. Variables are only supported for rendering when using ``RenderConfig.LoadMode.DBT_LS`` and + ``RenderConfig.LoadMode.CUSTOM`` load mode. Variables using `Airflow templating `_ + will only be rendered at execution time, not at render time. +- ``env_vars``: (new in v1.3) A dictionary of environment variables used for rendering and execution. Rendering with + env vars is only supported when using ``RenderConfig.LoadMode.DBT_LS`` load mode. Project Config Example ---------------------- @@ -31,4 +37,10 @@ Project Config Example seeds_relative_path="data", snapshots_relative_path="snapshots", manifest_path="/path/to/manifests", + env_vars={"MY_ENV_VAR": "my_env_value"}, + dbt_vars={ + "my_dbt_var": "my_value", + "start_time": "{{ data_interval_start.strftime('%Y%m%d%H%M%S') }}", + "end_time": "{{ data_interval_end.strftime('%Y%m%d%H%M%S') }}", + }, ) diff --git a/docs/configuration/render-config.rst b/docs/configuration/render-config.rst index 5e1c23824c..1028ecf622 100644 --- a/docs/configuration/render-config.rst +++ b/docs/configuration/render-config.rst @@ -14,7 +14,7 @@ The ``RenderConfig`` class takes the following arguments: - ``dbt_deps``: A Boolean to run dbt deps when using dbt ls for dag parsing. Default True - ``node_converters``: a dictionary mapping a ``DbtResourceType`` into a callable. Users can control how to render dbt nodes in Airflow. Only supported when using ``load_method=LoadMode.DBT_MANIFEST`` or ``LoadMode.DBT_LS``. Find more information below. - ``dbt_executable_path``: The path to the dbt executable for dag generation. Defaults to dbt if available on the path. -- ``env_vars``: A dictionary of environment variables for rendering. Only supported when using ``load_method=LoadMode.DBT_LS``. +- ``env_vars``: (available in v1.2.5, use``ProjectConfig.env_vars`` for v1.3.0 onwards) A dictionary of environment variables for rendering. Only supported when using ``load_method=LoadMode.DBT_LS``. - ``dbt_project_path``: Configures the DBT project location accessible on their airflow controller for DAG rendering - Required when using ``load_method=LoadMode.DBT_LS`` or ``load_method=LoadMode.CUSTOM`` Customizing how nodes are rendered (experimental) diff --git a/tests/dbt/parser/test_project.py b/tests/dbt/parser/test_project.py index 4f13a3eb36..31fe7e18d0 100644 --- a/tests/dbt/parser/test_project.py +++ b/tests/dbt/parser/test_project.py @@ -219,6 +219,6 @@ def test_dbtmodelconfig_with_vars(tmp_path): name="some_name", type=DbtModelType.DBT_MODEL, path=path_with_sources, - operator_args={"vars": {"country_code": "us"}}, + dbt_vars={"country_code": "us"}, ) assert "stg_customers_us" in dbt_model.config.upstream_models diff --git a/tests/dbt/test_graph.py b/tests/dbt/test_graph.py index 58a7ef7426..3b80424b61 100644 --- a/tests/dbt/test_graph.py +++ b/tests/dbt/test_graph.py @@ -2,6 +2,7 @@ import tempfile from pathlib import Path from unittest.mock import patch +import yaml import pytest @@ -767,3 +768,88 @@ def test_parse_dbt_ls_output(): nodes = parse_dbt_ls_output(Path("fake-project"), fake_ls_stdout) assert expected_nodes == nodes + + +@patch("cosmos.dbt.graph.Popen") +@patch("cosmos.dbt.graph.DbtGraph.update_node_dependency") +@patch("cosmos.config.RenderConfig.validate_dbt_command") +def test_load_via_dbt_ls_project_config_env_vars(mock_validate, mock_update_nodes, mock_popen, tmp_dbt_project_dir): + """Tests that the dbt ls command in the subprocess has the project config env vars set.""" + mock_popen().communicate.return_value = ("", "") + mock_popen().returncode = 0 + env_vars = {"MY_ENV_VAR": "my_value"} + project_config = ProjectConfig(env_vars=env_vars) + render_config = RenderConfig(dbt_project_path=tmp_dbt_project_dir / DBT_PROJECT_NAME) + profile_config = ProfileConfig( + profile_name="test", + target_name="test", + profiles_yml_filepath=DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME / "profiles.yml", + ) + execution_config = ExecutionConfig(dbt_project_path=tmp_dbt_project_dir / DBT_PROJECT_NAME) + dbt_graph = DbtGraph( + project=project_config, + render_config=render_config, + execution_config=execution_config, + profile_config=profile_config, + ) + dbt_graph.load_via_dbt_ls() + + assert "MY_ENV_VAR" in mock_popen.call_args.kwargs["env"] + assert mock_popen.call_args.kwargs["env"]["MY_ENV_VAR"] == "my_value" + + +@patch("cosmos.dbt.graph.Popen") +@patch("cosmos.dbt.graph.DbtGraph.update_node_dependency") +@patch("cosmos.config.RenderConfig.validate_dbt_command") +def test_load_via_dbt_ls_project_config_dbt_vars(mock_validate, mock_update_nodes, mock_popen, tmp_dbt_project_dir): + """Tests that the dbt ls command in the subprocess has "--vars" with the project config dbt_vars.""" + mock_popen().communicate.return_value = ("", "") + mock_popen().returncode = 0 + dbt_vars = {"my_var1": "my_value1", "my_var2": "my_value2"} + project_config = ProjectConfig(dbt_vars=dbt_vars) + render_config = RenderConfig(dbt_project_path=tmp_dbt_project_dir / DBT_PROJECT_NAME) + profile_config = ProfileConfig( + profile_name="test", + target_name="test", + profiles_yml_filepath=DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME / "profiles.yml", + ) + execution_config = ExecutionConfig(dbt_project_path=tmp_dbt_project_dir / DBT_PROJECT_NAME) + dbt_graph = DbtGraph( + project=project_config, + render_config=render_config, + execution_config=execution_config, + profile_config=profile_config, + ) + dbt_graph.load_via_dbt_ls() + ls_command = mock_popen.call_args.args[0] + assert "--vars" in ls_command + assert ls_command[ls_command.index("--vars") + 1] == yaml.dump(dbt_vars) + + +@pytest.mark.sqlite +@pytest.mark.integration +def test_load_via_dbt_ls_with_project_config_vars(): + """ + Integration that tests that the dbt ls command is successful and that the node affected by the dbt_vars is + rendered correctly. + """ + project_name = "simple" + dbt_graph = DbtGraph( + project=ProjectConfig( + dbt_project_path=DBT_PROJECTS_ROOT_DIR / project_name, + env_vars={"DBT_SQLITE_PATH": str(DBT_PROJECTS_ROOT_DIR / "data")}, + dbt_vars={"animation_alias": "top_5_animated_movies"}, + ), + render_config=RenderConfig( + dbt_project_path=DBT_PROJECTS_ROOT_DIR / project_name, + dbt_deps=False, + ), + execution_config=ExecutionConfig(dbt_project_path=DBT_PROJECTS_ROOT_DIR / project_name), + profile_config=ProfileConfig( + profile_name="simple", + target_name="dev", + profiles_yml_filepath=(DBT_PROJECTS_ROOT_DIR / project_name / "profiles.yml"), + ), + ) + dbt_graph.load_via_dbt_ls() + assert dbt_graph.nodes["model.simple.top_animations"].config["alias"] == "top_5_animated_movies" diff --git a/tests/test_config.py b/tests/test_config.py index 578a68f760..734303a3e5 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -174,3 +174,9 @@ def test_render_config_uses_default_if_exists(mock_which): render_config = RenderConfig(dbt_executable_path="user-dbt") render_config.validate_dbt_command("fallback-dbt-path") assert render_config.dbt_executable_path == "user-dbt" + + +def test_render_config_env_vars_deprecated(): + """RenderConfig.env_vars is deprecated since Cosmos 1.3, should warn user.""" + with pytest.deprecated_call(): + RenderConfig(env_vars={"VAR": "value"}) diff --git a/tests/test_converter.py b/tests/test_converter.py index c04da2c3a6..d84249aaee 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -1,13 +1,13 @@ from datetime import datetime from pathlib import Path -from unittest.mock import patch +from unittest.mock import patch, MagicMock from cosmos.profiles.postgres import PostgresUserPasswordProfileMapping import pytest from airflow.models import DAG from cosmos.converter import DbtToAirflowConverter, validate_arguments, validate_initial_user_config -from cosmos.constants import DbtResourceType, ExecutionMode +from cosmos.constants import DbtResourceType, ExecutionMode, LoadMode from cosmos.config import ProjectConfig, ProfileConfig, ExecutionConfig, RenderConfig, CosmosConfigException from cosmos.dbt.graph import DbtNode from cosmos.exceptions import CosmosValueError @@ -44,7 +44,7 @@ def test_validate_initial_user_config_no_profile(execution_mode): profile_config = None project_config = ProjectConfig() with pytest.raises(CosmosValueError) as err_info: - validate_initial_user_config(execution_config, profile_config, project_config, None) + validate_initial_user_config(execution_config, profile_config, project_config, None, {}) err_msg = f"The profile_config is mandatory when using {execution_mode}" assert err_info.value.args[0] == err_msg @@ -57,7 +57,55 @@ def test_validate_initial_user_config_expects_profile(execution_mode): execution_config = ExecutionConfig(execution_mode=execution_mode) profile_config = None project_config = ProjectConfig() - assert validate_initial_user_config(execution_config, profile_config, project_config, None) is None + assert validate_initial_user_config(execution_config, profile_config, project_config, None, {}) is None + + +@pytest.mark.parametrize("operator_args", [{"env": {"key": "value"}}, {"vars": {"key": "value"}}]) +def test_validate_user_config_operator_args_deprecated(operator_args): + """Deprecating warnings should be raised when using operator_args with "vars" or "env".""" + project_config = ProjectConfig() + execution_config = ExecutionConfig() + render_config = RenderConfig() + profile_config = MagicMock() + + with pytest.deprecated_call(): + validate_initial_user_config(execution_config, profile_config, project_config, render_config, operator_args) + + +@pytest.mark.parametrize("project_config_arg, operator_arg", [("dbt_vars", "vars"), ("env_vars", "env")]) +def test_validate_user_config_fails_project_config_and_operator_args_overlap(project_config_arg, operator_arg): + """ + The validation should fail if a user specifies both a ProjectConfig and operator_args with dbt_vars/vars or env_vars/env + that overlap. + """ + project_config = ProjectConfig( + project_name="fake-project", + dbt_project_path="/some/project/path", + **{project_config_arg: {"key": "value"}}, # type: ignore + ) + execution_config = ExecutionConfig() + render_config = RenderConfig() + profile_config = MagicMock() + operator_args = {operator_arg: {"key": "value"}} + + expected_error_msg = f"ProjectConfig.{project_config_arg} and operator_args with '{operator_arg}' are mutually exclusive and only one can be used." + with pytest.raises(CosmosValueError, match=expected_error_msg): + validate_initial_user_config(execution_config, profile_config, project_config, render_config, operator_args) + + +def test_validate_user_config_fails_project_config_render_config_env_vars(): + """ + The validation should fail if a user specifies both ProjectConfig.env_vars and RenderConfig.env_vars. + """ + project_config = ProjectConfig(env_vars={"key": "value"}) + execution_config = ExecutionConfig() + render_config = RenderConfig(env_vars={"key": "value"}) + profile_config = MagicMock() + operator_args = {} + + expected_error_match = "Both ProjectConfig.env_vars and RenderConfig.env_vars were provided.*" + with pytest.raises(CosmosValueError, match=expected_error_match): + validate_initial_user_config(execution_config, profile_config, project_config, render_config, operator_args) def test_validate_arguments_schema_in_task_args(): @@ -327,3 +375,33 @@ def test_converter_fails_no_manifest_no_render_config(mock_load_dbt_graph, execu err_info.value.args[0] == "RenderConfig.dbt_project_path is required for rendering an airflow DAG from a DBT Graph if no manifest is provided." ) + + +@patch("cosmos.config.ProjectConfig.validate_project") +@patch("cosmos.converter.build_airflow_graph") +@patch("cosmos.dbt.graph.LegacyDbtProject") +def test_converter_project_config_dbt_vars_with_custom_load_mode( + mock_legacy_dbt_project, mock_validate_project, mock_build_airflow_graph +): + """Tests that if ProjectConfig.dbt_vars are used with RenderConfig.load_method of "custom" that the + expected dbt_vars are passed to LegacyDbtProject. + """ + project_config = ProjectConfig( + project_name="fake-project", dbt_project_path="/some/project/path", dbt_vars={"key": "value"} + ) + execution_config = ExecutionConfig() + render_config = RenderConfig(load_method=LoadMode.CUSTOM) + profile_config = MagicMock() + + with DAG("test-id", start_date=datetime(2022, 1, 1)) as dag: + DbtToAirflowConverter( + dag=dag, + nodes=nodes, + project_config=project_config, + profile_config=profile_config, + execution_config=execution_config, + render_config=render_config, + operator_args={}, + ) + _, kwargs = mock_legacy_dbt_project.call_args + assert kwargs["dbt_vars"] == {"key": "value"} From eecad2174f3102fc9097c5f649005cf67f5d177a Mon Sep 17 00:00:00 2001 From: Justin Bandoro <79104794+jbandoro@users.noreply.github.com> Date: Tue, 12 Dec 2023 06:36:06 -0800 Subject: [PATCH 049/223] Add package location to work with hatchling 1.19.0 (#761) Fixes issue described in #760 where Cosmos tests are failing from a recent `hatchling` update that now requires package path to be specified if it can't be inferred from the package's name. Closes #760 --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 2f295c12e4..c08de4adea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -138,6 +138,9 @@ include = [ "/cosmos", ] +[tool.hatch.build.targets.wheel] +packages = ["cosmos"] + ###################################### # TESTING ###################################### From 57fb9db4ed601aef31ded287ddb0282199b79f88 Mon Sep 17 00:00:00 2001 From: Justin Bandoro <79104794+jbandoro@users.noreply.github.com> Date: Wed, 13 Dec 2023 04:00:47 -0800 Subject: [PATCH 050/223] Fix type check error in `DbtKubernetesBaseOperator.build_env_args` (#766) The imported function `airflow.providers.cncf.kubernetes.backcompat.backwards_compat_converters.convert_env_vars` was recently updated with type hints and can only accept dictionaries of type `dict[str, str]`. The fix in this PR is to ensure the env var values are strings. Closes #765 --- cosmos/operators/kubernetes.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cosmos/operators/kubernetes.py b/cosmos/operators/kubernetes.py index af0988a6ac..b844716de1 100644 --- a/cosmos/operators/kubernetes.py +++ b/cosmos/operators/kubernetes.py @@ -56,10 +56,12 @@ def __init__(self, profile_config: ProfileConfig | None = None, **kwargs: Any) - def build_env_args(self, env: dict[str, str | bytes | PathLike[Any]]) -> None: env_vars_dict: dict[str, str] = dict() + for env_var_key, env_var_value in env.items(): + env_vars_dict[env_var_key] = str(env_var_value) for env_var in self.env_vars: env_vars_dict[env_var.name] = env_var.value - self.env_vars: list[Any] = convert_env_vars({**env, **env_vars_dict}) + self.env_vars: list[Any] = convert_env_vars(env_vars_dict) def build_and_run_cmd(self, context: Context, cmd_flags: list[str] | None = None) -> Any: self.build_kube_args(context, cmd_flags) From 390bdb8582a1358234f30c60f0edf0d2cdb5250f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 13 Dec 2023 12:14:23 +0000 Subject: [PATCH 051/223] =?UTF-8?q?=E2=AC=86=20[pre-commit.ci]=20pre-commi?= =?UTF-8?q?t=20autoupdate=20(#762)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.1.6 → v0.1.7](https://github.com/astral-sh/ruff-pre-commit/compare/v0.1.6...v0.1.7) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Tatiana Al-Chueyr --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b0c1aac6aa..baf2cd57c5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -54,7 +54,7 @@ repos: - --py37-plus - --keep-runtime-typing - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.6 + rev: v0.1.7 hooks: - id: ruff args: From 11f8a1fe5ec408fc35936b71966372723e6dfbb6 Mon Sep 17 00:00:00 2001 From: Perttu Salonen Date: Wed, 13 Dec 2023 16:18:10 +0200 Subject: [PATCH 052/223] Fix map Vertica airflow connection `schema` for vertica `database` (#741) Map airflow connection schema for vertica database to keep it consistent with other connection types and profiles. Also Vertica Airflow provider assumes this: https://github.com/apache/airflow/blob/395ac463494dba1478a05a32900218988495889c/airflow/providers/vertica/hooks/vertica.py#L72 Closes: #740 Related: #538 Signed-off-by: Perttu Salonen --- cosmos/profiles/vertica/user_pass.py | 13 ++++++++---- .../vertica/test_vertica_user_pass.py | 21 ++++++++----------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/cosmos/profiles/vertica/user_pass.py b/cosmos/profiles/vertica/user_pass.py index ccaaf301dc..e016b612c6 100644 --- a/cosmos/profiles/vertica/user_pass.py +++ b/cosmos/profiles/vertica/user_pass.py @@ -9,8 +9,14 @@ class VerticaUserPasswordProfileMapping(BaseProfileMapping): """ Maps Airflow Vertica connections using username + password authentication to dbt profiles. - https://docs.getdbt.com/reference/warehouse-setups/vertica-setup - https://airflow.apache.org/docs/apache-airflow-providers-vertica/stable/connections/vertica.html + .. note:: + Use Airflow connection ``schema`` for vertica ``database`` to keep it consistent with other connection types and profiles. \ + The Vertica Airflow provider hook `assumes this `_. + This seems to be a common approach also for `Postgres `_, \ + Redshift and Exasol since there is no ``database`` field in Airflow connection and ``schema`` is not required for the database connection. + .. seealso:: + https://docs.getdbt.com/reference/warehouse-setups/vertica-setup + https://airflow.apache.org/docs/apache-airflow-providers-vertica/stable/connections/vertica.html """ airflow_connection_type: str = "vertica" @@ -31,8 +37,7 @@ class VerticaUserPasswordProfileMapping(BaseProfileMapping): "username": "login", "password": "password", "port": "port", - "schema": "schema", - "database": "extra.database", + "database": "schema", "autocommit": "extra.autocommit", "backup_server_node": "extra.backup_server_node", "binary_transfer": "extra.binary_transfer", diff --git a/tests/profiles/vertica/test_vertica_user_pass.py b/tests/profiles/vertica/test_vertica_user_pass.py index 19771c799b..6459dea962 100644 --- a/tests/profiles/vertica/test_vertica_user_pass.py +++ b/tests/profiles/vertica/test_vertica_user_pass.py @@ -23,8 +23,7 @@ def mock_vertica_conn(): # type: ignore login="my_user", password="my_password", port=5433, - schema="my_schema", - extra='{"database": "my_database"}', + schema="my_database", ) with patch("airflow.hooks.base.BaseHook.get_connection", return_value=conn): @@ -43,8 +42,7 @@ def mock_vertica_conn_custom_port(): # type: ignore login="my_user", password="my_password", port=7472, - schema="my_schema", - extra='{"database": "my_database"}', + schema="my_database", ) with patch("airflow.hooks.base.BaseHook.get_connection", return_value=conn): @@ -69,8 +67,7 @@ def test_connection_claiming() -> None: "host": "my_host", "login": "my_user", "password": "my_password", - "schema": "my_schema", - "extra": '{"database": "my_database"}', + "schema": "my_database", } # if we're missing any of the values, it shouldn't claim @@ -82,20 +79,20 @@ def test_connection_claiming() -> None: print("testing with", values) with patch("airflow.hooks.base.BaseHook.get_connection", return_value=conn): - profile_mapping = VerticaUserPasswordProfileMapping(conn) + profile_mapping = VerticaUserPasswordProfileMapping(conn, {"schema": "my_schema"}) assert not profile_mapping.can_claim_connection() - # also test when there's no database + # also test when there's no schema conn = Connection(**potential_values) # type: ignore conn.extra = "" with patch("airflow.hooks.base.BaseHook.get_connection", return_value=conn): - profile_mapping = VerticaUserPasswordProfileMapping(conn) + profile_mapping = VerticaUserPasswordProfileMapping(conn, {}) assert not profile_mapping.can_claim_connection() # if we have them all, it should claim conn = Connection(**potential_values) # type: ignore with patch("airflow.hooks.base.BaseHook.get_connection", return_value=conn): - profile_mapping = VerticaUserPasswordProfileMapping(conn) + profile_mapping = VerticaUserPasswordProfileMapping(conn, {"schema": "my_schema"}) assert profile_mapping.can_claim_connection() @@ -107,7 +104,7 @@ def test_profile_mapping_selected( """ profile_mapping = get_automatic_profile_mapping( mock_vertica_conn.conn_id, - {"schema": "my_schema"}, + {"schema": "my_database"}, ) assert isinstance(profile_mapping, VerticaUserPasswordProfileMapping) @@ -145,8 +142,8 @@ def test_profile_args( "username": mock_vertica_conn.login, "password": "{{ env_var('COSMOS_CONN_VERTICA_PASSWORD') }}", "port": mock_vertica_conn.port, + "database": mock_vertica_conn.schema, "schema": "my_schema", - "database": mock_vertica_conn.extra_dejson.get("database"), } From 9890f231855ae980d1114325511af3c8e1bd7d0b Mon Sep 17 00:00:00 2001 From: Justin Bandoro <79104794+jbandoro@users.noreply.github.com> Date: Wed, 13 Dec 2023 08:38:33 -0800 Subject: [PATCH 053/223] Add support for dbt `selector` arg for DAG parsing (#755) =This PR adds support for a new `selector` arg in `RenderConfig` so users can reference[ dbt yaml selectors](https://docs.getdbt.com/reference/node-selection/yaml-selectors) when rendering a project using dbt ls by adding the `--selector` arg to the cli command. This was pretty straightforward for dbt ls, though in order to support custom and manifest filtering we would need to add logic for translating the yaml selector definitions in follow up PR(s). Added a coverage test and integration test here, and a small update in `tests/dbt/test_graph.py` to use a fixture for a ProfileConfig that is repeated 9 times. An open question I have is whether we want to log a warning in `RenderConfig.__post_init__` if a user uses both `select`/`exclude` with `selector`. dbt docs [here](https://docs.getdbt.com/reference/node-selection/syntax#examples) state that when using `--selector` with `--exclude` and/or `--select` the select/exclude flags are ignored (no error or warnings are logged by dbt if both are used). Closes #718 --- cosmos/config.py | 2 + cosmos/dbt/graph.py | 13 ++ docs/configuration/render-config.rst | 1 + docs/configuration/selecting-excluding.rst | 29 ++- tests/dbt/test_graph.py | 208 +++++++++++++-------- 5 files changed, 170 insertions(+), 83 deletions(-) diff --git a/cosmos/config.py b/cosmos/config.py index c5e7a69a30..3b332931fb 100644 --- a/cosmos/config.py +++ b/cosmos/config.py @@ -40,6 +40,7 @@ class RenderConfig: :param load_method: The parsing method for loading the dbt model. Defaults to AUTOMATIC :param select: A list of dbt select arguments (e.g. 'config.materialized:incremental') :param exclude: A list of dbt exclude arguments (e.g. 'tag:nightly') + :param selector: Name of a dbt YAML selector to use for parsing. Only supported when using ``load_method=LoadMode.DBT_LS``. :param dbt_deps: Configure to run dbt deps when using dbt ls for dag parsing :param node_converters: a dictionary mapping a ``DbtResourceType`` into a callable. Users can control how to render dbt nodes in Airflow. Only supported when using ``load_method=LoadMode.DBT_MANIFEST`` or ``LoadMode.DBT_LS``. :param dbt_executable_path: The path to the dbt executable for dag generation. Defaults to dbt if available on the path. @@ -52,6 +53,7 @@ class RenderConfig: load_method: LoadMode = LoadMode.AUTOMATIC select: list[str] = field(default_factory=list) exclude: list[str] = field(default_factory=list) + selector: str | None = None dbt_deps: bool = True node_converters: dict[DbtResourceType, Callable[..., Any]] | None = None dbt_executable_path: str | Path = get_system_dbt() diff --git a/cosmos/dbt/graph.py b/cosmos/dbt/graph.py index 0305d22577..d4fa0db494 100644 --- a/cosmos/dbt/graph.py +++ b/cosmos/dbt/graph.py @@ -232,6 +232,9 @@ def run_dbt_ls( if self.project.dbt_vars: ls_command.extend(["--vars", yaml.dump(self.project.dbt_vars)]) + if self.render_config.selector: + ls_command.extend(["--selector", self.render_config.selector]) + ls_command.extend(self.local_flags) stdout = run_command(ls_command, tmp_dir, env_vars) @@ -328,6 +331,11 @@ def load_via_custom_parser(self) -> None: """ logger.info("Trying to parse the dbt project `%s` using a custom Cosmos method...", self.project.project_name) + if self.render_config.selector: + raise CosmosLoadDbtException( + "RenderConfig.selector is not yet supported when loading dbt projects using the LoadMode.CUSTOM parser." + ) + if not self.render_config.project_path or not self.execution_config.project_path: raise CosmosLoadDbtException( "Unable to load dbt project without RenderConfig.dbt_project_path and ExecutionConfig.dbt_project_path" @@ -386,6 +394,11 @@ def load_from_dbt_manifest(self) -> None: """ logger.info("Trying to parse the dbt project `%s` using a dbt manifest...", self.project.project_name) + if self.render_config.selector: + raise CosmosLoadDbtException( + "RenderConfig.selector is not yet supported when loading dbt projects using the LoadMode.DBT_MANIFEST parser." + ) + if not self.project.is_manifest_available(): raise CosmosLoadDbtException(f"Unable to load manifest using {self.project.manifest_path}") diff --git a/docs/configuration/render-config.rst b/docs/configuration/render-config.rst index 1028ecf622..6d669d0a5d 100644 --- a/docs/configuration/render-config.rst +++ b/docs/configuration/render-config.rst @@ -11,6 +11,7 @@ The ``RenderConfig`` class takes the following arguments: - ``test_behavior``: how to run tests. Defaults to running a model's tests immediately after the model is run. For more information, see the `Testing Behavior `_ section. - ``load_method``: how to load your dbt project. See `Parsing Methods `_ for more information. - ``select`` and ``exclude``: which models to include or exclude from your DAGs. See `Selecting & Excluding `_ for more information. +- ``selector``: (new in v1.3) name of a dbt YAML selector to use for DAG parsing. Only supported when using ``load_method=LoadMode.DBT_LS``. See `Selecting & Excluding `_ for more information. - ``dbt_deps``: A Boolean to run dbt deps when using dbt ls for dag parsing. Default True - ``node_converters``: a dictionary mapping a ``DbtResourceType`` into a callable. Users can control how to render dbt nodes in Airflow. Only supported when using ``load_method=LoadMode.DBT_MANIFEST`` or ``LoadMode.DBT_LS``. Find more information below. - ``dbt_executable_path``: The path to the dbt executable for dag generation. Defaults to dbt if available on the path. diff --git a/docs/configuration/selecting-excluding.rst b/docs/configuration/selecting-excluding.rst index dfa4a96c59..01ee536b0a 100644 --- a/docs/configuration/selecting-excluding.rst +++ b/docs/configuration/selecting-excluding.rst @@ -3,7 +3,13 @@ Selecting & Excluding ======================= -Cosmos allows you to filter to a subset of your dbt project in each ``DbtDag`` / ``DbtTaskGroup`` using the ``select`` and ``exclude`` parameters in the ``RenderConfig`` class. +Cosmos allows you to filter to a subset of your dbt project in each ``DbtDag`` / ``DbtTaskGroup`` using the ``select `` and ``exclude`` parameters in the ``RenderConfig`` class. + + Since Cosmos 1.3, the ``selector`` parameter is also available in ``RenderConfig`` when using the ``LoadMode.DBT_LS`` to parse the dbt project into Airflow. + + +Using ``select`` and ``exclude`` +-------------------------------- The ``select`` and ``exclude`` parameters are lists, with values like the following: @@ -84,3 +90,24 @@ Examples: exclude=["node_name+"], # node_name and its children ) ) + +Using ``selector`` +-------------------------------- +.. note:: + Only currently supported using the ``dbt_ls`` parsing method since Cosmos 1.3 where the selector is passed directly to the dbt CLI command. \ + If ``select`` and/or ``exclude`` are used with ``selector``, dbt will ignore the ``select`` and ``exclude`` parameters. + +The ``selector`` parameter is a string that references a `dbt YAML selector `_ already defined in a dbt project. + +Examples: + +.. code-block:: python + + from cosmos import DbtDag, RenderConfig, LoadMode + + jaffle_shop = DbtDag( + render_config=RenderConfig( + selector="my_selector", # this selector must be defined in your dbt project + load_method=LoadMode.DBT_LS, + ) + ) diff --git a/tests/dbt/test_graph.py b/tests/dbt/test_graph.py index 3b80424b61..2816fd07a5 100644 --- a/tests/dbt/test_graph.py +++ b/tests/dbt/test_graph.py @@ -1,7 +1,7 @@ import shutil import tempfile from pathlib import Path -from unittest.mock import patch +from unittest.mock import patch, MagicMock import yaml import pytest @@ -44,6 +44,18 @@ def tmp_dbt_project_dir(): shutil.rmtree(tmp_dir, ignore_errors=True) # delete directory +@pytest.fixture +def postgres_profile_config() -> ProfileConfig: + return ProfileConfig( + profile_name="default", + target_name="default", + profile_mapping=PostgresUserPasswordProfileMapping( + conn_id="airflow_db", + profile_args={"schema": "public"}, + ), + ) + + @pytest.mark.parametrize( "unique_id,expected_name, expected_select", [ @@ -220,7 +232,9 @@ def test_load( @pytest.mark.integration @patch("cosmos.dbt.graph.Popen") -def test_load_via_dbt_ls_does_not_create_target_logs_in_original_folder(mock_popen, tmp_dbt_project_dir): +def test_load_via_dbt_ls_does_not_create_target_logs_in_original_folder( + mock_popen, tmp_dbt_project_dir, postgres_profile_config +): mock_popen().communicate.return_value = ("", "") mock_popen().returncode = 0 assert not (tmp_dbt_project_dir / "target").exists() @@ -233,14 +247,7 @@ def test_load_via_dbt_ls_does_not_create_target_logs_in_original_folder(mock_pop project=project_config, render_config=render_config, execution_config=execution_config, - profile_config=ProfileConfig( - profile_name="default", - target_name="default", - profile_mapping=PostgresUserPasswordProfileMapping( - conn_id="airflow_db", - profile_args={"schema": "public"}, - ), - ), + profile_config=postgres_profile_config, ) dbt_graph.load_via_dbt_ls() assert not (tmp_dbt_project_dir / "target").exists() @@ -252,7 +259,7 @@ def test_load_via_dbt_ls_does_not_create_target_logs_in_original_folder(mock_pop @pytest.mark.integration -def test_load_via_dbt_ls_with_exclude(): +def test_load_via_dbt_ls_with_exclude(postgres_profile_config): project_config = ProjectConfig(dbt_project_path=DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME) render_config = RenderConfig( dbt_project_path=DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME, select=["*customers*"], exclude=["*orders*"] @@ -262,14 +269,7 @@ def test_load_via_dbt_ls_with_exclude(): project=project_config, render_config=render_config, execution_config=execution_config, - profile_config=ProfileConfig( - profile_name="default", - target_name="default", - profile_mapping=PostgresUserPasswordProfileMapping( - conn_id="airflow_db", - profile_args={"schema": "public"}, - ), - ), + profile_config=postgres_profile_config, ) dbt_graph.load_via_dbt_ls() @@ -301,7 +301,7 @@ def test_load_via_dbt_ls_with_exclude(): @pytest.mark.integration @pytest.mark.parametrize("project_name", ("jaffle_shop", "jaffle_shop_python")) -def test_load_via_dbt_ls_without_exclude(project_name): +def test_load_via_dbt_ls_without_exclude(project_name, postgres_profile_config): project_config = ProjectConfig(dbt_project_path=DBT_PROJECTS_ROOT_DIR / project_name) render_config = RenderConfig(dbt_project_path=DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME) execution_config = ExecutionConfig(dbt_project_path=DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME) @@ -309,14 +309,7 @@ def test_load_via_dbt_ls_without_exclude(project_name): project=project_config, render_config=render_config, execution_config=execution_config, - profile_config=ProfileConfig( - profile_name="default", - target_name="default", - profile_mapping=PostgresUserPasswordProfileMapping( - conn_id="airflow_db", - profile_args={"schema": "public"}, - ), - ), + profile_config=postgres_profile_config, ) dbt_graph.load_via_dbt_ls() @@ -413,7 +406,7 @@ def test_load_via_dbt_ls_with_sources(load_method): @pytest.mark.integration -def test_load_via_dbt_ls_without_dbt_deps(): +def test_load_via_dbt_ls_without_dbt_deps(postgres_profile_config): project_config = ProjectConfig(dbt_project_path=DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME) render_config = RenderConfig(dbt_project_path=DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME, dbt_deps=False) execution_config = ExecutionConfig(dbt_project_path=DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME) @@ -421,14 +414,7 @@ def test_load_via_dbt_ls_without_dbt_deps(): project=project_config, render_config=render_config, execution_config=execution_config, - profile_config=ProfileConfig( - profile_name="default", - target_name="default", - profile_mapping=PostgresUserPasswordProfileMapping( - conn_id="airflow_db", - profile_args={"schema": "public"}, - ), - ), + profile_config=postgres_profile_config, ) with pytest.raises(CosmosLoadDbtException) as err_info: @@ -439,7 +425,7 @@ def test_load_via_dbt_ls_without_dbt_deps(): @pytest.mark.integration -def test_load_via_dbt_ls_without_dbt_deps_and_preinstalled_dbt_packages(tmp_dbt_project_dir): +def test_load_via_dbt_ls_without_dbt_deps_and_preinstalled_dbt_packages(tmp_dbt_project_dir, postgres_profile_config): local_flags = [ "--project-dir", tmp_dbt_project_dir / DBT_PROJECT_NAME, @@ -469,14 +455,7 @@ def test_load_via_dbt_ls_without_dbt_deps_and_preinstalled_dbt_packages(tmp_dbt_ project=project_config, render_config=render_config, execution_config=execution_config, - profile_config=ProfileConfig( - profile_name="default", - target_name="default", - profile_mapping=PostgresUserPasswordProfileMapping( - conn_id="airflow_db", - profile_args={"schema": "public"}, - ), - ), + profile_config=postgres_profile_config, ) dbt_graph.load_via_dbt_ls() # does not raise exception @@ -484,7 +463,9 @@ def test_load_via_dbt_ls_without_dbt_deps_and_preinstalled_dbt_packages(tmp_dbt_ @pytest.mark.integration @patch("cosmos.dbt.graph.Popen") -def test_load_via_dbt_ls_with_zero_returncode_and_non_empty_stderr(mock_popen, tmp_dbt_project_dir): +def test_load_via_dbt_ls_with_zero_returncode_and_non_empty_stderr( + mock_popen, tmp_dbt_project_dir, postgres_profile_config +): mock_popen().communicate.return_value = ("", "Some stderr warnings") mock_popen().returncode = 0 @@ -495,14 +476,7 @@ def test_load_via_dbt_ls_with_zero_returncode_and_non_empty_stderr(mock_popen, t project=project_config, render_config=render_config, execution_config=execution_config, - profile_config=ProfileConfig( - profile_name="default", - target_name="default", - profile_mapping=PostgresUserPasswordProfileMapping( - conn_id="airflow_db", - profile_args={"schema": "public"}, - ), - ), + profile_config=postgres_profile_config, ) dbt_graph.load_via_dbt_ls() # does not raise exception @@ -510,7 +484,7 @@ def test_load_via_dbt_ls_with_zero_returncode_and_non_empty_stderr(mock_popen, t @pytest.mark.integration @patch("cosmos.dbt.graph.Popen") -def test_load_via_dbt_ls_with_non_zero_returncode(mock_popen): +def test_load_via_dbt_ls_with_non_zero_returncode(mock_popen, postgres_profile_config): mock_popen().communicate.return_value = ("", "Some stderr message") mock_popen().returncode = 1 @@ -521,14 +495,7 @@ def test_load_via_dbt_ls_with_non_zero_returncode(mock_popen): project=project_config, render_config=render_config, execution_config=execution_config, - profile_config=ProfileConfig( - profile_name="default", - target_name="default", - profile_mapping=PostgresUserPasswordProfileMapping( - conn_id="airflow_db", - profile_args={"schema": "public"}, - ), - ), + profile_config=postgres_profile_config, ) expected = r"Unable to run \['.+dbt', 'deps', .*\] due to the error:\nSome stderr message" with pytest.raises(CosmosLoadDbtException, match=expected): @@ -537,7 +504,7 @@ def test_load_via_dbt_ls_with_non_zero_returncode(mock_popen): @pytest.mark.integration @patch("cosmos.dbt.graph.Popen.communicate", return_value=("Some Runtime Error", "")) -def test_load_via_dbt_ls_with_runtime_error_in_stdout(mock_popen_communicate): +def test_load_via_dbt_ls_with_runtime_error_in_stdout(mock_popen_communicate, postgres_profile_config): # It may seem strange, but at least until dbt 1.6.0, there are circumstances when it outputs errors to stdout project_config = ProjectConfig(dbt_project_path=DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME) render_config = RenderConfig(dbt_project_path=DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME) @@ -546,14 +513,7 @@ def test_load_via_dbt_ls_with_runtime_error_in_stdout(mock_popen_communicate): project=project_config, render_config=render_config, execution_config=execution_config, - profile_config=ProfileConfig( - profile_name="default", - target_name="default", - profile_mapping=PostgresUserPasswordProfileMapping( - conn_id="airflow_db", - profile_args={"schema": "public"}, - ), - ), + profile_config=postgres_profile_config, ) expected = r"Unable to run \['.+dbt', 'deps', .*\] due to the error:\nSome Runtime Error" with pytest.raises(CosmosLoadDbtException, match=expected): @@ -673,7 +633,7 @@ def test_tag_selected_node_test_exist(): @pytest.mark.integration @pytest.mark.parametrize("load_method", ["load_via_dbt_ls", "load_from_dbt_manifest"]) -def test_load_dbt_ls_and_manifest_with_model_version(load_method): +def test_load_dbt_ls_and_manifest_with_model_version(load_method, postgres_profile_config): dbt_graph = DbtGraph( project=ProjectConfig( dbt_project_path=DBT_PROJECTS_ROOT_DIR / "model_version", @@ -681,14 +641,7 @@ def test_load_dbt_ls_and_manifest_with_model_version(load_method): ), render_config=RenderConfig(dbt_project_path=DBT_PROJECTS_ROOT_DIR / "model_version"), execution_config=ExecutionConfig(dbt_project_path=DBT_PROJECTS_ROOT_DIR / "model_version"), - profile_config=ProfileConfig( - profile_name="default", - target_name="default", - profile_mapping=PostgresUserPasswordProfileMapping( - conn_id="airflow_db", - profile_args={"schema": "public"}, - ), - ), + profile_config=postgres_profile_config, ) getattr(dbt_graph, load_method)() expected_dbt_nodes = { @@ -826,6 +779,55 @@ def test_load_via_dbt_ls_project_config_dbt_vars(mock_validate, mock_update_node assert ls_command[ls_command.index("--vars") + 1] == yaml.dump(dbt_vars) +@patch("cosmos.dbt.graph.Popen") +@patch("cosmos.dbt.graph.DbtGraph.update_node_dependency") +@patch("cosmos.config.RenderConfig.validate_dbt_command") +def test_load_via_dbt_ls_render_config_selector_arg_is_used( + mock_validate, mock_update_nodes, mock_popen, tmp_dbt_project_dir +): + """Tests that the dbt ls command in the subprocess has "--selector" with the RenderConfig.selector.""" + mock_popen().communicate.return_value = ("", "") + mock_popen().returncode = 0 + selector = "my_selector" + project_config = ProjectConfig() + render_config = RenderConfig( + dbt_project_path=tmp_dbt_project_dir / DBT_PROJECT_NAME, + load_method=LoadMode.DBT_LS, + selector=selector, + ) + profile_config = ProfileConfig( + profile_name="test", + target_name="test", + profiles_yml_filepath=DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME / "profiles.yml", + ) + execution_config = MagicMock() + dbt_graph = DbtGraph( + project=project_config, + render_config=render_config, + execution_config=execution_config, + profile_config=profile_config, + ) + dbt_graph.load_via_dbt_ls() + ls_command = mock_popen.call_args.args[0] + assert "--selector" in ls_command + assert ls_command[ls_command.index("--selector") + 1] == selector + + +@pytest.mark.parametrize("load_method", [LoadMode.DBT_MANIFEST, LoadMode.CUSTOM]) +def test_load_method_with_unsupported_render_config_selector_arg(load_method): + """Tests that error is raised when RenderConfig.selector is used with LoadMode.DBT_MANIFEST or LoadMode.CUSTOM.""" + + expected_error_msg = ( + f"RenderConfig.selector is not yet supported when loading dbt projects using the {load_method} parser." + ) + dbt_graph = DbtGraph( + render_config=RenderConfig(load_method=load_method, selector="my_selector"), + project=MagicMock(), + ) + with pytest.raises(CosmosLoadDbtException, match=expected_error_msg): + dbt_graph.load(method=load_method) + + @pytest.mark.sqlite @pytest.mark.integration def test_load_via_dbt_ls_with_project_config_vars(): @@ -853,3 +855,45 @@ def test_load_via_dbt_ls_with_project_config_vars(): ) dbt_graph.load_via_dbt_ls() assert dbt_graph.nodes["model.simple.top_animations"].config["alias"] == "top_5_animated_movies" + + +@pytest.mark.integration +def test_load_via_dbt_ls_with_selector_arg(tmp_dbt_project_dir, postgres_profile_config): + """ + Tests that the dbt ls load method is successful if a selector arg is used with RenderConfig + and that the filtered nodes are expected. + """ + # Add a selectors yaml file to the project that will select the stg_customers model and all + # parents (raw_customers) + selectors_yaml = """ + selectors: + - name: stage_customers + definition: + method: fqn + value: stg_customers + parents: true + """ + with open(tmp_dbt_project_dir / DBT_PROJECT_NAME / "selectors.yml", "w") as f: + f.write(selectors_yaml) + + project_config = ProjectConfig(dbt_project_path=tmp_dbt_project_dir / DBT_PROJECT_NAME) + execution_config = ExecutionConfig(dbt_project_path=tmp_dbt_project_dir / DBT_PROJECT_NAME) + render_config = RenderConfig( + dbt_project_path=tmp_dbt_project_dir / DBT_PROJECT_NAME, + selector="stage_customers", + ) + + dbt_graph = DbtGraph( + project=project_config, + render_config=render_config, + execution_config=execution_config, + profile_config=postgres_profile_config, + ) + dbt_graph.load_via_dbt_ls() + + filtered_nodes = dbt_graph.filtered_nodes.keys() + assert len(filtered_nodes) == 4 + assert "model.jaffle_shop.stg_customers" in filtered_nodes + assert "seed.jaffle_shop.raw_customers" in filtered_nodes + # Two tests should be filtered + assert sum(node.startswith("test.jaffle_shop") for node in filtered_nodes) == 2 From 48dce2b64aa4abc21030e8d2e77cb0c7bd394c5f Mon Sep 17 00:00:00 2001 From: Joppe Vos <44348300+joppevos@users.noreply.github.com> Date: Wed, 13 Dec 2023 18:54:31 +0100 Subject: [PATCH 054/223] Extend DbtDocsLocalOperator with static flag (#759) ## Description Extends the docsOperator to make use of dbt `--static` flag. The static flag is available from dbt 1.7> I decided to add the flag through `dbt_cmd_flags`. Other options could be a parameter or the `operator_args`. There is no official documentation page from DBT on the flag [yet](https://github.com/dbt-labs/docs.getdbt.com/issues/4599). When they do add it, I can trim down our documentation and link directly to dbt. I contribute to learn and am appreciative of any feedback. ## Related Issue(s) closes #746 ## Breaking Change? ## Checklist - [x] I have made corresponding changes to the documentation (if required) - [x] I have added tests that prove my fix is effective or that my feature works --------- Co-authored-by: Tatiana Al-Chueyr Co-authored-by: Justin Bandoro <79104794+jbandoro@users.noreply.github.com> --- cosmos/operators/local.py | 11 +++++++++- docs/configuration/generating-docs.rst | 28 ++++++++++++++++++++++++++ tests/operators/test_local.py | 11 ++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/cosmos/operators/local.py b/cosmos/operators/local.py index 3b1751cd98..ea7cb41ae6 100644 --- a/cosmos/operators/local.py +++ b/cosmos/operators/local.py @@ -548,6 +548,15 @@ def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) self.base_cmd = ["docs", "generate"] + self.check_static_flag() + + def check_static_flag(self) -> None: + flag = "--static" + if self.dbt_cmd_flags: + if flag in self.dbt_cmd_flags: + # For the --static flag we only upload the generated static_index.html file + self.required_files = ["static_index.html"] + class DbtDocsCloudLocalOperator(DbtDocsLocalOperator, ABC): """ @@ -578,7 +587,7 @@ def upload_to_cloud_storage(self, project_dir: str) -> None: class DbtDocsS3LocalOperator(DbtDocsCloudLocalOperator): """ - Executes `dbt docs generate` command and upload to S3 storage. Returns the S3 path to the generated documentation. + Executes `dbt docs generate` command and upload to S3 storage. :param connection_id: S3's Airflow connection ID :param bucket_name: S3's bucket name diff --git a/docs/configuration/generating-docs.rst b/docs/configuration/generating-docs.rst index 88459fd14e..6112ebcee6 100644 --- a/docs/configuration/generating-docs.rst +++ b/docs/configuration/generating-docs.rst @@ -83,6 +83,34 @@ You can use the :class:`~cosmos.operators.DbtDocsGCSOperator` to generate and up bucket_name="test_bucket", ) +Static Flag +~~~~~~~~~~~~~~~~~~~~~~~ + +All of the DbtDocsOperator accept the ``--static`` flag. To learn more about the static flag, check out the `original PR on dbt-core `_. +The static flag is used to generate a single doc file that can be hosted directly from cloud storage. +By having a single documentation file, you can make use of Access control can be configured through Identity-Aware Proxy (IAP), and making it easy to host. + +.. note:: + The static flag is only available from dbt-core >=1.7 + +The following code snippet shows how to provide this flag with the default jaffle_shop project: + + +.. code-block:: python + + from cosmos.operators import DbtDocsGCSOperator + + # then, in your DAG code: + generate_dbt_docs_aws = DbtDocsGCSOperator( + task_id="generate_dbt_docs_gcs", + project_dir="path/to/jaffle_shop", + profile_config=profile_config, + # docs-specific arguments + connection_id="test_gcs", + bucket_name="test_bucket", + dbt_cmd_flags=["--static"], + ) + Custom Callback ~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/tests/operators/test_local.py b/tests/operators/test_local.py index b0a36b3352..dd7d34a6d8 100644 --- a/tests/operators/test_local.py +++ b/tests/operators/test_local.py @@ -488,3 +488,14 @@ def test_operator_execute_deps_parameters( mock_ensure_profile.return_value.__enter__.return_value = (Path("/path/to/profile"), {"ENV_VAR": "value"}) task.execute(context={"task_instance": MagicMock()}) assert mock_build_and_run_cmd.call_args_list[0].kwargs["command"] == expected_call_kwargs + + +def test_dbt_docs_local_operator_with_static_flag(): + # Check when static flag is passed, the required files are correctly adjusted to a single file + operator = DbtDocsLocalOperator( + task_id="fake-task", + project_dir="fake-dir", + profile_config=profile_config, + dbt_cmd_flags=["--static"], + ) + assert operator.required_files == ["static_index.html"] From 6b3c1f6172a926de600935652724b770519e81f4 Mon Sep 17 00:00:00 2001 From: Spencer <53303191+octiva@users.noreply.github.com> Date: Thu, 14 Dec 2023 19:42:02 +1000 Subject: [PATCH 055/223] Athena - Get temporary credentials from the conn_id (#758) ## Description Passes the `conn_id` to the `AwsGenericHook` and uses `get_credentials()`, which handles the creation of a session, credentials, freezing of credentials & also masking. [See get_credentials() docs here](https://airflow.apache.org/docs/apache-airflow-providers-amazon/stable/_api/airflow/providers/amazon/aws/hooks/base_aws/index.html#airflow.providers.amazon.aws.hooks.base_aws.AwsGenericHook.get_credentials) ## Related Issue(s) Closes: #691 Co-authored-by: Spencer horton --- cosmos/profiles/athena/access_key.py | 55 ++++++++++++-- pyproject.toml | 3 +- .../profiles/athena/test_athena_access_key.py | 71 +++++++++++++++---- 3 files changed, 105 insertions(+), 24 deletions(-) diff --git a/cosmos/profiles/athena/access_key.py b/cosmos/profiles/athena/access_key.py index a8f71c2b7a..02de2be247 100644 --- a/cosmos/profiles/athena/access_key.py +++ b/cosmos/profiles/athena/access_key.py @@ -3,20 +3,33 @@ from typing import Any +from cosmos.exceptions import CosmosValueError + from ..base import BaseProfileMapping class AthenaAccessKeyProfileMapping(BaseProfileMapping): """ - Maps Airflow AWS connections to a dbt Athena profile using an access key id and secret access key. + Uses the Airflow AWS Connection provided to get_credentials() to generate the profile for dbt. - https://docs.getdbt.com/docs/core/connect-data-platform/athena-setup https://airflow.apache.org/docs/apache-airflow-providers-amazon/stable/connections/aws.html + + + This behaves similarly to other provider operators such as the AWS Athena Operator. + Where you pass the aws_conn_id and the operator will generate the credentials for you. + + https://registry.astronomer.io/providers/amazon/versions/latest/modules/athenaoperator + + Information about the dbt Athena profile that is generated can be found here: + + https://github.com/dbt-athena/dbt-athena?tab=readme-ov-file#configuring-your-profile + https://docs.getdbt.com/docs/core/connect-data-platform/athena-setup """ airflow_connection_type: str = "aws" dbt_profile_type: str = "athena" is_community: bool = True + temporary_credentials = None required_fields = [ "aws_access_key_id", @@ -26,11 +39,7 @@ class AthenaAccessKeyProfileMapping(BaseProfileMapping): "s3_staging_dir", "schema", ] - secret_fields = ["aws_secret_access_key", "aws_session_token"] airflow_param_mapping = { - "aws_access_key_id": "login", - "aws_secret_access_key": "password", - "aws_session_token": "extra.aws_session_token", "aws_profile_name": "extra.aws_profile_name", "database": "extra.database", "debug_query_state": "extra.debug_query_state", @@ -49,11 +58,43 @@ class AthenaAccessKeyProfileMapping(BaseProfileMapping): @property def profile(self) -> dict[str, Any | None]: "Gets profile. The password is stored in an environment variable." + + self.temporary_credentials = self._get_temporary_credentials() # type: ignore + profile = { **self.mapped_params, **self.profile_args, - # aws_secret_access_key and aws_session_token should always get set as env var + "aws_access_key_id": self.temporary_credentials.access_key, "aws_secret_access_key": self.get_env_var_format("aws_secret_access_key"), "aws_session_token": self.get_env_var_format("aws_session_token"), } + return self.filter_null(profile) + + @property + def env_vars(self) -> dict[str, str]: + "Overwrites the env_vars for athena, Returns a dictionary of environment variables that should be set based on the self.temporary_credentials." + + if self.temporary_credentials is None: + raise CosmosValueError(f"Could not find the athena credentials.") + + env_vars = {} + + env_secret_key_name = self.get_env_var_name("aws_secret_access_key") + env_session_token_name = self.get_env_var_name("aws_session_token") + + env_vars[env_secret_key_name] = str(self.temporary_credentials.secret_key) + env_vars[env_session_token_name] = str(self.temporary_credentials.token) + + return env_vars + + def _get_temporary_credentials(self): # type: ignore + """ + Helper function to retrieve temporary short lived credentials + Returns an object including access_key, secret_key and token + """ + from airflow.providers.amazon.aws.hooks.base_aws import AwsGenericHook + + hook = AwsGenericHook(self.conn_id) # type: ignore + credentials = hook.get_credentials() + return credentials diff --git a/pyproject.toml b/pyproject.toml index c08de4adea..9d367c075f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,7 @@ dbt-all = [ ] dbt-athena = [ "dbt-athena-community", + "apache-airflow-providers-amazon>=8.0.0", ] dbt-bigquery = [ "dbt-bigquery", @@ -110,7 +111,6 @@ tests = [ "mypy", "sqlalchemy-stubs", # Change when sqlalchemy is upgraded https://docs.sqlalchemy.org/en/14/orm/extensions/mypy.html ] - docker = [ "apache-airflow-providers-docker>=3.5.0", ] @@ -121,7 +121,6 @@ pydantic = [ "pydantic>=1.10.0,<2.0.0", ] - [project.entry-points.cosmos] provider_info = "cosmos:get_provider_info" diff --git a/tests/profiles/athena/test_athena_access_key.py b/tests/profiles/athena/test_athena_access_key.py index 22c8efa2c0..c224a9d4b8 100644 --- a/tests/profiles/athena/test_athena_access_key.py +++ b/tests/profiles/athena/test_athena_access_key.py @@ -1,20 +1,49 @@ "Tests for the Athena profile." import json -from unittest.mock import patch - +from collections import namedtuple +import sys +from unittest.mock import MagicMock, patch import pytest from airflow.models.connection import Connection from cosmos.profiles import get_automatic_profile_mapping from cosmos.profiles.athena.access_key import AthenaAccessKeyProfileMapping +Credentials = namedtuple("Credentials", ["access_key", "secret_key", "token"]) + +mock_assumed_credentials = Credentials( + secret_key="my_aws_assumed_secret_key", + access_key="my_aws_assumed_access_key", + token="my_aws_assumed_token", +) + +mock_missing_credentials = Credentials(access_key=None, secret_key=None, token=None) + + +@pytest.fixture(autouse=True) +def mock_aws_module(): + mock_aws_hook = MagicMock() + + class MockAwsGenericHook: + def __init__(self, conn_id: str) -> None: + pass + + def get_credentials(self) -> Credentials: + return mock_assumed_credentials + + mock_aws_hook.AwsGenericHook = MockAwsGenericHook + + with patch.dict(sys.modules, {"airflow.providers.amazon.aws.hooks.base_aws": mock_aws_hook}): + yield mock_aws_hook + @pytest.fixture() def mock_athena_conn(): # type: ignore """ Sets the connection as an environment variable. """ + conn = Connection( conn_id="my_athena_connection", conn_type="aws", @@ -24,7 +53,7 @@ def mock_athena_conn(): # type: ignore { "aws_session_token": "token123", "database": "my_database", - "region_name": "my_region", + "region_name": "us-east-1", "s3_staging_dir": "s3://my_bucket/dbt/", "schema": "my_schema", } @@ -48,6 +77,7 @@ def test_athena_connection_claiming() -> None: # - region_name # - s3_staging_dir # - schema + potential_values = { "conn_type": "aws", "login": "my_aws_access_key_id", @@ -55,7 +85,7 @@ def test_athena_connection_claiming() -> None: "extra": json.dumps( { "database": "my_database", - "region_name": "my_region", + "region_name": "us-east-1", "s3_staging_dir": "s3://my_bucket/dbt/", "schema": "my_schema", } @@ -68,12 +98,14 @@ def test_athena_connection_claiming() -> None: del values[key] conn = Connection(**values) # type: ignore - print("testing with", values) - - with patch("airflow.hooks.base.BaseHook.get_connection", return_value=conn): - # should raise an InvalidMappingException - profile_mapping = AthenaAccessKeyProfileMapping(conn, {}) - assert not profile_mapping.can_claim_connection() + with patch( + "cosmos.profiles.athena.access_key.AthenaAccessKeyProfileMapping._get_temporary_credentials", + return_value=mock_missing_credentials, + ): + with patch("airflow.hooks.base.BaseHook.get_connection", return_value=conn): + # should raise an InvalidMappingException + profile_mapping = AthenaAccessKeyProfileMapping(conn, {}) + assert not profile_mapping.can_claim_connection() # if we have them all, it should claim conn = Connection(**potential_values) # type: ignore @@ -88,6 +120,7 @@ def test_athena_profile_mapping_selected( """ Tests that the correct profile mapping is selected for Athena. """ + profile_mapping = get_automatic_profile_mapping( mock_athena_conn.conn_id, ) @@ -100,13 +133,14 @@ def test_athena_profile_args( """ Tests that the profile values get set correctly for Athena. """ + profile_mapping = get_automatic_profile_mapping( mock_athena_conn.conn_id, ) assert profile_mapping.profile == { "type": "athena", - "aws_access_key_id": mock_athena_conn.login, + "aws_access_key_id": mock_assumed_credentials.access_key, "aws_secret_access_key": "{{ env_var('COSMOS_CONN_AWS_AWS_SECRET_ACCESS_KEY') }}", "aws_session_token": "{{ env_var('COSMOS_CONN_AWS_AWS_SESSION_TOKEN') }}", "database": mock_athena_conn.extra_dejson.get("database"), @@ -122,9 +156,14 @@ def test_athena_profile_args_overrides( """ Tests that you can override the profile values for Athena. """ + profile_mapping = get_automatic_profile_mapping( mock_athena_conn.conn_id, - profile_args={"schema": "my_custom_schema", "database": "my_custom_db", "aws_session_token": "override_token"}, + profile_args={ + "schema": "my_custom_schema", + "database": "my_custom_db", + "aws_session_token": "override_token", + }, ) assert profile_mapping.profile_args == { @@ -135,7 +174,7 @@ def test_athena_profile_args_overrides( assert profile_mapping.profile == { "type": "athena", - "aws_access_key_id": mock_athena_conn.login, + "aws_access_key_id": mock_assumed_credentials.access_key, "aws_secret_access_key": "{{ env_var('COSMOS_CONN_AWS_AWS_SECRET_ACCESS_KEY') }}", "aws_session_token": "{{ env_var('COSMOS_CONN_AWS_AWS_SESSION_TOKEN') }}", "database": "my_custom_db", @@ -151,10 +190,12 @@ def test_athena_profile_env_vars( """ Tests that the environment variables get set correctly for Athena. """ + profile_mapping = get_automatic_profile_mapping( mock_athena_conn.conn_id, ) + assert profile_mapping.env_vars == { - "COSMOS_CONN_AWS_AWS_SECRET_ACCESS_KEY": mock_athena_conn.password, - "COSMOS_CONN_AWS_AWS_SESSION_TOKEN": mock_athena_conn.extra_dejson.get("aws_session_token"), + "COSMOS_CONN_AWS_AWS_SECRET_ACCESS_KEY": mock_assumed_credentials.secret_key, + "COSMOS_CONN_AWS_AWS_SESSION_TOKEN": mock_assumed_credentials.token, } From daeb727c5e9b65e5051144a25c041894220c22bf Mon Sep 17 00:00:00 2001 From: Koki <146737781+woogakoki@users.noreply.github.com> Date: Thu, 14 Dec 2023 12:28:05 +0100 Subject: [PATCH 056/223] Add new parsing method `LoadMode.DBT_LS_FILE` (#733) Adds a new load method, which is in between manifest and dbt_ls. This aims to have a dbt ls file with the output prepared and only use the parsing logic of the dbt_ls load method. This should increase performance compared to using dbt_ls. However, this method needs one output file for each taskgroup build. --- cosmos/config.py | 11 ++- cosmos/constants.py | 1 + cosmos/dbt/graph.py | 25 ++++++ .../dbt/jaffle_shop/dbt_ls_models_staging.txt | 9 ++ dev/dags/user_defined_profile.py | 7 +- docs/configuration/parsing-methods.rst | 22 +++++ tests/dbt/test_graph.py | 88 ++++++++++++++++++- tests/sample/sample_dbt_ls.txt | 6 ++ tests/test_config.py | 10 +++ 9 files changed, 176 insertions(+), 3 deletions(-) create mode 100644 dev/dags/dbt/jaffle_shop/dbt_ls_models_staging.txt create mode 100644 tests/sample/sample_dbt_ls.txt diff --git a/cosmos/config.py b/cosmos/config.py index 3b332931fb..46e3f19152 100644 --- a/cosmos/config.py +++ b/cosmos/config.py @@ -59,6 +59,7 @@ class RenderConfig: dbt_executable_path: str | Path = get_system_dbt() env_vars: dict[str, str] | None = None dbt_project_path: InitVar[str | Path | None] = None + dbt_ls_path: Path | None = None project_path: Path | None = field(init=False) @@ -91,6 +92,15 @@ def validate_dbt_command(self, fallback_cmd: str | Path = "") -> None: f"<{self.dbt_executable_path}>" + (f" and <{fallback_cmd}>." if fallback_cmd else ".") ) + def is_dbt_ls_file_available(self) -> bool: + """ + Check if the `dbt ls` output is set and if the file exists. + """ + if not self.dbt_ls_path: + return False + + return self.dbt_ls_path.exists() + class ProjectConfig: """ @@ -287,7 +297,6 @@ class ExecutionConfig: dbt_executable_path: str | Path = field(default_factory=get_system_dbt) dbt_project_path: InitVar[str | Path | None] = None - project_path: Path | None = field(init=False) def __post_init__(self, dbt_project_path: str | Path | None) -> None: diff --git a/cosmos/constants.py b/cosmos/constants.py index 9aa38c34e6..96c5bdd070 100644 --- a/cosmos/constants.py +++ b/cosmos/constants.py @@ -27,6 +27,7 @@ class LoadMode(Enum): AUTOMATIC = "automatic" CUSTOM = "custom" DBT_LS = "dbt_ls" + DBT_LS_FILE = "dbt_ls_file" DBT_MANIFEST = "dbt_manifest" diff --git a/cosmos/dbt/graph.py b/cosmos/dbt/graph.py index d4fa0db494..db8d6d2437 100644 --- a/cosmos/dbt/graph.py +++ b/cosmos/dbt/graph.py @@ -193,6 +193,7 @@ def load( load_method = { LoadMode.CUSTOM: self.load_via_custom_parser, LoadMode.DBT_LS: self.load_via_dbt_ls, + LoadMode.DBT_LS_FILE: self.load_via_dbt_ls_file, LoadMode.DBT_MANIFEST: self.load_from_dbt_manifest, } @@ -317,6 +318,30 @@ def load_via_dbt_ls(self) -> None: logger.info("Total nodes: %i", len(self.nodes)) logger.info("Total filtered nodes: %i", len(self.nodes)) + def load_via_dbt_ls_file(self) -> None: + """ + This is between dbt ls and full manifest. It allows to use the output (needs to be json output) of the dbt ls as a + file stored in the image you run Cosmos on. The advantage is that you can use the parser from LoadMode.DBT_LS without + actually running dbt ls every time. BUT you will need one dbt ls file for each separate group. + + This technically should increase performance and also removes the necessity to have your whole dbt project copied + to the airflow image. + """ + logger.info("Trying to parse the dbt project `%s` using a dbt ls output file...", self.project.project_name) + + if not self.render_config.is_dbt_ls_file_available(): + raise CosmosLoadDbtException(f"Unable to load dbt ls file using {self.render_config.dbt_ls_path}") + + project_path = self.render_config.project_path + if not project_path: + raise CosmosLoadDbtException("Unable to load dbt ls file without RenderConfig.project_path") + with open(self.render_config.dbt_ls_path) as fp: # type: ignore[arg-type] + dbt_ls_output = fp.read() + nodes = parse_dbt_ls_output(project_path=project_path, ls_stdout=dbt_ls_output) + + self.nodes = nodes + self.filtered_nodes = nodes + def load_via_custom_parser(self) -> None: """ This is the least accurate way of loading `dbt` projects and filtering them out, since it uses custom Cosmos diff --git a/dev/dags/dbt/jaffle_shop/dbt_ls_models_staging.txt b/dev/dags/dbt/jaffle_shop/dbt_ls_models_staging.txt new file mode 100644 index 0000000000..b8cc902ec0 --- /dev/null +++ b/dev/dags/dbt/jaffle_shop/dbt_ls_models_staging.txt @@ -0,0 +1,9 @@ +14:26:04 Running with dbt=1.6.9 +14:26:04 Registered adapter: exasol=1.6.2 +14:26:04 Found 5 models, 3 seeds, 20 tests, 0 sources, 0 exposures, 0 metrics, 366 macros, 0 groups, 0 semantic models +{"name": "stg_customers", "resource_type": "model", "package_name": "jaffle_shop", "original_file_path": "models/staging/stg_customers.sql", "unique_id": "model.jaffle_shop.stg_customers", "alias": "stg_customers", "config": {"enabled": true, "alias": null, "schema": null, "database": null, "tags": [], "meta": {}, "group": null, "materialized": "view", "incremental_strategy": null, "persist_docs": {}, "quoting": {}, "column_types": {}, "full_refresh": null, "unique_key": null, "on_schema_change": "ignore", "on_configuration_change": "apply", "grants": {}, "packages": [], "docs": {"show": true, "node_color": null}, "contract": {"enforced": false}, "post-hook": [], "pre-hook": []}, "tags": [], "depends_on": {"macros": [], "nodes": ["seed.jaffle_shop.raw_customers"]}} +{"name": "stg_orders", "resource_type": "model", "package_name": "jaffle_shop", "original_file_path": "models/staging/stg_orders.sql", "unique_id": "model.jaffle_shop.stg_orders", "alias": "stg_orders", "config": {"enabled": true, "alias": null, "schema": null, "database": null, "tags": [], "meta": {}, "group": null, "materialized": "view", "incremental_strategy": null, "persist_docs": {}, "quoting": {}, "column_types": {}, "full_refresh": null, "unique_key": null, "on_schema_change": "ignore", "on_configuration_change": "apply", "grants": {}, "packages": [], "docs": {"show": true, "node_color": null}, "contract": {"enforced": false}, "post-hook": [], "pre-hook": []}, "tags": [], "depends_on": {"macros": [], "nodes": ["seed.jaffle_shop.raw_orders"]}} +{"name": "stg_payments", "resource_type": "model", "package_name": "jaffle_shop", "original_file_path": "models/staging/stg_payments.sql", "unique_id": "model.jaffle_shop.stg_payments", "alias": "stg_payments", "config": {"enabled": true, "alias": null, "schema": null, "database": null, "tags": [], "meta": {}, "group": null, "materialized": "view", "incremental_strategy": null, "persist_docs": {}, "quoting": {}, "column_types": {}, "full_refresh": null, "unique_key": null, "on_schema_change": "ignore", "on_configuration_change": "apply", "grants": {}, "packages": [], "docs": {"show": true, "node_color": null}, "contract": {"enforced": false}, "post-hook": [], "pre-hook": []}, "tags": [], "depends_on": {"macros": [], "nodes": ["seed.jaffle_shop.raw_payments"]}} +{"name": "raw_customers", "resource_type": "seed", "package_name": "jaffle_shop", "original_file_path": "seeds/raw_customers.csv", "unique_id": "seed.jaffle_shop.raw_customers", "alias": "raw_customers", "config": {"enabled": true, "alias": null, "schema": null, "database": null, "tags": [], "meta": {}, "group": null, "materialized": "seed", "incremental_strategy": null, "persist_docs": {}, "quoting": {}, "column_types": {}, "full_refresh": null, "unique_key": null, "on_schema_change": "ignore", "on_configuration_change": "apply", "grants": {}, "packages": [], "docs": {"show": true, "node_color": null}, "contract": {"enforced": false}, "quote_columns": null, "post-hook": [], "pre-hook": []}, "tags": [], "depends_on": {"macros": []}} +{"name": "raw_orders", "resource_type": "seed", "package_name": "jaffle_shop", "original_file_path": "seeds/raw_orders.csv", "unique_id": "seed.jaffle_shop.raw_orders", "alias": "raw_orders", "config": {"enabled": true, "alias": null, "schema": null, "database": null, "tags": [], "meta": {}, "group": null, "materialized": "seed", "incremental_strategy": null, "persist_docs": {}, "quoting": {}, "column_types": {}, "full_refresh": null, "unique_key": null, "on_schema_change": "ignore", "on_configuration_change": "apply", "grants": {}, "packages": [], "docs": {"show": true, "node_color": null}, "contract": {"enforced": false}, "quote_columns": null, "post-hook": [], "pre-hook": []}, "tags": [], "depends_on": {"macros": []}} +{"name": "raw_payments", "resource_type": "seed", "package_name": "jaffle_shop", "original_file_path": "seeds/raw_payments.csv", "unique_id": "seed.jaffle_shop.raw_payments", "alias": "raw_payments", "config": {"enabled": true, "alias": null, "schema": null, "database": null, "tags": [], "meta": {}, "group": null, "materialized": "seed", "incremental_strategy": null, "persist_docs": {}, "quoting": {}, "column_types": {}, "full_refresh": null, "unique_key": null, "on_schema_change": "ignore", "on_configuration_change": "apply", "grants": {}, "packages": [], "docs": {"show": true, "node_color": null}, "contract": {"enforced": false}, "quote_columns": null, "post-hook": [], "pre-hook": []}, "tags": [], "depends_on": {"macros": []}} diff --git a/dev/dags/user_defined_profile.py b/dev/dags/user_defined_profile.py index ab30cdb2fe..032915d0ab 100644 --- a/dev/dags/user_defined_profile.py +++ b/dev/dags/user_defined_profile.py @@ -8,11 +8,12 @@ from airflow.decorators import dag from airflow.operators.empty import EmptyOperator -from cosmos import DbtTaskGroup, ProjectConfig, ProfileConfig +from cosmos import DbtTaskGroup, ProjectConfig, ProfileConfig, RenderConfig, LoadMode DEFAULT_DBT_ROOT_PATH = Path(__file__).parent / "dbt" DBT_ROOT_PATH = Path(os.getenv("DBT_ROOT_PATH", DEFAULT_DBT_ROOT_PATH)) PROFILES_FILE_PATH = Path(DBT_ROOT_PATH, "jaffle_shop", "profiles.yml") +DBT_LS_PATH = Path(DBT_ROOT_PATH, "jaffle_shop", "dbt_ls_models_staging.txt") @dag( @@ -35,6 +36,10 @@ def user_defined_profile() -> None: target_name="dev", profiles_yml_filepath=PROFILES_FILE_PATH, ), + render_config=RenderConfig( + load_method=LoadMode.DBT_LS_FILE, + dbt_ls_path=DBT_LS_PATH, + ), operator_args={"append_env": True, "install_deps": True}, default_args={"retries": 2}, ) diff --git a/docs/configuration/parsing-methods.rst b/docs/configuration/parsing-methods.rst index fbf6e43bfd..ef50bdb4e6 100644 --- a/docs/configuration/parsing-methods.rst +++ b/docs/configuration/parsing-methods.rst @@ -8,12 +8,14 @@ Cosmos offers several options to parse your dbt project: - ``automatic``. Tries to find a user-supplied ``manifest.json`` file. If it can't find one, it will run ``dbt ls`` to generate one. If that fails, it will use Cosmos' dbt parser. - ``dbt_manifest``. Parses a user-supplied ``manifest.json`` file. This can be generated manually with dbt commands or via a CI/CD process. - ``dbt_ls``. Parses a dbt project directory using the ``dbt ls`` command. +- ``dbt_ls_file``. Parses a dbt project directory using the output of ``dbt ls`` command from a file. - ``custom``. Uses Cosmos' custom dbt parser, which extracts dependencies from your dbt's model code. There are benefits and drawbacks to each method: - ``dbt_manifest``: You have to generate the manifest file on your own. When using the manifest, Cosmos gets a complete set of metadata about your models. However, Cosmos uses its own selecting & excluding logic to determine which models to run, which may not be as robust as dbt's. - ``dbt_ls``: Cosmos will generate the manifest file for you. This method uses dbt's metadata AND dbt's selecting/excluding logic. This is the most robust method. However, this requires the dbt executable to be installed on your machine (either on the host directly or in a virtual environment). +- ``dbt_ls_file`` (new in 1.3): Path to a file containing the ``dbt ls`` output. To use this method, run ``dbt ls`` using ``--output json`` and store the output in a file. ``RenderConfig.select`` and ``RenderConfig.exclude`` will not work using this method. - ``custom``: Cosmos will parse your project and model files for you. This means that Cosmos will not have access to dbt's metadata. However, this method does not require the dbt executable to be installed on your machine. If you're using the ``local`` mode, you should use the ``dbt_ls`` method. @@ -75,6 +77,26 @@ To use this: # ..., ) +``dbt_ls_file`` +---------------- + +.. note:: + New in Cosmos 1.3. + +If you provide the output of ``dbt ls --output json`` as a file, you can use this to parse similar to ``dbt_ls``. +You can supply a ``dbt_ls_path`` parameter on the DbtDag / DbtTaskGroup with a path to a ``dbt_ls_output.txt`` file. +Check `this Dag `_ for an example. + +To use this: + +.. code-block:: python + + DbtDag( + render_config=RenderConfig( + load_method=LoadMode.DBT_MANIFEST, dbt_ls_path="/path/to/dbt_ls_file.txt" + ) + # ..., + ) ``custom`` ---------- diff --git a/tests/dbt/test_graph.py b/tests/dbt/test_graph.py index 2816fd07a5..7e941cb490 100644 --- a/tests/dbt/test_graph.py +++ b/tests/dbt/test_graph.py @@ -25,6 +25,7 @@ SAMPLE_MANIFEST_PY = Path(__file__).parent.parent / "sample/manifest_python.json" SAMPLE_MANIFEST_MODEL_VERSION = Path(__file__).parent.parent / "sample/manifest_model_version.json" SAMPLE_MANIFEST_SOURCE = Path(__file__).parent.parent / "sample/manifest_source.json" +SAMPLE_DBT_LS_OUTPUT = Path(__file__).parent.parent / "sample/sample_dbt_ls.txt" @pytest.fixture @@ -124,6 +125,52 @@ def test_load_automatic_manifest_is_available(mock_load_from_dbt_manifest): assert mock_load_from_dbt_manifest.called +@patch("cosmos.dbt.graph.DbtGraph.load_via_dbt_ls_file", return_value=None) +def test_load_automatic_dbt_ls_file_is_available(mock_load_via_dbt_ls_file): + project_config = ProjectConfig(dbt_project_path=DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME) + profile_config = ProfileConfig( + profile_name="test", + target_name="test", + profiles_yml_filepath=DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME / "profiles.yml", + ) + render_config = RenderConfig(dbt_ls_path=SAMPLE_DBT_LS_OUTPUT) + dbt_graph = DbtGraph(project=project_config, profile_config=profile_config, render_config=render_config) + dbt_graph.load(method=LoadMode.DBT_LS_FILE, execution_mode=ExecutionMode.LOCAL) + assert mock_load_via_dbt_ls_file.called + + +def test_load_dbt_ls_file_without_file(): + project_config = ProjectConfig(dbt_project_path=DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME) + profile_config = ProfileConfig( + profile_name="test", + target_name="test", + profiles_yml_filepath=DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME / "profiles.yml", + ) + render_config = RenderConfig(dbt_ls_path=None) + dbt_graph = DbtGraph(project=project_config, profile_config=profile_config, render_config=render_config) + with pytest.raises(CosmosLoadDbtException) as err_info: + dbt_graph.load(execution_mode=ExecutionMode.LOCAL, method=LoadMode.DBT_LS_FILE) + assert err_info.value.args[0] == "Unable to load dbt ls file using None" + + +def test_load_dbt_ls_file_without_project_path(): + project_config = ProjectConfig(dbt_project_path=DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME) + profile_config = ProfileConfig( + profile_name="test", + target_name="test", + profiles_yml_filepath=DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME / "profiles.yml", + ) + render_config = RenderConfig(dbt_ls_path=SAMPLE_DBT_LS_OUTPUT, dbt_project_path=None) + dbt_graph = DbtGraph( + project=project_config, + profile_config=profile_config, + render_config=render_config, + ) + with pytest.raises(CosmosLoadDbtException) as err_info: + dbt_graph.load(execution_mode=ExecutionMode.LOCAL, method=LoadMode.DBT_LS_FILE) + assert err_info.value.args[0] == "Unable to load dbt ls file without RenderConfig.project_path" + + @patch("cosmos.dbt.graph.DbtGraph.load_via_custom_parser", side_effect=None) @patch("cosmos.dbt.graph.DbtGraph.load_via_dbt_ls", return_value=None) def test_load_automatic_without_manifest_with_profile_yml(mock_load_via_dbt_ls, mock_load_via_custom_parser): @@ -214,8 +261,15 @@ def test_load_manifest_with_manifest(mock_load_from_dbt_manifest): @patch("cosmos.dbt.graph.DbtGraph.load_via_custom_parser", return_value=None) @patch("cosmos.dbt.graph.DbtGraph.load_via_dbt_ls", return_value=None) @patch("cosmos.dbt.graph.DbtGraph.load_from_dbt_manifest", return_value=None) +@patch("cosmos.dbt.graph.DbtGraph.load_via_dbt_ls_file", return_value=None) def test_load( - mock_load_from_dbt_manifest, mock_load_via_dbt_ls, mock_load_via_custom_parser, exec_mode, method, expected_function + mock_load_from_dbt_manifest, + mock_load_via_dbt_ls_file, + mock_load_via_dbt_ls, + mock_load_via_custom_parser, + exec_mode, + method, + expected_function, ): project_config = ProjectConfig(dbt_project_path=DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME) profile_config = ProfileConfig( @@ -678,6 +732,38 @@ def test_load_dbt_ls_and_manifest_with_model_version(load_method, postgres_profi } == set(dbt_graph.nodes["model.jaffle_shop.orders"].depends_on) +@pytest.mark.integration +def test_load_via_dbt_ls_file(): + project_config = ProjectConfig(dbt_project_path=DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME) + profile_config = ProfileConfig( + profile_name="test", + target_name="test", + profiles_yml_filepath=DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME / "profiles.yml", + ) + render_config = RenderConfig( + dbt_ls_path=SAMPLE_DBT_LS_OUTPUT, dbt_project_path=DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME + ) + dbt_graph = DbtGraph( + project=project_config, + profile_config=profile_config, + render_config=render_config, + ) + dbt_graph.load(method=LoadMode.DBT_LS_FILE, execution_mode=ExecutionMode.LOCAL) + + expected_dbt_nodes = { + "model.jaffle_shop.stg_customers": "stg_customers", + "model.jaffle_shop.stg_orders": "stg_orders", + "model.jaffle_shop.stg_payments": "stg_payments", + } + for unique_id, name in expected_dbt_nodes.items(): + assert unique_id in dbt_graph.nodes + assert name == dbt_graph.nodes[unique_id].name + # Test dependencies + assert {"seed.jaffle_shop.raw_customers"} == set(dbt_graph.nodes["model.jaffle_shop.stg_customers"].depends_on) + assert {"seed.jaffle_shop.raw_orders"} == set(dbt_graph.nodes["model.jaffle_shop.stg_orders"].depends_on) + assert {"seed.jaffle_shop.raw_payments"} == set(dbt_graph.nodes["model.jaffle_shop.stg_payments"].depends_on) + + @pytest.mark.parametrize( "stdout,returncode", [ diff --git a/tests/sample/sample_dbt_ls.txt b/tests/sample/sample_dbt_ls.txt new file mode 100644 index 0000000000..b356a5208c --- /dev/null +++ b/tests/sample/sample_dbt_ls.txt @@ -0,0 +1,6 @@ +14:26:04 Running with dbt=1.6.9 +14:26:04 Registered adapter: exasol=1.6.2 +14:26:04 Found 5 models, 3 seeds, 20 tests, 0 sources, 0 exposures, 0 metrics, 366 macros, 0 groups, 0 semantic models +{"name": "stg_customers", "resource_type": "model", "package_name": "jaffle_shop", "original_file_path": "models/staging/stg_customers.sql", "unique_id": "model.jaffle_shop.stg_customers", "alias": "stg_customers", "config": {"enabled": true, "alias": null, "schema": null, "database": null, "tags": [], "meta": {}, "group": null, "materialized": "view", "incremental_strategy": null, "persist_docs": {}, "quoting": {}, "column_types": {}, "full_refresh": null, "unique_key": null, "on_schema_change": "ignore", "on_configuration_change": "apply", "grants": {}, "packages": [], "docs": {"show": true, "node_color": null}, "contract": {"enforced": false}, "post-hook": [], "pre-hook": []}, "tags": [], "depends_on": {"macros": [], "nodes": ["seed.jaffle_shop.raw_customers"]}} +{"name": "stg_orders", "resource_type": "model", "package_name": "jaffle_shop", "original_file_path": "models/staging/stg_orders.sql", "unique_id": "model.jaffle_shop.stg_orders", "alias": "stg_orders", "config": {"enabled": true, "alias": null, "schema": null, "database": null, "tags": [], "meta": {}, "group": null, "materialized": "view", "incremental_strategy": null, "persist_docs": {}, "quoting": {}, "column_types": {}, "full_refresh": null, "unique_key": null, "on_schema_change": "ignore", "on_configuration_change": "apply", "grants": {}, "packages": [], "docs": {"show": true, "node_color": null}, "contract": {"enforced": false}, "post-hook": [], "pre-hook": []}, "tags": [], "depends_on": {"macros": [], "nodes": ["seed.jaffle_shop.raw_orders"]}} +{"name": "stg_payments", "resource_type": "model", "package_name": "jaffle_shop", "original_file_path": "models/staging/stg_payments.sql", "unique_id": "model.jaffle_shop.stg_payments", "alias": "stg_payments", "config": {"enabled": true, "alias": null, "schema": null, "database": null, "tags": [], "meta": {}, "group": null, "materialized": "view", "incremental_strategy": null, "persist_docs": {}, "quoting": {}, "column_types": {}, "full_refresh": null, "unique_key": null, "on_schema_change": "ignore", "on_configuration_change": "apply", "grants": {}, "packages": [], "docs": {"show": true, "node_color": null}, "contract": {"enforced": false}, "post-hook": [], "pre-hook": []}, "tags": [], "depends_on": {"macros": [], "nodes": ["seed.jaffle_shop.raw_payments"]}} diff --git a/tests/test_config.py b/tests/test_config.py index 734303a3e5..6fa53b10ca 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -176,6 +176,16 @@ def test_render_config_uses_default_if_exists(mock_which): assert render_config.dbt_executable_path == "user-dbt" +def test_is_dbt_ls_file_available_is_true(): + render_config = RenderConfig(dbt_ls_path=DBT_PROJECTS_ROOT_DIR / "sample_dbt_ls.txt") + assert render_config.is_dbt_ls_file_available() + + +def test_is_dbt_ls_file_available_is_false(): + render_config = RenderConfig(dbt_ls_path=None) + assert not render_config.is_dbt_ls_file_available() + + def test_render_config_env_vars_deprecated(): """RenderConfig.env_vars is deprecated since Cosmos 1.3, should warn user.""" with pytest.deprecated_call(): From d21baee405a97e4fbf5c237a9b5999a858289240 Mon Sep 17 00:00:00 2001 From: Justin Bandoro <79104794+jbandoro@users.noreply.github.com> Date: Thu, 14 Dec 2023 23:28:17 -0800 Subject: [PATCH 057/223] Support disabling event tracking when using Cosmos profile mapping (#768) Since dbt by default tracks events by sending anonymous statistic usage when dbt is invoked, users who currently use Cosmos profile mapping can only opt-out by setting environment variables as described in https://github.com/astronomer/astronomer-cosmos/issues/724#issuecomment-1839538680 This PR adds a new arg to the profile mapping so that the [dbt recommended config block](https://docs.getdbt.com/reference/global-configs/usage-stats) can be added in the generated `profiles.yml` file: ```yaml config: send_anonymous_usage_stats: False ``` Closes: #724 --- cosmos/profiles/base.py | 7 ++++++- docs/templates/index.rst.jinja2 | 31 +++++++++++++++++++++++++++++ tests/profiles/test_base_profile.py | 25 +++++++++++++++++++++-- 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/cosmos/profiles/base.py b/cosmos/profiles/base.py index b1cebb38bd..171eac2d9c 100644 --- a/cosmos/profiles/base.py +++ b/cosmos/profiles/base.py @@ -41,10 +41,11 @@ class BaseProfileMapping(ABC): _conn: Connection | None = None - def __init__(self, conn_id: str, profile_args: dict[str, Any] | None = None): + def __init__(self, conn_id: str, profile_args: dict[str, Any] | None = None, disable_event_tracking: bool = False): self.conn_id = conn_id self.profile_args = profile_args or {} self._validate_profile_args() + self.disable_event_tracking = disable_event_tracking def _validate_profile_args(self) -> None: """ @@ -178,6 +179,10 @@ def get_profile_file_contents( "outputs": {target_name: profile_vars}, } } + + if self.disable_event_tracking: + profile_contents["config"] = {"send_anonymous_usage_stats": "False"} + return str(yaml.dump(profile_contents, indent=4)) def get_dbt_value(self, name: str) -> Any: diff --git a/docs/templates/index.rst.jinja2 b/docs/templates/index.rst.jinja2 index 5c0152363b..d5c3069111 100644 --- a/docs/templates/index.rst.jinja2 +++ b/docs/templates/index.rst.jinja2 @@ -83,6 +83,37 @@ but override the ``database`` and ``schema`` values: Note that when using a profile mapping, the profiles.yml file gets generated with the profile name and target name you specify in ``ProfileConfig``. +Disabling dbt event tracking +-------------------------------- +.. versionadded:: 1.3 + +By default `dbt will track events `_ by sending anonymous usage data +when dbt commands are invoked. Users have an option to opt out of event tracking by updating their ``profiles.yml`` file. + +If you'd like to disable this behavior in the Cosmos generated profile, you can pass ``disable_event_tracking=True`` to the profile mapping like in +the example below: + +.. code-block:: python + + from cosmos.profiles import SnowflakeUserPasswordProfileMapping + + profile_config = ProfileConfig( + profile_name="my_profile_name", + target_name="my_target_name", + profile_mapping=SnowflakeUserPasswordProfileMapping( + conn_id="my_snowflake_conn_id", + profile_args={ + "database": "my_snowflake_database", + "schema": "my_snowflake_schema", + }, + disable_event_tracking=True, + ), + ) + + dag = DbtDag(profile_config=profile_config, ...) + + + Using your own profiles.yml file ++++++++++++++++++++++++++++++++++++ diff --git a/tests/profiles/test_base_profile.py b/tests/profiles/test_base_profile.py index 1b1ba3e8ad..f2603d43cd 100644 --- a/tests/profiles/test_base_profile.py +++ b/tests/profiles/test_base_profile.py @@ -1,4 +1,8 @@ +from __future__ import annotations + import pytest +import yaml + from cosmos.profiles.base import BaseProfileMapping from cosmos.exceptions import CosmosValueError @@ -7,8 +11,9 @@ class TestProfileMapping(BaseProfileMapping): dbt_profile_method: str = "fake-method" dbt_profile_type: str = "fake-type" - def profile(self): - raise NotImplementedError + @property + def profile(self) -> dict[str, str]: + return {"some-profile-key": "some-profile-value"} @pytest.mark.parametrize("profile_arg", ["type", "method"]) @@ -29,3 +34,19 @@ def test_validate_profile_args(profile_arg: str): conn_id="fake_conn_id", profile_args=profile_args, ) + + +@pytest.mark.parametrize("disable_event_tracking", [True, False]) +def test_disable_event_tracking(disable_event_tracking: str): + """ + Tests the config block in the profile is set correctly if disable_event_tracking is set. + """ + test_profile = TestProfileMapping( + conn_id="fake_conn_id", + disable_event_tracking=disable_event_tracking, + ) + profile_contents = yaml.safe_load(test_profile.get_profile_file_contents(profile_name="fake-profile-name")) + + assert ("config" in profile_contents) == disable_event_tracking + if disable_event_tracking: + assert profile_contents["config"]["send_anonymous_usage_stats"] == "False" From 8f970b0b4af781bfc5cde65dbac563171e25cf96 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 19 Dec 2023 12:29:57 -0800 Subject: [PATCH 058/223] =?UTF-8?q?=E2=AC=86=20[pre-commit.ci]=20pre-commi?= =?UTF-8?q?t=20autoupdate=20(#770)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.1.7 → v0.1.8](https://github.com/astral-sh/ruff-pre-commit/compare/v0.1.7...v0.1.8) - [github.com/psf/black: 23.11.0 → 23.12.0](https://github.com/psf/black/compare/23.11.0...23.12.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index baf2cd57c5..c4b9989fdf 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -54,13 +54,13 @@ repos: - --py37-plus - --keep-runtime-typing - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.7 + rev: v0.1.8 hooks: - id: ruff args: - --fix - repo: https://github.com/psf/black - rev: 23.11.0 + rev: 23.12.0 hooks: - id: black args: ["--config", "./pyproject.toml"] From 9112215c6067be0b200749b3d134c8bcafdb63a9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 26 Dec 2023 18:55:28 -0500 Subject: [PATCH 059/223] =?UTF-8?q?=E2=AC=86=20[pre-commit.ci]=20pre-commi?= =?UTF-8?q?t=20autoupdate=20(#775)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.1.8 → v0.1.9](https://github.com/astral-sh/ruff-pre-commit/compare/v0.1.8...v0.1.9) - [github.com/psf/black: 23.12.0 → 23.12.1](https://github.com/psf/black/compare/23.12.0...23.12.1) - [github.com/pre-commit/mirrors-mypy: v1.7.1 → v1.8.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.7.1...v1.8.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c4b9989fdf..3585f4e33c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -54,13 +54,13 @@ repos: - --py37-plus - --keep-runtime-typing - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.8 + rev: v0.1.9 hooks: - id: ruff args: - --fix - repo: https://github.com/psf/black - rev: 23.12.0 + rev: 23.12.1 hooks: - id: black args: ["--config", "./pyproject.toml"] @@ -71,7 +71,7 @@ repos: alias: black additional_dependencies: [black>=22.10.0] - repo: https://github.com/pre-commit/mirrors-mypy - rev: "v1.7.1" + rev: "v1.8.0" hooks: - id: mypy From f74c9a2df2cffe13abc9036fe32247218a3a1e70 Mon Sep 17 00:00:00 2001 From: Julian LaNeve Date: Wed, 3 Jan 2024 05:53:48 -0500 Subject: [PATCH 060/223] Remove upper limit to Pydantic (#772) [Airflow's upgraded to Pydantic 2.0 (!!)](https://github.com/apache/airflow/pull/35551), so this removes the restriction on Cosmos to ensure it's compatible. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9d367c075f..6218fb69f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -118,7 +118,7 @@ kubernetes = [ "apache-airflow-providers-cncf-kubernetes>=5.1.1", ] pydantic = [ - "pydantic>=1.10.0,<2.0.0", + "pydantic>=1.10.0", ] [project.entry-points.cosmos] From 91ca232a6d868d3dd45c9097281dd5749e81cab4 Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Wed, 3 Jan 2024 16:29:43 +0000 Subject: [PATCH 061/223] Add Airflow 2.8 to test matrix and update dbt & Airflow conflicts doc (#779) Apache Airflow 2.8 was released on 18 December 2023: https://pypi.org/project/apache-airflow/2.8.0/ Add Airflow 2.8 to Cosmos integration tests matrix. Update Airflow & dbt dependencies conflict document to include the new conflicts introduced by this release: https://astronomer.github.io/astronomer-cosmos/getting_started/execution-modes-local-conflicts.html#execution-modes-local-conflicts --- .../execution-modes-local-conflicts.rst | 22 ++++++++++++++++--- pyproject.toml | 3 ++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/docs/getting_started/execution-modes-local-conflicts.rst b/docs/getting_started/execution-modes-local-conflicts.rst index 3e201bef8f..96921b6f7f 100644 --- a/docs/getting_started/execution-modes-local-conflicts.rst +++ b/docs/getting_started/execution-modes-local-conflicts.rst @@ -1,10 +1,10 @@ .. _execution-modes-local-conflicts: -Airflow and DBT dependencies conflicts +Airflow and dbt dependencies conflicts ====================================== When using the `Local Execution Mode `__, users may face dependency conflicts between -Apache Airflow and DBT. The conflicts may increase depending on the Airflow providers and DBT plugins being used. +Apache Airflow and dbt. The conflicts may increase depending on the Airflow providers and dbt adapters being used. If you find errors, we recommend users look into using `alternative execution modes `__. @@ -25,10 +25,26 @@ In the following table, ``x`` represents combinations that lead to conflicts (va +---------------+-----+-----+-----+-----+-----+-----+-----+-----+ | 2.7 | x | x | x | x | x | | | | +---------------+-----+-----+-----+-----+-----+-----+-----+-----+ +| 2.8 | x | x | x | x | x | | x | x | ++---------------+-----+-----+-----+-----+-----+-----+-----+-----+ + Examples of errors ----------------------------------- +.. code-block:: bash + + The conflict is caused by: + apache-airflow 2.8.0 depends on pydantic>=2.3.0 + dbt-semantic-interfaces 0.4.2 depends on pydantic~=1.10 + apache-airflow 2.8.0 depends on pydantic>=2.3.0 + dbt-semantic-interfaces 0.4.2.dev0 depends on pydantic~=1.10 + apache-airflow 2.8.0 depends on pydantic>=2.3.0 + dbt-semantic-interfaces 0.4.1 depends on pydantic~=1.10 + apache-airflow 2.8.0 depends on pydantic>=2.3.0 + dbt-semantic-interfaces 0.4.0 depends on pydantic~=1.10 + + .. code-block:: bash ERROR: Cannot install apache-airflow==2.2.4 and dbt-core==1.5.0 because these package versions have conflicting dependencies. @@ -78,7 +94,7 @@ The table was created by running `nox `__ wi @nox.parametrize( "dbt_version", ["1.0", "1.1", "1.2", "1.3", "1.4", "1.5", "1.6", "1.7"] ) - @nox.parametrize("airflow_version", ["2.2.4", "2.3", "2.4", "2.5", "2.6", "2.7"]) + @nox.parametrize("airflow_version", ["2.2.4", "2.3", "2.4", "2.5", "2.6", "2.7", "2.8"]) def compatibility(session: nox.Session, airflow_version, dbt_version) -> None: """Run both unit and integration tests.""" session.run( diff --git a/pyproject.toml b/pyproject.toml index 6218fb69f7..5d966c91b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -159,7 +159,7 @@ dependencies = [ [[tool.hatch.envs.tests.matrix]] python = ["3.8", "3.9", "3.10"] -airflow = ["2.3", "2.4", "2.5", "2.6", "2.7"] +airflow = ["2.3", "2.4", "2.5", "2.6", "2.7", "2.8"] [tool.hatch.envs.tests.overrides] matrix.airflow.dependencies = [ @@ -169,6 +169,7 @@ matrix.airflow.dependencies = [ { value = "apache-airflow==2.6", if = ["2.6"] }, { value = "pydantic>=1.10.0,<2.0.0", if = ["2.6"]}, { value = "apache-airflow==2.7", if = ["2.7"] }, + { value = "apache-airflow==2.8", if = ["2.8"] }, ] [tool.hatch.envs.tests.scripts] From 32b261a12a6690d437c80e3a1d4a36235aa80064 Mon Sep 17 00:00:00 2001 From: Daniel Reeves <31971762+dwreeves@users.noreply.github.com> Date: Thu, 4 Jan 2024 05:42:56 -0500 Subject: [PATCH 062/223] Additions to docs regarding the `DBT_MANIFEST` load mode (#757) ## Description I thought that there were a few aspects regarding execution modes that could require more clarification in the docs. - The parsing methods docs mentions that only `LOCAL` execution mode is supported for `DBT_LS`, but the reverse was not true (i.e. execution mode docs made no mention of parsing methods), so I added notes about that. - GCC docs suggest using `VIRTUALENV` execution mode, but makes no mention of the fact that the `DBT_LS` parsing method is not supported in this execution mode. Naturally, in this case, users should be utilizing the `DBT_MANIFEST` load mode, but that means that the docs are incomplete since they don't include a `manifest_path=?` in the `ProjectConfig`. - Note that there are also discussions in the Airflow Slack regarding issues users have had parsing the `DbtDag` in GCC that are fixable via using a pre-compiled `manifest,json`, e.g. https://apache-airflow.slack.com/archives/C059CC42E9W/p1696435273519979 - Also see #520 for more discussion. - Generally speaking when doing the `DBT_MANIFEST` load method, the pattern is that you run `dbt deps && dbt compile` as part of your deployment, and upload your full dbt project including these artifacts. This deployment approach may be obvious to veteran users of Airflow and/or dbt, but it may not be obvious to everyone, so I think adding a couple sentences in `parsing-methods.rst` is beneficial. ## Related Issue(s) Not explicitly related, but #520 discusses some issues encountered using the default parsing method. (Specifically, running `dbt deps` from a blank slate tends to slow everything down a lot.) Part of my motivation for adding to the docs is to better advertise + better document this alternate method of parsing the dbt DAG. ## Breaking Change? n/a ## Checklist n/a --- docs/configuration/parsing-methods.rst | 4 ++-- docs/getting_started/execution-modes.rst | 6 +++++- docs/getting_started/gcc.rst | 6 ++++++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/configuration/parsing-methods.rst b/docs/configuration/parsing-methods.rst index ef50bdb4e6..ab31c00d4f 100644 --- a/docs/configuration/parsing-methods.rst +++ b/docs/configuration/parsing-methods.rst @@ -16,7 +16,7 @@ There are benefits and drawbacks to each method: - ``dbt_manifest``: You have to generate the manifest file on your own. When using the manifest, Cosmos gets a complete set of metadata about your models. However, Cosmos uses its own selecting & excluding logic to determine which models to run, which may not be as robust as dbt's. - ``dbt_ls``: Cosmos will generate the manifest file for you. This method uses dbt's metadata AND dbt's selecting/excluding logic. This is the most robust method. However, this requires the dbt executable to be installed on your machine (either on the host directly or in a virtual environment). - ``dbt_ls_file`` (new in 1.3): Path to a file containing the ``dbt ls`` output. To use this method, run ``dbt ls`` using ``--output json`` and store the output in a file. ``RenderConfig.select`` and ``RenderConfig.exclude`` will not work using this method. -- ``custom``: Cosmos will parse your project and model files for you. This means that Cosmos will not have access to dbt's metadata. However, this method does not require the dbt executable to be installed on your machine. +- ``custom``: Cosmos will parse your project and model files. This means that Cosmos will not have access to dbt's metadata. However, this method does not require the dbt executable to be installed on your machine, and does not require the user to provide any dbt artifacts. If you're using the ``local`` mode, you should use the ``dbt_ls`` method. @@ -60,7 +60,7 @@ To use this: .. note:: - This only works for the ``local`` execution mode. + This only works if a dbt command / executable is available to the scheduler. If you don't have a ``manifest.json`` file, Cosmos will attempt to generate one from your dbt project. It does this by running ``dbt ls`` and parsing the output. diff --git a/docs/getting_started/execution-modes.rst b/docs/getting_started/execution-modes.rst index 5925853fe1..7211382387 100644 --- a/docs/getting_started/execution-modes.rst +++ b/docs/getting_started/execution-modes.rst @@ -72,7 +72,10 @@ In this case, users are responsible for declaring which version of ``dbt`` they Similar to the ``local`` execution mode, Cosmos converts Airflow Connections into a way ``dbt`` understands them by creating a ``dbt`` profile file (``profiles.yml``). -A drawback with this approach is that it is slower than ``local`` because it creates a new Python virtual environment for each Cosmos dbt task run. +Some drawbacks of this approach: + +- It is slower than ``local`` because it creates a new Python virtual environment for each Cosmos dbt task run. +- If dbt is unavailable in the Airflow scheduler, the default ``LoadMode.DBT_LS`` will not work. In this scenario, users must use a `parsing method `_ that does not rely on dbt, such as ``LoadMode.MANIFEST``. Example of how to use: @@ -91,6 +94,7 @@ The user has better environment isolation than when using ``local`` or ``virtual The other challenge with the ``docker`` approach is if the Airflow worker is already running in Docker, which sometimes can lead to challenges running `Docker in Docker `__. This approach can be significantly slower than ``virtualenv`` since it may have to build the ``Docker`` container, which is slower than creating a Virtualenv with ``dbt-core``. +If dbt is unavailable in the Airflow scheduler, the default ``LoadMode.DBT_LS`` will not work. In this scenario, users must use a `parsing method `_ that does not rely on dbt, such as ``LoadMode.MANIFEST``. Check the step-by-step guide on using the ``docker`` execution mode at :ref:`docker`. diff --git a/docs/getting_started/gcc.rst b/docs/getting_started/gcc.rst index 1ec056e842..5baa9c37ed 100644 --- a/docs/getting_started/gcc.rst +++ b/docs/getting_started/gcc.rst @@ -22,6 +22,8 @@ Make a new folder, ``dbt``, inside your local ``dags`` folder. Then, copy/paste Note: your dbt projects can go anywhere that Airflow can read. By default, Cosmos looks in the ``/usr/local/airflow/dags/dbt`` directory, but you can change this by setting the ``dbt_project_dir`` argument when you create your DAG instance. +For more accurate parsing of your dbt project, you should pre-compile your dbt project's ``manifest.json`` (include ``dbt deps && dbt compile`` as part of your deployment process). + For example, if you wanted to put your dbt project in the ``/usr/local/airflow/dags/my_dbt_project`` directory, you would do: .. code-block:: python @@ -31,11 +33,15 @@ For example, if you wanted to put your dbt project in the ``/usr/local/airflow/d my_cosmos_dag = DbtDag( project_config=ProjectConfig( dbt_project_path="/usr/local/airflow/dags/my_dbt_project", + manifest_path="/usr/local/airflow/dags/my_dbt_project/target/manifest.json", ), # ..., ) +.. note:: + You can also exclude the ``manifest_path=...`` from the ``ProjectConfig``. Excluding a ``manifest_path`` file will by default use Cosmos's ``custom`` parsing method, which may be less accurate at parsing a dbt project compared to providing a ``manifest.json``. + Create your DAG --------------- From e828bf3e2b2f1632b2145468b010b47fa3869f4c Mon Sep 17 00:00:00 2001 From: Ryan Hatter <25823361+RNHTTR@users.noreply.github.com> Date: Thu, 4 Jan 2024 05:58:34 -0500 Subject: [PATCH 063/223] Update README.rst quickstart link with link to getting started guide (#776) The link to the quickstart doesn't actually take users to any kind of quick start. So, instead, I've updated the link to point to the [getting started guide](https://astronomer.github.io/astronomer-cosmos/getting_started/astro.html) --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 041c23f3fa..0310114d70 100644 --- a/README.rst +++ b/README.rst @@ -31,7 +31,7 @@ Run your dbt Core projects as `Apache Airflow `_ DA Quickstart __________ -Check out the Quickstart guide on our `docs `_. See more examples at `/dev/dags `_ and at the `cosmos-demo repo `_. +Check out the Getting Started guide on our `docs `_. See more examples at `/dev/dags `_ and at the `cosmos-demo repo `_. Example Usage From 0fe721e6495d97ab549636dde4ad580b98e2ea4b Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Thu, 4 Jan 2024 22:48:59 +0000 Subject: [PATCH 064/223] Release 1.3.0 (#781) **Features** * Add new parsing method ``LoadMode.DBT_LS_FILE`` by @woogakoki in #733 ([documentation](https://astronomer.github.io/astronomer-cosmos/configuration/parsing-methods.html#dbt-ls-file)). * Add support to select using (some) graph operators when using ``LoadMode.CUSTOM`` and ``LoadMode.DBT_MANIFEST`` by @tatiana in #728 ([documentation](https://astronomer.github.io/astronomer-cosmos/configuration/selecting-excluding.html#using-select-and-exclude)) * Add support for dbt ``selector`` arg for DAG parsing by @jbandoro in #755, ([documentation](https://astronomer.github.io/astronomer-cosmos/configuration/render-config.html#render-config)). * Add ``ProfileMapping`` for Vertica by @perttus in #540, #688 and #741, as ([documentation](https://astronomer.github.io/astronomer-cosmos/profiles/VerticaUserPassword.html)). * Add ``ProfileMapping`` for Snowflake encrypted private key path by @ivanstillfront in #608, as ([documentation]( https://astronomer.github.io/astronomer-cosmos/profiles/SnowflakeEncryptedPrivateKeyFilePem.html)). * Add support for Snowflake encrypted private key environment variable by @DanMawdsleyBA in #649 * Add ``DbtDocsGCSOperator`` for uploading dbt docs to GCS by @jbandoro in #616, ([documentation](https://astronomer.github.io/astronomer-cosmos/configuration/generating-docs.html#upload-to-gcs)). * Add cosmos/propagate_logs Airflow config support for disabling log propagation by @agreenburg in #648, ([documentation](https://astronomer.github.io/astronomer-cosmos/configuration/logging.html)). * Add operator_args ``full_refresh`` as a templated field by @joppevos in #623 * Expose environment variables and dbt variables in ``ProjectConfig`` by @jbandoro in #735 ([documentation](https://astronomer.github.io/astronomer-cosmos/configuration/project-config.html#project-config-example)). * Support disabling event tracking when using Cosmos profile mapping by @jbandoro in #768, ([documentation](https://astronomer.github.io/astronomer-cosmos/profiles/index.html#disabling-dbt-event-tracking)). **Enhancements** * Make Pydantic an optional dependency by @pixie79 in #736 * Create a symbolic link to ``dbt_packages`` when ``dbt_deps`` is False when using ``LoadMode.DBT_LS`` by @DanMawdsleyBA in #730 * Add ``aws_session_token`` for Athena mapping by @benjamin-awd in #663 * Retrieve temporary credentials from ``conn_id`` for Athena by @octiva in #758 * Extend ``DbtDocsLocalOperator`` with static flag by @joppevos in #759 **Bug fixes** * Remove Pydantic upper version restriction so Cosmos can be used with Airflow 2.8 by @jlaneve in #772 **Others** * Replace flake8 for Ruff by @joppevos in #743 * Reduce code complexity to 8 by @joppevos in #738 * Speed up integration tests by @jbandoro in #732 * Fix README quickstart link in by @RNHTTR in #776 * Add package location to work with hatchling 1.19.0 by @jbandoro in #761 * Fix type check error in ``DbtKubernetesBaseOperator.build_env_args`` by @jbandoro in #766 * Improve ``DBT_MANIFEST`` documentation by @dwreeves in #757 * Update conflict matrix between Airflow and dbt versions by @tatiana in #731 and #779 * pre-commit updates in #775, #770, #762 --- CHANGELOG.rst | 33 +++++++++++++++++++++++---------- cosmos/__init__.py | 2 +- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 252875cfc5..23fa05fe16 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,33 +1,46 @@ Changelog ========= -1.3.0a3 (2023-12-07) --------------------- +1.3.0 (2023-01-04) +------------------ Features -* Add ``ProfileMapping`` for Vertica by @perttus in #540 and #688 -* Add ``ProfileMapping`` for Snowflake encrypted private key path by @ivanstillfront in #608 +* Add new parsing method ``LoadMode.DBT_LS_FILE`` by @woogakoki in #733 (`documentation `_). +* Add support to select using (some) graph operators when using ``LoadMode.CUSTOM`` and ``LoadMode.DBT_MANIFEST`` by @tatiana in #728 (`documentation `_) +* Add support for dbt ``selector`` arg for DAG parsing by @jbandoro in #755 (`documentation `_). +* Add ``ProfileMapping`` for Vertica by @perttus in #540, #688 and #741 (`documentation `_). +* Add ``ProfileMapping`` for Snowflake encrypted private key path by @ivanstillfront in #608 (`documentation `_). * Add support for Snowflake encrypted private key environment variable by @DanMawdsleyBA in #649 -* Add ``DbtDocsGCSOperator`` for uploading dbt docs to GCS by @jbandoro in #616 -* Add support to select using (some) graph operators when using ``LoadMode.CUSTOM`` and ``LoadMode.DBT_MANIFEST`` by @tatiana in #728 -* Add cosmos/propagate_logs Airflow config support for disabling log pr… by @agreenburg in #648 +* Add ``DbtDocsGCSOperator`` for uploading dbt docs to GCS by @jbandoro in #616, (`documentation `_). +* Add cosmos/propagate_logs Airflow config support for disabling log propagation by @agreenburg in #648 (`documentation `_). * Add operator_args ``full_refresh`` as a templated field by @joppevos in #623 -* Expose environment variables and dbt variables in ``ProjectConfig`` by @jbandoro in #735 +* Expose environment variables and dbt variables in ``ProjectConfig`` by @jbandoro in #735 (`documentation `_). +* Support disabling event tracking when using Cosmos profile mapping by @jbandoro in #768 (`documentation `_). Enhancements * Make Pydantic an optional dependency by @pixie79 in #736 * Create a symbolic link to ``dbt_packages`` when ``dbt_deps`` is False when using ``LoadMode.DBT_LS`` by @DanMawdsleyBA in #730 -* Support no ``profile_config`` for ``ExecutionMode.KUBERNETES`` and ``ExecutionMode.DOCKER`` by @MrBones757 and @tatiana in #681 and #731 * Add ``aws_session_token`` for Athena mapping by @benjamin-awd in #663 +* Retrieve temporary credentials from ``conn_id`` for Athena by @octiva in #758 +* Extend ``DbtDocsLocalOperator`` with static flag by @joppevos in #759 + +Bug fixes + +* Remove Pydantic upper version restriction so Cosmos can be used with Airflow 2.8 by @jlaneve in #772 Others * Replace flake8 for Ruff by @joppevos in #743 * Reduce code complexity to 8 by @joppevos in #738 -* Update conflict matrix between Airflow and dbt versions by @tatiana in #731 * Speed up integration tests by @jbandoro in #732 +* Fix README quickstart link in by @RNHTTR in #776 +* Add package location to work with hatchling 1.19.0 by @jbandoro in #761 +* Fix type check error in ``DbtKubernetesBaseOperator.build_env_args`` by @jbandoro in #766 +* Improve ``DBT_MANIFEST`` documentation by @dwreeves in #757 +* Update conflict matrix between Airflow and dbt versions by @tatiana in #731 and #779 +* pre-commit updates in #775, #770, #762 1.2.5 (2023-11-23) diff --git a/cosmos/__init__.py b/cosmos/__init__.py index 2d3c2f6ac7..3910f84bb2 100644 --- a/cosmos/__init__.py +++ b/cosmos/__init__.py @@ -5,7 +5,7 @@ Contains dags, task groups, and operators. """ -__version__ = "1.3.0a3" +__version__ = "1.3.0" from cosmos.airflow.dag import DbtDag From 5056e155a39c053ef85603dc8d2b23cd14912e7b Mon Sep 17 00:00:00 2001 From: Ryan Hatter <25823361+RNHTTR@users.noreply.github.com> Date: Fri, 5 Jan 2024 06:20:05 -0500 Subject: [PATCH 065/223] Update examples to use the latest astro-runtime (10.0.0) (#777) --- dev/Dockerfile | 2 +- docs/getting_started/astro.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dev/Dockerfile b/dev/Dockerfile index 90c49ed6ca..b929be8b1c 100644 --- a/dev/Dockerfile +++ b/dev/Dockerfile @@ -1,4 +1,4 @@ -FROM quay.io/astronomer/astro-runtime:7.3.0-base +FROM quay.io/astronomer/astro-runtime:10.0.0-base USER root diff --git a/docs/getting_started/astro.rst b/docs/getting_started/astro.rst index c0bedc7e64..8aaa194e5f 100644 --- a/docs/getting_started/astro.rst +++ b/docs/getting_started/astro.rst @@ -20,7 +20,7 @@ Create a virtual environment in your ``Dockerfile`` using the sample below. Be s .. code-block:: docker - FROM quay.io/astronomer/astro-runtime:8.8.0 + FROM quay.io/astronomer/astro-runtime:10.0.0 # install dbt into a virtual environment RUN python -m venv dbt_venv && source dbt_venv/bin/activate && \ From ba87832b4df5327276f63b22145f1062f240ec8f Mon Sep 17 00:00:00 2001 From: Justin Bandoro <79104794+jbandoro@users.noreply.github.com> Date: Mon, 8 Jan 2024 03:06:26 -0800 Subject: [PATCH 066/223] Bugfix disable event tracking throwing error (#784) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As reported in #783 the new disabling event tracking feature was throwing an error. I didn't test an update I made changing the value from boolean to a string 🤦‍♂️. This PR fixes the issue and also adds it to a cosmo dag for integration testing so the error would be caught. Closes: #783 --- cosmos/profiles/base.py | 4 ++-- dev/dags/basic_cosmos_dag.py | 1 + tests/profiles/test_base_profile.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/cosmos/profiles/base.py b/cosmos/profiles/base.py index 171eac2d9c..2b2a5c7e2a 100644 --- a/cosmos/profiles/base.py +++ b/cosmos/profiles/base.py @@ -173,7 +173,7 @@ def get_profile_file_contents( # filter out any null values profile_vars = {k: v for k, v in profile_vars.items() if v is not None} - profile_contents = { + profile_contents: dict[str, Any] = { profile_name: { "target": target_name, "outputs": {target_name: profile_vars}, @@ -181,7 +181,7 @@ def get_profile_file_contents( } if self.disable_event_tracking: - profile_contents["config"] = {"send_anonymous_usage_stats": "False"} + profile_contents["config"] = {"send_anonymous_usage_stats": False} return str(yaml.dump(profile_contents, indent=4)) diff --git a/dev/dags/basic_cosmos_dag.py b/dev/dags/basic_cosmos_dag.py index 8bd49b0b39..80e3c1a5f5 100644 --- a/dev/dags/basic_cosmos_dag.py +++ b/dev/dags/basic_cosmos_dag.py @@ -18,6 +18,7 @@ profile_mapping=PostgresUserPasswordProfileMapping( conn_id="airflow_db", profile_args={"schema": "public"}, + disable_event_tracking=True, ), ) diff --git a/tests/profiles/test_base_profile.py b/tests/profiles/test_base_profile.py index f2603d43cd..98c4004e71 100644 --- a/tests/profiles/test_base_profile.py +++ b/tests/profiles/test_base_profile.py @@ -49,4 +49,4 @@ def test_disable_event_tracking(disable_event_tracking: str): assert ("config" in profile_contents) == disable_event_tracking if disable_event_tracking: - assert profile_contents["config"]["send_anonymous_usage_stats"] == "False" + assert profile_contents["config"]["send_anonymous_usage_stats"] is False From a9cde8e716ef7cd0b709687ec837e47076006a1e Mon Sep 17 00:00:00 2001 From: Justin Bandoro <79104794+jbandoro@users.noreply.github.com> Date: Mon, 8 Jan 2024 03:49:40 -0800 Subject: [PATCH 067/223] Refactor common executor constructors with test coverage (#774) I noticed in #771 that there was a lot of repeated class constructors in order to add a new execution mode that is common among `local`, `docker` and `kubernetes` and there is no test coverage for the constructors and methods in some of the operators. This PR attempts to make it easier to add new execution operators in the future. ## Breaking Change? None There may be task UI color differences with the kuberentes/docker operators, since now all of LS/Seed/Run etc. operators across execution modes have the same task colors. --- cosmos/operators/base.py | 119 +++++++++++++++++++++++++++-- cosmos/operators/docker.py | 90 ++++++---------------- cosmos/operators/kubernetes.py | 95 +++++------------------ cosmos/operators/local.py | 110 ++++++-------------------- cosmos/operators/virtualenv.py | 6 +- tests/operators/test_base.py | 50 ++++++++++++ tests/operators/test_docker.py | 8 +- tests/operators/test_kubernetes.py | 8 +- tests/operators/test_local.py | 40 +++++----- tests/operators/test_virtualenv.py | 6 +- 10 files changed, 269 insertions(+), 263 deletions(-) create mode 100644 tests/operators/test_base.py diff --git a/cosmos/operators/base.py b/cosmos/operators/base.py index 6d276013d5..c46b95c591 100644 --- a/cosmos/operators/base.py +++ b/cosmos/operators/base.py @@ -2,6 +2,7 @@ import os from typing import Any, Sequence, Tuple +from abc import ABCMeta, abstractmethod import yaml from airflow.models.baseoperator import BaseOperator @@ -15,14 +16,13 @@ logger = get_logger(__name__) -class DbtBaseOperator(BaseOperator): +class AbstractDbtBaseOperator(BaseOperator, metaclass=ABCMeta): """ Executes a dbt core cli command. :param project_dir: Which directory to look in for the dbt_project.yml file. Default is the current working directory and its parents. :param conn_id: The airflow connection to use as the target - :param base_cmd: dbt sub-command to run (i.e ls, seed, run, test, etc.) :param select: dbt optional argument that specifies which nodes to include. :param exclude: dbt optional argument that specifies which models to exclude. :param selector: dbt optional argument - the selector name to use, as defined in selectors.yml @@ -78,11 +78,15 @@ class DbtBaseOperator(BaseOperator): intercept_flag = True + @property + @abstractmethod + def base_cmd(self) -> list[str]: + """Override this property to set the dbt sub-command (i.e ls, seed, run, test, etc.) for the operator""" + def __init__( self, project_dir: str, conn_id: str | None = None, - base_cmd: list[str] | None = None, select: str | None = None, exclude: str | None = None, selector: str | None = None, @@ -109,7 +113,6 @@ def __init__( ) -> None: self.project_dir = project_dir self.conn_id = conn_id - self.base_cmd = base_cmd self.select = select self.exclude = exclude self.selector = selector @@ -203,6 +206,10 @@ def add_global_flags(self) -> list[str]: flags.append(f"--{global_boolean_flag.replace('_', '-')}") return flags + def add_cmd_flags(self) -> list[str]: + """Allows subclasses to override to add flags for their dbt command""" + return [] + def build_cmd( self, context: Context, @@ -212,8 +219,7 @@ def build_cmd( dbt_cmd.extend(self.dbt_cmd_global_flags) - if self.base_cmd: - dbt_cmd.extend(self.base_cmd) + dbt_cmd.extend(self.base_cmd) if self.indirect_selection: dbt_cmd += ["--indirect-selection", self.indirect_selection] @@ -231,3 +237,104 @@ def build_cmd( env = self.get_env(context) return dbt_cmd, env + + +class DbtLSMixin: + """ + Executes a dbt core ls command. + """ + + base_cmd = ["ls"] + ui_color = "#DBCDF6" + + +class DbtSeedMixin: + """ + Mixin for dbt seed operation command. + + :param full_refresh: whether to add the flag --full-refresh to the dbt seed command + """ + + base_cmd = ["seed"] + ui_color = "#F58D7E" + + template_fields: Sequence[str] = ("full_refresh",) + + def __init__(self, full_refresh: bool = False, **kwargs: Any) -> None: + self.full_refresh = full_refresh + super().__init__(**kwargs) + + def add_cmd_flags(self) -> list[str]: + flags = [] + if self.full_refresh is True: + flags.append("--full-refresh") + + return flags + + +class DbtSnapshotMixin: + """Mixin for a dbt snapshot command.""" + + base_cmd = ["snapshot"] + ui_color = "#964B00" + + +class DbtRunMixin: + """ + Mixin for dbt run command. + + :param full_refresh: whether to add the flag --full-refresh to the dbt seed command + """ + + base_cmd = ["run"] + ui_color = "#7352BA" + ui_fgcolor = "#F4F2FC" + + template_fields: Sequence[str] = ("full_refresh",) + + def __init__(self, full_refresh: bool = False, **kwargs: Any) -> None: + self.full_refresh = full_refresh + super().__init__(**kwargs) + + def add_cmd_flags(self) -> list[str]: + flags = [] + if self.full_refresh is True: + flags.append("--full-refresh") + + return flags + + +class DbtTestMixin: + """Mixin for dbt test command.""" + + base_cmd = ["test"] + ui_color = "#8194E0" + + +class DbtRunOperationMixin: + """ + Mixin for dbt run operation command. + + :param macro_name: name of macro to execute + :param args: Supply arguments to the macro. This dictionary will be mapped to the keyword arguments defined in the + selected macro. + """ + + ui_color = "#8194E0" + template_fields: Sequence[str] = ("args",) + + def __init__(self, macro_name: str, args: dict[str, Any] | None = None, **kwargs: Any) -> None: + self.macro_name = macro_name + self.args = args + super().__init__(**kwargs) + + @property + def base_cmd(self) -> list[str]: + return ["run-operation", self.macro_name] + + def add_cmd_flags(self) -> list[str]: + flags = [] + if self.args is not None: + flags.append("--args") + flags.append(yaml.dump(self.args)) + return flags diff --git a/cosmos/operators/docker.py b/cosmos/operators/docker.py index fb2e1c90c7..dfe6b955d7 100644 --- a/cosmos/operators/docker.py +++ b/cosmos/operators/docker.py @@ -2,11 +2,18 @@ from typing import Any, Callable, Sequence -import yaml from airflow.utils.context import Context from cosmos.log import get_logger -from cosmos.operators.base import DbtBaseOperator +from cosmos.operators.base import ( + AbstractDbtBaseOperator, + DbtRunMixin, + DbtSeedMixin, + DbtSnapshotMixin, + DbtTestMixin, + DbtLSMixin, + DbtRunOperationMixin, +) logger = get_logger(__name__) @@ -20,13 +27,15 @@ ) -class DbtDockerBaseOperator(DockerOperator, DbtBaseOperator): # type: ignore +class DbtDockerBaseOperator(DockerOperator, AbstractDbtBaseOperator): # type: ignore """ Executes a dbt core cli command in a Docker container. """ - template_fields: Sequence[str] = tuple(list(DbtBaseOperator.template_fields) + list(DockerOperator.template_fields)) + template_fields: Sequence[str] = tuple( + list(AbstractDbtBaseOperator.template_fields) + list(DockerOperator.template_fields) + ) intercept_flag = False @@ -57,85 +66,48 @@ def execute(self, context: Context) -> None: self.build_and_run_cmd(context=context) -class DbtLSDockerOperator(DbtDockerBaseOperator): +class DbtLSDockerOperator(DbtLSMixin, DbtDockerBaseOperator): """ Executes a dbt core ls command. """ - ui_color = "#DBCDF6" - - def __init__(self, **kwargs: str) -> None: - super().__init__(**kwargs) - self.base_cmd = ["ls"] - -class DbtSeedDockerOperator(DbtDockerBaseOperator): +class DbtSeedDockerOperator(DbtSeedMixin, DbtDockerBaseOperator): """ Executes a dbt core seed command. :param full_refresh: dbt optional arg - dbt will treat incremental models as table models """ - ui_color = "#F58D7E" - - def __init__(self, full_refresh: bool = False, **kwargs: str) -> None: - self.full_refresh = full_refresh - super().__init__(**kwargs) - self.base_cmd = ["seed"] + template_fields: Sequence[str] = DbtDockerBaseOperator.template_fields + DbtSeedMixin.template_fields # type: ignore[operator] - def add_cmd_flags(self) -> list[str]: - flags = [] - if self.full_refresh is True: - flags.append("--full-refresh") - return flags - - def execute(self, context: Context) -> None: - cmd_flags = self.add_cmd_flags() - self.build_and_run_cmd(context=context, cmd_flags=cmd_flags) - - -class DbtSnapshotDockerOperator(DbtDockerBaseOperator): +class DbtSnapshotDockerOperator(DbtSnapshotMixin, DbtDockerBaseOperator): """ Executes a dbt core snapshot command. - """ - ui_color = "#964B00" - def __init__(self, **kwargs: str) -> None: - super().__init__(**kwargs) - self.base_cmd = ["snapshot"] - - -class DbtRunDockerOperator(DbtDockerBaseOperator): +class DbtRunDockerOperator(DbtRunMixin, DbtDockerBaseOperator): """ Executes a dbt core run command. """ - ui_color = "#7352BA" - ui_fgcolor = "#F4F2FC" - - def __init__(self, **kwargs: str) -> None: - super().__init__(**kwargs) - self.base_cmd = ["run"] + template_fields: Sequence[str] = DbtDockerBaseOperator.template_fields + DbtRunMixin.template_fields # type: ignore[operator] -class DbtTestDockerOperator(DbtDockerBaseOperator): +class DbtTestDockerOperator(DbtTestMixin, DbtDockerBaseOperator): """ Executes a dbt core test command. """ - ui_color = "#8194E0" - def __init__(self, on_warning_callback: Callable[..., Any] | None = None, **kwargs: str) -> None: super().__init__(**kwargs) - self.base_cmd = ["test"] # as of now, on_warning_callback in docker executor does nothing self.on_warning_callback = on_warning_callback -class DbtRunOperationDockerOperator(DbtDockerBaseOperator): +class DbtRunOperationDockerOperator(DbtRunOperationMixin, DbtDockerBaseOperator): """ Executes a dbt core run-operation command. @@ -144,22 +116,4 @@ class DbtRunOperationDockerOperator(DbtDockerBaseOperator): selected macro. """ - ui_color = "#8194E0" - template_fields: Sequence[str] = ("args",) - - def __init__(self, macro_name: str, args: dict[str, Any] | None = None, **kwargs: str) -> None: - self.macro_name = macro_name - self.args = args - super().__init__(**kwargs) - self.base_cmd = ["run-operation", macro_name] - - def add_cmd_flags(self) -> list[str]: - flags = [] - if self.args is not None: - flags.append("--args") - flags.append(yaml.dump(self.args)) - return flags - - def execute(self, context: Context) -> None: - cmd_flags = self.add_cmd_flags() - self.build_and_run_cmd(context=context, cmd_flags=cmd_flags) + template_fields: Sequence[str] = DbtDockerBaseOperator.template_fields + DbtRunOperationMixin.template_fields # type: ignore[operator] diff --git a/cosmos/operators/kubernetes.py b/cosmos/operators/kubernetes.py index b844716de1..353d9c5346 100644 --- a/cosmos/operators/kubernetes.py +++ b/cosmos/operators/kubernetes.py @@ -3,12 +3,19 @@ from os import PathLike from typing import Any, Callable, Sequence -import yaml from airflow.utils.context import Context, context_merge from cosmos.log import get_logger from cosmos.config import ProfileConfig -from cosmos.operators.base import DbtBaseOperator +from cosmos.operators.base import ( + AbstractDbtBaseOperator, + DbtRunMixin, + DbtSeedMixin, + DbtSnapshotMixin, + DbtTestMixin, + DbtLSMixin, + DbtRunOperationMixin, +) from airflow.models import TaskInstance from cosmos.dbt.parser.output import extract_log_issues @@ -37,14 +44,14 @@ ) -class DbtKubernetesBaseOperator(KubernetesPodOperator, DbtBaseOperator): # type: ignore +class DbtKubernetesBaseOperator(KubernetesPodOperator, AbstractDbtBaseOperator): # type: ignore """ Executes a dbt core cli command in a Kubernetes Pod. """ template_fields: Sequence[str] = tuple( - list(DbtBaseOperator.template_fields) + list(KubernetesPodOperator.template_fields) + list(AbstractDbtBaseOperator.template_fields) + list(KubernetesPodOperator.template_fields) ) intercept_flag = False @@ -94,77 +101,39 @@ def execute(self, context: Context) -> None: self.build_and_run_cmd(context=context) -class DbtLSKubernetesOperator(DbtKubernetesBaseOperator): +class DbtLSKubernetesOperator(DbtLSMixin, DbtKubernetesBaseOperator): """ Executes a dbt core ls command. """ - ui_color = "#DBCDF6" - def __init__(self, **kwargs: Any) -> None: - super().__init__(**kwargs) - self.base_cmd = ["ls"] - - -class DbtSeedKubernetesOperator(DbtKubernetesBaseOperator): +class DbtSeedKubernetesOperator(DbtSeedMixin, DbtKubernetesBaseOperator): """ Executes a dbt core seed command. - - :param full_refresh: dbt optional arg - dbt will treat incremental models as table models """ - ui_color = "#F58D7E" - - def __init__(self, full_refresh: bool = False, **kwargs: Any) -> None: - self.full_refresh = full_refresh - super().__init__(**kwargs) - self.base_cmd = ["seed"] - - def add_cmd_flags(self) -> list[str]: - flags = [] - if self.full_refresh is True: - flags.append("--full-refresh") - - return flags - - def execute(self, context: Context) -> None: - cmd_flags = self.add_cmd_flags() - self.build_and_run_cmd(context=context, cmd_flags=cmd_flags) + template_fields: Sequence[str] = DbtKubernetesBaseOperator.template_fields + DbtSeedMixin.template_fields # type: ignore[operator] -class DbtSnapshotKubernetesOperator(DbtKubernetesBaseOperator): +class DbtSnapshotKubernetesOperator(DbtSnapshotMixin, DbtKubernetesBaseOperator): """ Executes a dbt core snapshot command. - """ - ui_color = "#964B00" - - def __init__(self, **kwargs: Any) -> None: - super().__init__(**kwargs) - self.base_cmd = ["snapshot"] - -class DbtRunKubernetesOperator(DbtKubernetesBaseOperator): +class DbtRunKubernetesOperator(DbtRunMixin, DbtKubernetesBaseOperator): """ Executes a dbt core run command. """ - ui_color = "#7352BA" - ui_fgcolor = "#F4F2FC" - - def __init__(self, **kwargs: Any) -> None: - super().__init__(**kwargs) - self.base_cmd = ["run"] + template_fields: Sequence[str] = DbtKubernetesBaseOperator.template_fields + DbtRunMixin.template_fields # type: ignore[operator] -class DbtTestKubernetesOperator(DbtKubernetesBaseOperator): +class DbtTestKubernetesOperator(DbtTestMixin, DbtKubernetesBaseOperator): """ Executes a dbt core test command. """ - ui_color = "#8194E0" - def __init__(self, on_warning_callback: Callable[..., Any] | None = None, **kwargs: Any) -> None: if not on_warning_callback: super().__init__(**kwargs) @@ -203,8 +172,6 @@ def __init__(self, on_warning_callback: Callable[..., Any] | None = None, **kwar super().__init__(**kwargs) - self.base_cmd = ["test"] - def _handle_warnings(self, context: Context) -> None: """ Handles warnings by extracting log issues, creating additional context, and calling the @@ -258,31 +225,9 @@ def _cleanup_pod(self, context: Context) -> None: task.cleanup(pod=task.pod, remote_pod=task.remote_pod) -class DbtRunOperationKubernetesOperator(DbtKubernetesBaseOperator): +class DbtRunOperationKubernetesOperator(DbtRunOperationMixin, DbtKubernetesBaseOperator): """ Executes a dbt core run-operation command. - - :param macro_name: name of macro to execute - :param args: Supply arguments to the macro. This dictionary will be mapped to the keyword arguments defined in the - selected macro. """ - ui_color = "#8194E0" - template_fields: Sequence[str] = ("args",) - - def __init__(self, macro_name: str, args: dict[str, Any] | None = None, **kwargs: Any) -> None: - self.macro_name = macro_name - self.args = args - super().__init__(**kwargs) - self.base_cmd = ["run-operation", macro_name] - - def add_cmd_flags(self) -> list[str]: - flags = [] - if self.args is not None: - flags.append("--args") - flags.append(yaml.dump(self.args)) - return flags - - def execute(self, context: Context) -> None: - cmd_flags = self.add_cmd_flags() - self.build_and_run_cmd(context=context, cmd_flags=cmd_flags) + template_fields: Sequence[str] = DbtKubernetesBaseOperator.template_fields + DbtRunOperationMixin.template_fields # type: ignore[operator] diff --git a/cosmos/operators/local.py b/cosmos/operators/local.py index ea7cb41ae6..ab9b33a4e1 100644 --- a/cosmos/operators/local.py +++ b/cosmos/operators/local.py @@ -11,7 +11,6 @@ import airflow import jinja2 -import yaml from airflow import DAG from airflow.compat.functools import cached_property from airflow.configuration import conf @@ -38,7 +37,15 @@ from cosmos.constants import DEFAULT_OPENLINEAGE_NAMESPACE, OPENLINEAGE_PRODUCER from cosmos.config import ProfileConfig from cosmos.log import get_logger -from cosmos.operators.base import DbtBaseOperator +from cosmos.operators.base import ( + AbstractDbtBaseOperator, + DbtRunMixin, + DbtSeedMixin, + DbtSnapshotMixin, + DbtTestMixin, + DbtLSMixin, + DbtRunOperationMixin, +) from cosmos.hooks.subprocess import ( FullOutputSubprocessHook, FullOutputSubprocessResult, @@ -80,7 +87,7 @@ class OperatorLineage: # type: ignore LINEAGE_NAMESPACE = os.getenv("OPENLINEAGE_NAMESPACE", DEFAULT_OPENLINEAGE_NAMESPACE) -class DbtLocalBaseOperator(DbtBaseOperator): +class DbtLocalBaseOperator(AbstractDbtBaseOperator): """ Executes a dbt core cli command locally. @@ -96,7 +103,7 @@ class DbtLocalBaseOperator(DbtBaseOperator): :param should_store_compiled_sql: If true, store the compiled SQL in the compiled_sql rendered template. """ - template_fields: Sequence[str] = DbtBaseOperator.template_fields + ("compiled_sql",) # type: ignore[operator] + template_fields: Sequence[str] = AbstractDbtBaseOperator.template_fields + ("compiled_sql",) # type: ignore[operator] template_fields_renderers = { "compiled_sql": "sql", } @@ -366,7 +373,7 @@ def build_and_run_cmd(self, context: Context, cmd_flags: list[str] | None = None return result def execute(self, context: Context) -> None: - self.build_and_run_cmd(context=context) + self.build_and_run_cmd(context=context, cmd_flags=self.add_cmd_flags()) def on_kill(self) -> None: if self.cancel_query_on_kill: @@ -377,100 +384,47 @@ def on_kill(self) -> None: self.subprocess_hook.send_sigterm() -class DbtLSLocalOperator(DbtLocalBaseOperator): +class DbtLSLocalOperator(DbtLSMixin, DbtLocalBaseOperator): """ Executes a dbt core ls command. """ - ui_color = "#DBCDF6" - - def __init__(self, **kwargs: Any) -> None: - super().__init__(**kwargs) - self.base_cmd = ["ls"] - -class DbtSeedLocalOperator(DbtLocalBaseOperator): +class DbtSeedLocalOperator(DbtSeedMixin, DbtLocalBaseOperator): """ Executes a dbt core seed command. - - :param full_refresh: dbt optional arg - dbt will treat incremental models as table models """ - ui_color = "#F58D7E" - - template_fields: Sequence[str] = DbtLocalBaseOperator.template_fields + ("full_refresh",) # type: ignore[operator] - - def __init__(self, full_refresh: bool = False, **kwargs: Any) -> None: - self.full_refresh = full_refresh - super().__init__(**kwargs) - self.base_cmd = ["seed"] + template_fields: Sequence[str] = DbtLocalBaseOperator.template_fields + DbtSeedMixin.template_fields # type: ignore[operator] - def add_cmd_flags(self) -> list[str]: - flags = [] - if self.full_refresh is True: - flags.append("--full-refresh") - return flags - - def execute(self, context: Context) -> None: - cmd_flags = self.add_cmd_flags() - self.build_and_run_cmd(context=context, cmd_flags=cmd_flags) - - -class DbtSnapshotLocalOperator(DbtLocalBaseOperator): +class DbtSnapshotLocalOperator(DbtSnapshotMixin, DbtLocalBaseOperator): """ Executes a dbt core snapshot command. - """ - ui_color = "#964B00" - - def __init__(self, **kwargs: Any) -> None: - super().__init__(**kwargs) - self.base_cmd = ["snapshot"] - -class DbtRunLocalOperator(DbtLocalBaseOperator): +class DbtRunLocalOperator(DbtRunMixin, DbtLocalBaseOperator): """ Executes a dbt core run command. """ - ui_color = "#7352BA" - ui_fgcolor = "#F4F2FC" - template_fields: Sequence[str] = DbtLocalBaseOperator.template_fields + ("full_refresh",) # type: ignore[operator] - - def __init__(self, full_refresh: bool = False, **kwargs: Any) -> None: - self.full_refresh = full_refresh - super().__init__(**kwargs) - self.base_cmd = ["run"] - - def add_cmd_flags(self) -> list[str]: - flags = [] - if self.full_refresh is True: - flags.append("--full-refresh") - return flags - - def execute(self, context: Context) -> None: - cmd_flags = self.add_cmd_flags() - self.build_and_run_cmd(context=context, cmd_flags=cmd_flags) + template_fields: Sequence[str] = DbtLocalBaseOperator.template_fields + DbtRunMixin.template_fields # type: ignore[operator] -class DbtTestLocalOperator(DbtLocalBaseOperator): +class DbtTestLocalOperator(DbtTestMixin, DbtLocalBaseOperator): """ Executes a dbt core test command. :param on_warning_callback: A callback function called on warnings with additional Context variables "test_names" and "test_results" of type `List`. Each index in "test_names" corresponds to the same index in "test_results". """ - ui_color = "#8194E0" - def __init__( self, on_warning_callback: Callable[..., Any] | None = None, **kwargs: Any, ) -> None: super().__init__(**kwargs) - self.base_cmd = ["test"] self.on_warning_callback = on_warning_callback def _handle_warnings(self, result: FullOutputSubprocessResult, context: Context) -> None: @@ -504,7 +458,7 @@ def execute(self, context: Context) -> None: self._handle_warnings(result, context) -class DbtRunOperationLocalOperator(DbtLocalBaseOperator): +class DbtRunOperationLocalOperator(DbtRunOperationMixin, DbtLocalBaseOperator): """ Executes a dbt core run-operation command. @@ -513,25 +467,7 @@ class DbtRunOperationLocalOperator(DbtLocalBaseOperator): selected macro. """ - ui_color = "#8194E0" - template_fields: Sequence[str] = ("args",) - - def __init__(self, macro_name: str, args: dict[str, Any] | None = None, **kwargs: Any) -> None: - self.macro_name = macro_name - self.args = args - super().__init__(**kwargs) - self.base_cmd = ["run-operation", macro_name] - - def add_cmd_flags(self) -> list[str]: - flags = [] - if self.args is not None: - flags.append("--args") - flags.append(yaml.dump(self.args)) - return flags - - def execute(self, context: Context) -> None: - cmd_flags = self.add_cmd_flags() - self.build_and_run_cmd(context=context, cmd_flags=cmd_flags) + template_fields: Sequence[str] = DbtLocalBaseOperator.template_fields + DbtRunOperationMixin.template_fields # type: ignore[operator] class DbtDocsLocalOperator(DbtLocalBaseOperator): @@ -541,13 +477,11 @@ class DbtDocsLocalOperator(DbtLocalBaseOperator): """ ui_color = "#8194E0" - required_files = ["index.html", "manifest.json", "graph.gpickle", "catalog.json"] + base_cmd = ["docs", "generate"] def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) - self.base_cmd = ["docs", "generate"] - self.check_static_flag() def check_static_flag(self) -> None: diff --git a/cosmos/operators/virtualenv.py b/cosmos/operators/virtualenv.py index f7f319f551..e628fc362b 100644 --- a/cosmos/operators/virtualenv.py +++ b/cosmos/operators/virtualenv.py @@ -106,7 +106,7 @@ class DbtLSVirtualenvOperator(DbtVirtualenvBaseOperator, DbtLSLocalOperator): """ -class DbtSeedVirtualenvOperator(DbtVirtualenvBaseOperator, DbtSeedLocalOperator): +class DbtSeedVirtualenvOperator(DbtVirtualenvBaseOperator, DbtSeedLocalOperator): # type: ignore[misc] """ Executes a dbt core seed command within a Python Virtual Environment, that is created before running the dbt command and deleted just after. @@ -120,7 +120,7 @@ class DbtSnapshotVirtualenvOperator(DbtVirtualenvBaseOperator, DbtSnapshotLocalO """ -class DbtRunVirtualenvOperator(DbtVirtualenvBaseOperator, DbtRunLocalOperator): +class DbtRunVirtualenvOperator(DbtVirtualenvBaseOperator, DbtRunLocalOperator): # type: ignore[misc] """ Executes a dbt core run command within a Python Virtual Environment, that is created before running the dbt command and deleted just after. @@ -134,7 +134,7 @@ class DbtTestVirtualenvOperator(DbtVirtualenvBaseOperator, DbtTestLocalOperator) """ -class DbtRunOperationVirtualenvOperator(DbtVirtualenvBaseOperator, DbtRunOperationLocalOperator): +class DbtRunOperationVirtualenvOperator(DbtVirtualenvBaseOperator, DbtRunOperationLocalOperator): # type: ignore[misc] """ Executes a dbt core run-operation command within a Python Virtual Environment, that is created before running the dbt command and deleted just after. diff --git a/tests/operators/test_base.py b/tests/operators/test_base.py new file mode 100644 index 0000000000..a46b51f7fe --- /dev/null +++ b/tests/operators/test_base.py @@ -0,0 +1,50 @@ +import pytest + +from cosmos.operators.base import ( + AbstractDbtBaseOperator, + DbtLSMixin, + DbtSeedMixin, + DbtRunOperationMixin, + DbtTestMixin, + DbtSnapshotMixin, + DbtRunMixin, +) + + +def test_dbt_base_operator_is_abstract(): + """Tests that the abstract base operator cannot be instantiated since the base_cmd is not defined.""" + expected_error = "Can't instantiate abstract class AbstractDbtBaseOperator with abstract methods? base_cmd" + with pytest.raises(TypeError, match=expected_error): + AbstractDbtBaseOperator() + + +@pytest.mark.parametrize( + "dbt_command, dbt_operator_class", + [ + ("test", DbtTestMixin), + ("snapshot", DbtSnapshotMixin), + ("ls", DbtLSMixin), + ("seed", DbtSeedMixin), + ("run", DbtRunMixin), + ], +) +def test_dbt_mixin_base_cmd(dbt_command, dbt_operator_class): + assert [dbt_command] == dbt_operator_class.base_cmd + + +@pytest.mark.parametrize("dbt_operator_class", [DbtSeedMixin, DbtRunMixin]) +@pytest.mark.parametrize("full_refresh, expected_flags", [(True, ["--full-refresh"]), (False, [])]) +def test_dbt_mixin_add_cmd_flags_full_refresh(full_refresh, expected_flags, dbt_operator_class): + dbt_mixin = dbt_operator_class(full_refresh=full_refresh) + flags = dbt_mixin.add_cmd_flags() + assert flags == expected_flags + + +@pytest.mark.parametrize("args, expected_flags", [(None, []), ({"arg1": "val1"}, ["--args", "arg1: val1\n"])]) +def test_dbt_mixin_add_cmd_flags_run_operator(args, expected_flags): + macro_name = "some_macro" + run_operation = DbtRunOperationMixin(macro_name=macro_name, args=args) + assert run_operation.base_cmd == ["run-operation", "some_macro"] + + flags = run_operation.add_cmd_flags() + assert flags == expected_flags diff --git a/tests/operators/test_docker.py b/tests/operators/test_docker.py index 234878e07a..520511b23d 100644 --- a/tests/operators/test_docker.py +++ b/tests/operators/test_docker.py @@ -13,8 +13,12 @@ ) +class ConcreteDbtDockerBaseOperator(DbtDockerBaseOperator): + base_cmd = ["cmd"] + + def test_dbt_docker_operator_add_global_flags() -> None: - dbt_base_operator = DbtDockerBaseOperator( + dbt_base_operator = ConcreteDbtDockerBaseOperator( conn_id="my_airflow_connection", task_id="my-task", image="my_image", @@ -38,7 +42,7 @@ def test_dbt_docker_operator_get_env(p_context_to_airflow_vars: MagicMock) -> No """ If an end user passes in a """ - dbt_base_operator = DbtDockerBaseOperator( + dbt_base_operator = ConcreteDbtDockerBaseOperator( conn_id="my_airflow_connection", task_id="my-task", image="my_image", diff --git a/tests/operators/test_kubernetes.py b/tests/operators/test_kubernetes.py index 585b1ab322..638cff1408 100644 --- a/tests/operators/test_kubernetes.py +++ b/tests/operators/test_kubernetes.py @@ -23,8 +23,12 @@ module_available = False +class ConcreteDbtKubernetesBaseOperator(DbtKubernetesBaseOperator): + base_cmd = ["cmd"] + + def test_dbt_kubernetes_operator_add_global_flags() -> None: - dbt_kube_operator = DbtKubernetesBaseOperator( + dbt_kube_operator = ConcreteDbtKubernetesBaseOperator( conn_id="my_airflow_connection", task_id="my-task", image="my_image", @@ -48,7 +52,7 @@ def test_dbt_kubernetes_operator_get_env(p_context_to_airflow_vars: MagicMock) - """ If an end user passes in a """ - dbt_kube_operator = DbtKubernetesBaseOperator( + dbt_kube_operator = ConcreteDbtKubernetesBaseOperator( conn_id="my_airflow_connection", task_id="my-task", image="my_image", diff --git a/tests/operators/test_local.py b/tests/operators/test_local.py index dd7d34a6d8..aa4cb741fa 100644 --- a/tests/operators/test_local.py +++ b/tests/operators/test_local.py @@ -68,8 +68,12 @@ def failing_test_dbt_project(tmp_path): tmp_dir.cleanup() +class ConcreteDbtLocalBaseOperator(DbtLocalBaseOperator): + base_cmd = ["cmd"] + + def test_dbt_base_operator_add_global_flags() -> None: - dbt_base_operator = DbtLocalBaseOperator( + dbt_base_operator = ConcreteDbtLocalBaseOperator( profile_config=profile_config, task_id="my-task", project_dir="my/dir", @@ -88,27 +92,25 @@ def test_dbt_base_operator_add_global_flags() -> None: def test_dbt_base_operator_add_user_supplied_flags() -> None: - dbt_base_operator = DbtLocalBaseOperator( + dbt_base_operator = ConcreteDbtLocalBaseOperator( profile_config=profile_config, task_id="my-task", project_dir="my/dir", - base_cmd=["run"], dbt_cmd_flags=["--full-refresh"], ) cmd, _ = dbt_base_operator.build_cmd( Context(execution_date=datetime(2023, 2, 15, 12, 30)), ) - assert cmd[-2] == "run" + assert cmd[-2] == "cmd" assert cmd[-1] == "--full-refresh" def test_dbt_base_operator_add_user_supplied_global_flags() -> None: - dbt_base_operator = DbtLocalBaseOperator( + dbt_base_operator = ConcreteDbtLocalBaseOperator( profile_config=profile_config, task_id="my-task", project_dir="my/dir", - base_cmd=["run"], dbt_cmd_global_flags=["--cache-selected-only"], ) @@ -116,7 +118,7 @@ def test_dbt_base_operator_add_user_supplied_global_flags() -> None: Context(execution_date=datetime(2023, 2, 15, 12, 30)), ) assert cmd[-2] == "--cache-selected-only" - assert cmd[-1] == "run" + assert cmd[-1] == "cmd" @pytest.mark.parametrize( @@ -124,11 +126,10 @@ def test_dbt_base_operator_add_user_supplied_global_flags() -> None: [None, "cautious", "buildable", "empty"], ) def test_dbt_base_operator_use_indirect_selection(indirect_selection_type) -> None: - dbt_base_operator = DbtLocalBaseOperator( + dbt_base_operator = ConcreteDbtLocalBaseOperator( profile_config=profile_config, task_id="my-task", project_dir="my/dir", - base_cmd=["run"], indirect_selection=indirect_selection_type, ) @@ -140,7 +141,7 @@ def test_dbt_base_operator_use_indirect_selection(indirect_selection_type) -> No assert cmd[-1] == indirect_selection_type else: assert cmd[0].endswith("dbt") - assert cmd[1] == "run" + assert cmd[1] == "cmd" @pytest.mark.parametrize( @@ -157,7 +158,7 @@ def test_dbt_base_operator_use_indirect_selection(indirect_selection_type) -> No ], ) def test_dbt_base_operator_exception_handling(skip_exception, exception_code_returned, expected_exception) -> None: - dbt_base_operator = DbtLocalBaseOperator( + dbt_base_operator = ConcreteDbtLocalBaseOperator( profile_config=profile_config, task_id="my-task", project_dir="my/dir", @@ -174,7 +175,7 @@ def test_dbt_base_operator_get_env(p_context_to_airflow_vars: MagicMock) -> None """ If an end user passes in a """ - dbt_base_operator = DbtLocalBaseOperator( + dbt_base_operator = ConcreteDbtLocalBaseOperator( profile_config=profile_config, task_id="my-task", project_dir="my/dir", @@ -292,7 +293,7 @@ class MockEvent: run = MockRun() job = MockJob() - dbt_base_operator = DbtLocalBaseOperator( + dbt_base_operator = ConcreteDbtLocalBaseOperator( profile_config=profile_config, task_id="my-task", project_dir="my/dir", @@ -307,7 +308,7 @@ class MockEvent: def test_run_operator_emits_events_without_openlineage_events_completes(caplog): - dbt_base_operator = DbtLocalBaseOperator( + dbt_base_operator = ConcreteDbtLocalBaseOperator( profile_config=profile_config, task_id="my-task", project_dir="my/dir", @@ -324,7 +325,7 @@ def test_run_operator_emits_events_without_openlineage_events_completes(caplog): def test_store_compiled_sql() -> None: - dbt_base_operator = DbtLocalBaseOperator( + dbt_base_operator = ConcreteDbtLocalBaseOperator( profile_config=profile_config, task_id="my-task", project_dir="my/dir", @@ -337,7 +338,7 @@ def test_store_compiled_sql() -> None: context=Context(execution_date=datetime(2023, 2, 15, 12, 30)), ) - dbt_base_operator = DbtLocalBaseOperator( + dbt_base_operator = ConcreteDbtLocalBaseOperator( profile_config=profile_config, task_id="my-task", project_dir="my/dir", @@ -399,7 +400,10 @@ def test_operator_execute_without_flags(mock_build_and_run_cmd, operator_class): **operator_class_kwargs.get(operator_class, {}), ) task.execute(context={}) - mock_build_and_run_cmd.assert_called_once_with(context={}) + if operator_class == DbtTestLocalOperator: + mock_build_and_run_cmd.assert_called_once_with(context={}) + else: + mock_build_and_run_cmd.assert_called_once_with(context={}, cmd_flags=[]) @patch("cosmos.operators.local.DbtLocalArtifactProcessor") @@ -407,7 +411,7 @@ def test_calculate_openlineage_events_completes_openlineage_errors(mock_processo instance = mock_processor.return_value instance.parse = MagicMock(side_effect=KeyError) caplog.set_level(logging.DEBUG) - dbt_base_operator = DbtLocalBaseOperator( + dbt_base_operator = ConcreteDbtLocalBaseOperator( profile_config=profile_config, task_id="my-task", project_dir=DBT_PROJ_DIR, diff --git a/tests/operators/test_virtualenv.py b/tests/operators/test_virtualenv.py index 13dba8f942..86796308b1 100644 --- a/tests/operators/test_virtualenv.py +++ b/tests/operators/test_virtualenv.py @@ -18,6 +18,10 @@ ) +class ConcreteDbtVirtualenvBaseOperator(DbtVirtualenvBaseOperator): + base_cmd = ["cmd"] + + @patch("airflow.utils.python_virtualenv.execute_in_subprocess") @patch("cosmos.operators.virtualenv.DbtLocalBaseOperator.calculate_openlineage_events_completes") @patch("cosmos.operators.virtualenv.DbtLocalBaseOperator.store_compiled_sql") @@ -41,7 +45,7 @@ def test_run_command( password="fake_password", schema="fake_schema", ) - venv_operator = DbtVirtualenvBaseOperator( + venv_operator = ConcreteDbtVirtualenvBaseOperator( profile_config=profile_config, task_id="fake_task", install_deps=True, From fb93ceda1ff1b9a2776e3cc1be7c83945e176a4f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 9 Jan 2024 11:19:30 +0000 Subject: [PATCH 068/223] =?UTF-8?q?=E2=AC=86=20[pre-commit.ci]=20pre-commi?= =?UTF-8?q?t=20autoupdate=20(#789)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.1.9 → v0.1.11](https://github.com/astral-sh/ruff-pre-commit/compare/v0.1.9...v0.1.11) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3585f4e33c..9c09060fe8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -54,7 +54,7 @@ repos: - --py37-plus - --keep-runtime-typing - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.9 + rev: v0.1.11 hooks: - id: ruff args: From 16519c20efc792b2f4544397d6289a19669c9d78 Mon Sep 17 00:00:00 2001 From: Alex Seeholzer Date: Tue, 9 Jan 2024 12:20:43 +0100 Subject: [PATCH 069/223] fix docs and path for ls file (#773) (#788) Fix docs and support str argument for `dbt_ls_file` Closes: #773 Co-authored-by: Alex Seeholzer --- cosmos/config.py | 5 ++++- docs/configuration/parsing-methods.rst | 2 +- tests/test_config.py | 5 +++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/cosmos/config.py b/cosmos/config.py index 46e3f19152..dc33c0eba5 100644 --- a/cosmos/config.py +++ b/cosmos/config.py @@ -45,7 +45,8 @@ class RenderConfig: :param node_converters: a dictionary mapping a ``DbtResourceType`` into a callable. Users can control how to render dbt nodes in Airflow. Only supported when using ``load_method=LoadMode.DBT_MANIFEST`` or ``LoadMode.DBT_LS``. :param dbt_executable_path: The path to the dbt executable for dag generation. Defaults to dbt if available on the path. :param env_vars: (Deprecated since Cosmos 1.3 use ProjectConfig.env_vars) A dictionary of environment variables for rendering. Only supported when using ``LoadMode.DBT_LS``. - :param dbt_project_path Configures the DBT project location accessible on the airflow controller for DAG rendering. Mutually Exclusive with ProjectConfig.dbt_project_path. Required when using ``load_method=LoadMode.DBT_LS`` or ``load_method=LoadMode.CUSTOM``. + :param dbt_project_path: Configures the DBT project location accessible on the airflow controller for DAG rendering. Mutually Exclusive with ProjectConfig.dbt_project_path. Required when using ``load_method=LoadMode.DBT_LS`` or ``load_method=LoadMode.CUSTOM``. + :param dbt_ls_path: Configures the location of an output of ``dbt ls``. Required when using ``load_method=LoadMode.DBT_LS_FILE``. """ emit_datasets: bool = True @@ -70,6 +71,8 @@ def __post_init__(self, dbt_project_path: str | Path | None) -> None: DeprecationWarning, ) self.project_path = Path(dbt_project_path) if dbt_project_path else None + # allows us to initiate this attribute from Path objects and str + self.dbt_ls_path = Path(self.dbt_ls_path) if self.dbt_ls_path else None def validate_dbt_command(self, fallback_cmd: str | Path = "") -> None: """ diff --git a/docs/configuration/parsing-methods.rst b/docs/configuration/parsing-methods.rst index ab31c00d4f..ddea0606ce 100644 --- a/docs/configuration/parsing-methods.rst +++ b/docs/configuration/parsing-methods.rst @@ -93,7 +93,7 @@ To use this: DbtDag( render_config=RenderConfig( - load_method=LoadMode.DBT_MANIFEST, dbt_ls_path="/path/to/dbt_ls_file.txt" + load_method=LoadMode.DBT_LS_FILE, dbt_ls_path="/path/to/dbt_ls_file.txt" ) # ..., ) diff --git a/tests/test_config.py b/tests/test_config.py index 6fa53b10ca..795fcffb69 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -181,6 +181,11 @@ def test_is_dbt_ls_file_available_is_true(): assert render_config.is_dbt_ls_file_available() +def test_is_dbt_ls_file_available_is_true_for_str_path(): + render_config = RenderConfig(dbt_ls_path=str(DBT_PROJECTS_ROOT_DIR / "sample_dbt_ls.txt")) + assert render_config.is_dbt_ls_file_available() + + def test_is_dbt_ls_file_available_is_false(): render_config = RenderConfig(dbt_ls_path=None) assert not render_config.is_dbt_ls_file_available() From 0ece2eb3e64137f887ff73d7c9b6b47461c741b8 Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Tue, 9 Jan 2024 20:20:33 +0000 Subject: [PATCH 070/223] Fix Cosmos stacktrace to remove unecessary K8s errors (#790) Closes: #729 Even users who were using cosmos without Kubernetes, would see the following errors when running Airflow: ``` Traceback (most recent call last): File C:Usersu236618AppDataLocalPackagesPythonSoftwareFoundation.Python.3.11_qbz5n2kfra8p0LocalCachelocal-packagesPython311site-packagescosmosoperatorskubernetes.py, line 23, in from airflow.providers.cncf.kubernetes.backcompat.backwards_compat_converters import ( ModuleNotFoundError: No module named 'airflow.providers.cncf' During handling of the above exception, another exception occurred: Traceback (most recent call last): File C:Usersu236618AppDataLocalPackagesPythonSoftwareFoundation.Python.3.11_qbz5n2kfra8p0LocalCachelocal-packagesPython311site-packagescosmosoperatorskubernetes.py, line 31, in from airflow.providers.cncf.kubernetes.operators.kubernetes_pod import KubernetesPodOperator ModuleNotFoundError: No module named 'airflow.providers.cncf' [2023-11-30 12:57:36,961] {kubernetes.py:33} ERROR - No module named 'airflow.providers.cncf' Traceback (most recent call last): File C:Usersu236618AppDataLocalPackagesPythonSoftwareFoundation.Python.3.11_qbz5n2kfra8p0LocalCachelocal-packagesPython311site-packagescosmosoperatorskubernetes.py, line 23, in from airflow.providers.cncf.kubernetes.backcompat.backwards_compat_converters import ( ModuleNotFoundError: No module named 'airflow.providers.cncf' During handling of the above exception, another exception occurred: Traceback (most recent call last): File C:Usersu236618AppDataLocalPackagesPythonSoftwareFoundation.Python.3.11_qbz5n2kfra8p0LocalCachelocal-packagesPython311site-packagescosmosoperatorskubernetes.py, line 31, in from airflow.providers.cncf.kubernetes.operators.kubernetes_pod import KubernetesPodOperator ModuleNotFoundError: No module named 'airflow.providers.cncf' [2023-11-30 12:57:36,961] {kubernetes.py:33} ERROR - (astronomer-cosmos) - No module named 'airflow.providers.cncf' Traceback (most recent call last): File C:Usersu236618AppDataLocalPackagesPythonSoftwareFoundation.Python.3.11_qbz5n2kfra8p0LocalCachelocal-packagesPython311site-packagescosmosoperatorskubernetes.py, line 23, in from airflow.providers.cncf.kubernetes.backcompat.backwards_compat_converters import ( ModuleNotFoundError: No module named 'airflow.providers.cncf' During handling of the above exception, another exception occurred: Traceback (most recent call last): File C:Usersu236618AppDataLocalPackagesPythonSoftwareFoundation.Python.3.11_qbz5n2kfra8p0LocalCachelocal-packagesPython311site-packagescosmosoperatorskubernetes.py, line 31, in from airflow.providers.cncf.kubernetes.operators.kubernetes_pod import KubernetesPodOperator ModuleNotFoundError: No module named 'airflow.providers.cncf' ``` Users who use Kubernetes or users who have enabled the DEBUG logging mode will still be able to see this information: https://github.com/astronomer/astronomer-cosmos/blob/main/cosmos/__init__.py#L63 --- cosmos/operators/kubernetes.py | 1 - 1 file changed, 1 deletion(-) diff --git a/cosmos/operators/kubernetes.py b/cosmos/operators/kubernetes.py index 353d9c5346..eb26f936e7 100644 --- a/cosmos/operators/kubernetes.py +++ b/cosmos/operators/kubernetes.py @@ -37,7 +37,6 @@ # apache-airflow-providers-cncf-kubernetes < 7.4.0 from airflow.providers.cncf.kubernetes.operators.kubernetes_pod import KubernetesPodOperator except ImportError as error: - logger.exception(error) raise ImportError( "Could not import KubernetesPodOperator. Ensure you've installed the Kubernetes provider " "separately or with with `pip install astronomer-cosmos[...,kubernetes]`." From fc228b366d5c8b304219af2d3d2bebcde47efacf Mon Sep 17 00:00:00 2001 From: Harato Daisuke <129731743+Benjamin0313@users.noreply.github.com> Date: Thu, 11 Jan 2024 02:38:30 +0900 Subject: [PATCH 071/223] FIX: add missing imports for mwaa getting started docs (#792) ## Description To complete this tutorial, I added two import sentence - import os - from datetime import datetime ## Related Issue(s) Nothing ## Breaking Change? Nothing ## Checklist - [x] I have made corresponding changes to the documentation (if required) - [x] I have added tests that prove my fix is effective or that my feature works --- docs/getting_started/mwaa.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/getting_started/mwaa.rst b/docs/getting_started/mwaa.rst index 726cf0cb80..7c42b344dd 100644 --- a/docs/getting_started/mwaa.rst +++ b/docs/getting_started/mwaa.rst @@ -87,6 +87,8 @@ In your ``my_cosmos_dag.py`` file, import the ``DbtDag`` class from Cosmos and c .. code-block:: python + import os + from datetime import datetime from cosmos import DbtDag, ProjectConfig, ProfileConfig, ExecutionConfig from cosmos.profiles import PostgresUserPasswordProfileMapping from cosmos.constants import ExecutionMode From 140efb339a499eec557b7231d82209537326d20c Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Thu, 11 Jan 2024 00:11:57 +0000 Subject: [PATCH 072/223] Release 1.3.1 (#793) **Bug fixes** * Fix disable event tracking throwing error by @jbandoro in #784 * Fix support for string path for `LoadMode.DBT_LS_FILE` and docs by @flinz in #788 * Remove stack trace to disable unnecessary K8s error by @tatiana in #790 **Others** * Update examples to use the astro-runtime 10.0.0 by @RNHTTR in #777 * Docs: add missing imports for mwaa getting started by @Benjamin0313 in #792 * Refactor common executor constructors with test coverage by @jbandoro in #774 * pre-commit updates in #789 --- CHANGELOG.rst | 17 +++++++++++++++++ cosmos/__init__.py | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 23fa05fe16..ac2c4b832d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,23 @@ Changelog ========= +1.3.1 (2023-01-10) +------------------ + +Bug fixes + +* Fix disable event tracking throwing error by @jbandoro in #784 +* Fix support for string path for ``LoadMode.DBT_LS_FILE`` and docs by @flinz in #788 +* Remove stack trace to disable unnecessary K8s error by @tatiana in #790 + +Others + +* Update examples to use the astro-runtime 10.0.0 by @RNHTTR in #777 +* Docs: add missing imports for mwaa getting started by @Benjamin0313 in #792 +* Refactor common executor constructors with test coverage by @jbandoro in #774 +* pre-commit updates in #789 + + 1.3.0 (2023-01-04) ------------------ diff --git a/cosmos/__init__.py b/cosmos/__init__.py index 3910f84bb2..cdd7e5579b 100644 --- a/cosmos/__init__.py +++ b/cosmos/__init__.py @@ -5,7 +5,7 @@ Contains dags, task groups, and operators. """ -__version__ = "1.3.0" +__version__ = "1.3.1" from cosmos.airflow.dag import DbtDag From 0cd7b7a3d1c1493dfe0fa245a97bdb53df52254a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 16 Jan 2024 10:04:14 -0800 Subject: [PATCH 073/223] =?UTF-8?q?=E2=AC=86=20[pre-commit.ci]=20pre-commi?= =?UTF-8?q?t=20autoupdate=20(#799)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.1.11 → v0.1.13](https://github.com/astral-sh/ruff-pre-commit/compare/v0.1.11...v0.1.13) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9c09060fe8..08b189f25b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -54,7 +54,7 @@ repos: - --py37-plus - --keep-runtime-typing - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.11 + rev: v0.1.13 hooks: - id: ruff args: From 34dc3cd4d712788e3224e4a6c9144bfd0fecafa8 Mon Sep 17 00:00:00 2001 From: ykuc <140019825+ykuc@users.noreply.github.com> Date: Thu, 18 Jan 2024 10:55:12 +0100 Subject: [PATCH 074/223] Add dbt profile config variables to mapped profile (#794) When using `ProfileMapping` classes, allow users to define values of ``profiles.yml`` that are not specific to a particular data platform. For more information, https://docs.getdbt.com/docs/core/connect-data-platform/profiles.yml. Example of usage: ``` from cosmos.profiles import SnowflakeUserPasswordProfileMapping, DbtProfileConfigVars profile_config = ProfileConfig( profile_name="my_profile_name", target_name="my_target_name", profile_mapping=SnowflakeUserPasswordProfileMapping( conn_id="my_snowflake_conn_id", profile_args={ "database": "my_snowflake_database", "schema": "my_snowflake_schema", }, dbt_config_vars=DbtProfileConfigVars( send_anonymous_usage_stats=False, partial_parse=True, use_experimental_parse=True, static_parser=True, printer_width=120, write_json=True, warn_error=True, warn_error_options={"include": "all"}, log_format='text', debug=True, version_check=True, ), ), ) ``` --- cosmos/profiles/__init__.py | 3 +- cosmos/profiles/base.py | 65 ++++++++++++++-- dev/dags/cosmos_manifest_example.py | 3 +- docs/templates/index.rst.jinja2 | 40 ++++++++++ tests/profiles/test_base_profile.py | 115 +++++++++++++++++++++++++++- 5 files changed, 217 insertions(+), 9 deletions(-) diff --git a/cosmos/profiles/__init__.py b/cosmos/profiles/__init__.py index 1f39a91a0f..446207f353 100644 --- a/cosmos/profiles/__init__.py +++ b/cosmos/profiles/__init__.py @@ -6,7 +6,7 @@ from .athena import AthenaAccessKeyProfileMapping -from .base import BaseProfileMapping +from .base import BaseProfileMapping, DbtProfileConfigVars from .bigquery.service_account_file import GoogleCloudServiceAccountFileProfileMapping from .bigquery.service_account_keyfile_dict import GoogleCloudServiceAccountDictProfileMapping from .bigquery.oauth import GoogleCloudOauthProfileMapping @@ -70,6 +70,7 @@ def get_automatic_profile_mapping( "GoogleCloudServiceAccountDictProfileMapping", "GoogleCloudOauthProfileMapping", "DatabricksTokenProfileMapping", + "DbtProfileConfigVars", "PostgresUserPasswordProfileMapping", "RedshiftUserPasswordProfileMapping", "SnowflakeUserPasswordProfileMapping", diff --git a/cosmos/profiles/base.py b/cosmos/profiles/base.py index 2b2a5c7e2a..c583c8edb0 100644 --- a/cosmos/profiles/base.py +++ b/cosmos/profiles/base.py @@ -5,12 +5,12 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import Any - -from typing import TYPE_CHECKING -import yaml +from typing import Any, Optional, Literal, Dict, TYPE_CHECKING +import warnings from airflow.hooks.base import BaseHook +from pydantic import dataclasses +import yaml from cosmos.exceptions import CosmosValueError from cosmos.log import get_logger @@ -24,6 +24,31 @@ logger = get_logger(__name__) +@dataclasses.dataclass +class DbtProfileConfigVars: + send_anonymous_usage_stats: Optional[bool] = False + partial_parse: Optional[bool] = None + use_experimental_parser: Optional[bool] = None + static_parser: Optional[bool] = None + printer_width: Optional[bool] = None + write_json: Optional[bool] = None + warn_error: Optional[bool] = None + warn_error_options: Optional[Dict[Literal["include", "exclude"], Any]] = None + log_format: Optional[Literal["text", "json", "default"]] = None + debug: Optional[bool] = None + version_check: Optional[bool] = None + + def as_dict(self) -> dict[str, Any] | None: + result = { + field.name: getattr(self, field.name) + for field in self.__dataclass_fields__.values() + if getattr(self, field.name) is not None + } + if result != {}: + return result + return None + + class BaseProfileMapping(ABC): """ A base class that other profile mappings should inherit from to ensure consistency. @@ -41,11 +66,19 @@ class BaseProfileMapping(ABC): _conn: Connection | None = None - def __init__(self, conn_id: str, profile_args: dict[str, Any] | None = None, disable_event_tracking: bool = False): + def __init__( + self, + conn_id: str, + profile_args: dict[str, Any] | None = None, + disable_event_tracking: bool | None = None, + dbt_config_vars: DbtProfileConfigVars | None = None, + ): self.conn_id = conn_id self.profile_args = profile_args or {} self._validate_profile_args() self.disable_event_tracking = disable_event_tracking + self.dbt_config_vars = dbt_config_vars + self._validate_disable_event_tracking() def _validate_profile_args(self) -> None: """ @@ -66,6 +99,25 @@ class variables when creating the profile. ) ) + def _validate_disable_event_tracking(self) -> None: + """ + Check if disable_event_tracking is set and warn that it is deprecated. + """ + if self.disable_event_tracking: + warnings.warn( + "Disabling dbt event tracking is deprecated since Cosmos 1.3 and will be removed in Cosmos 2.0. " + "Use dbt_config_vars=DbtProfileConfigVars(send_anonymous_usage_stats=False) instead.", + DeprecationWarning, + ) + if ( + isinstance(self.dbt_config_vars, DbtProfileConfigVars) + and self.dbt_config_vars.send_anonymous_usage_stats is not None + ): + raise CosmosValueError( + "Cannot set both disable_event_tracking and " + "dbt_config_vars=DbtProfileConfigVars(send_anonymous_usage_stats ..." + ) + @property def conn(self) -> Connection: "Returns the Airflow connection." @@ -180,6 +232,9 @@ def get_profile_file_contents( } } + if self.dbt_config_vars: + profile_contents["config"] = self.dbt_config_vars.as_dict() + if self.disable_event_tracking: profile_contents["config"] = {"send_anonymous_usage_stats": False} diff --git a/dev/dags/cosmos_manifest_example.py b/dev/dags/cosmos_manifest_example.py index c94ea41a2e..7b7f9d4aaa 100644 --- a/dev/dags/cosmos_manifest_example.py +++ b/dev/dags/cosmos_manifest_example.py @@ -7,7 +7,7 @@ from pathlib import Path from cosmos import DbtDag, ProjectConfig, ProfileConfig, RenderConfig, LoadMode, ExecutionConfig -from cosmos.profiles import PostgresUserPasswordProfileMapping +from cosmos.profiles import PostgresUserPasswordProfileMapping, DbtProfileConfigVars DEFAULT_DBT_ROOT_PATH = Path(__file__).parent / "dbt" DBT_ROOT_PATH = Path(os.getenv("DBT_ROOT_PATH", DEFAULT_DBT_ROOT_PATH)) @@ -18,6 +18,7 @@ profile_mapping=PostgresUserPasswordProfileMapping( conn_id="airflow_db", profile_args={"schema": "public"}, + dbt_config_vars=DbtProfileConfigVars(send_anonymous_usage_stats=True), ), ) diff --git a/docs/templates/index.rst.jinja2 b/docs/templates/index.rst.jinja2 index d5c3069111..802b075ed9 100644 --- a/docs/templates/index.rst.jinja2 +++ b/docs/templates/index.rst.jinja2 @@ -85,6 +85,9 @@ you specify in ``ProfileConfig``. Disabling dbt event tracking -------------------------------- + +.. note: + Deprecated in v.1.4 and will be removed in v2.0.0. Use dbt_config_vars=DbtProfileConfigVars(send_anonymous_usage_stats=False) instead. .. versionadded:: 1.3 By default `dbt will track events `_ by sending anonymous usage data @@ -112,6 +115,43 @@ the example below: dag = DbtDag(profile_config=profile_config, ...) +Dbt profile config variables +-------------------------------- +.. versionadded:: 1.4.0 + +The parts of ``profiles.yml``, which aren't specific to a particular data platform `dbt docs `_ + +.. code-block:: python + + from cosmos.profiles import SnowflakeUserPasswordProfileMapping, DbtProfileConfigVars + + profile_config = ProfileConfig( + profile_name="my_profile_name", + target_name="my_target_name", + profile_mapping=SnowflakeUserPasswordProfileMapping( + conn_id="my_snowflake_conn_id", + profile_args={ + "database": "my_snowflake_database", + "schema": "my_snowflake_schema", + }, + dbt_config_vars=DbtProfileConfigVars( + send_anonymous_usage_stats=False, + partial_parse=True, + use_experimental_parse=True, + static_parser=True, + printer_width=120, + write_json=True, + warn_error=True, + warn_error_options={"include": "all"}, + log_format='text', + debug=True, + version_check=True, + ), + ), + ) + + dag = DbtDag(profile_config=profile_config, ...) + diff --git a/tests/profiles/test_base_profile.py b/tests/profiles/test_base_profile.py index 98c4004e71..b80912bcd8 100644 --- a/tests/profiles/test_base_profile.py +++ b/tests/profiles/test_base_profile.py @@ -1,9 +1,11 @@ from __future__ import annotations +from typing import Any import pytest import yaml +from pydantic.error_wrappers import ValidationError -from cosmos.profiles.base import BaseProfileMapping +from cosmos.profiles.base import BaseProfileMapping, DbtProfileConfigVars from cosmos.exceptions import CosmosValueError @@ -37,7 +39,7 @@ def test_validate_profile_args(profile_arg: str): @pytest.mark.parametrize("disable_event_tracking", [True, False]) -def test_disable_event_tracking(disable_event_tracking: str): +def test_disable_event_tracking(disable_event_tracking: bool): """ Tests the config block in the profile is set correctly if disable_event_tracking is set. """ @@ -50,3 +52,112 @@ def test_disable_event_tracking(disable_event_tracking: str): assert ("config" in profile_contents) == disable_event_tracking if disable_event_tracking: assert profile_contents["config"]["send_anonymous_usage_stats"] is False + + +def test_disable_event_tracking_and_send_anonymous_usage_stats(): + expected_cosmos_error = ( + "Cannot set both disable_event_tracking and " + "dbt_config_vars=DbtProfileConfigVars(send_anonymous_usage_stats ..." + ) + + with pytest.raises(CosmosValueError) as err_info: + TestProfileMapping( + conn_id="fake_conn_id", + dbt_config_vars=DbtProfileConfigVars(send_anonymous_usage_stats=False), + disable_event_tracking=True, + ) + assert err_info.value.args[0] == expected_cosmos_error + + +def test_dbt_profile_config_vars_none(): + """ + Tests the DbtProfileConfigVars return None. + """ + dbt_config_vars = DbtProfileConfigVars( + send_anonymous_usage_stats=None, + partial_parse=None, + use_experimental_parser=None, + static_parser=None, + printer_width=None, + write_json=None, + warn_error=None, + warn_error_options=None, + log_format=None, + debug=None, + version_check=None, + ) + assert dbt_config_vars.as_dict() is None + + +@pytest.mark.parametrize("config", [True, False]) +def test_dbt_config_vars_config(config: bool): + """ + Tests the config block in the profile is set correctly. + """ + + dbt_config_vars = None + if config: + dbt_config_vars = DbtProfileConfigVars(debug=False) + + test_profile = TestProfileMapping( + conn_id="fake_conn_id", + dbt_config_vars=dbt_config_vars, + ) + profile_contents = yaml.safe_load(test_profile.get_profile_file_contents(profile_name="fake-profile-name")) + + assert ("config" in profile_contents) == config + + +@pytest.mark.parametrize("dbt_config_var,dbt_config_value", [("debug", True), ("debug", False)]) +def test_validate_dbt_config_vars(dbt_config_var: str, dbt_config_value: Any): + """ + Tests the config block in the profile is set correctly. + """ + dbt_config_vars = {dbt_config_var: dbt_config_value} + test_profile = TestProfileMapping( + conn_id="fake_conn_id", + dbt_config_vars=DbtProfileConfigVars(**dbt_config_vars), + ) + profile_contents = yaml.safe_load(test_profile.get_profile_file_contents(profile_name="fake-profile-name")) + + assert "config" in profile_contents + assert profile_contents["config"][dbt_config_var] == dbt_config_value + + +@pytest.mark.parametrize( + "dbt_config_var,dbt_config_value", + [("send_anonymous_usage_stats", 2), ("send_anonymous_usage_stats", "aaa")], +) +def test_profile_config_validate_dbt_config_vars_check_unexpected_types(dbt_config_var: str, dbt_config_value: Any): + dbt_config_vars = {dbt_config_var: dbt_config_value} + + with pytest.raises(ValidationError): + TestProfileMapping( + conn_id="fake_conn_id", + dbt_config_vars=DbtProfileConfigVars(**dbt_config_vars), + ) + + +@pytest.mark.parametrize("dbt_config_var,dbt_config_value", [("send_anonymous_usage_stats", True)]) +def test_profile_config_validate_dbt_config_vars_check_expected_types(dbt_config_var: str, dbt_config_value: Any): + dbt_config_vars = {dbt_config_var: dbt_config_value} + + profile_config = TestProfileMapping( + conn_id="fake_conn_id", + dbt_config_vars=DbtProfileConfigVars(**dbt_config_vars), + ) + assert profile_config.dbt_config_vars.as_dict() == dbt_config_vars + + +@pytest.mark.parametrize( + "dbt_config_var,dbt_config_value", + [("log_format", "text2")], +) +def test_profile_config_validate_dbt_config_vars_check_values(dbt_config_var: str, dbt_config_value: Any): + dbt_config_vars = {dbt_config_var: dbt_config_value} + + with pytest.raises(ValidationError): + TestProfileMapping( + conn_id="fake_conn_id", + dbt_config_vars=DbtProfileConfigVars(**dbt_config_vars), + ) From 5e54a5e63c4ae7d401959aefe17c8daf2d9af32d Mon Sep 17 00:00:00 2001 From: Justin Bandoro <79104794+jbandoro@users.noreply.github.com> Date: Thu, 18 Jan 2024 01:56:54 -0800 Subject: [PATCH 075/223] Fix: ensure `DbtGraph.update_node_dependency` is called for all load methods (#803) This resolves #798 where when using `LoadMode.DBT_LS_FILE`, the `DbtGraph.update_node_dependency` was not called resulting in filtered nodes not having `DbtNode.has_test` set as expected. Closes: #798 --- cosmos/dbt/graph.py | 20 +++++--------------- tests/dbt/test_graph.py | 6 ++++-- 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/cosmos/dbt/graph.py b/cosmos/dbt/graph.py index db8d6d2437..6d941e2786 100644 --- a/cosmos/dbt/graph.py +++ b/cosmos/dbt/graph.py @@ -211,6 +211,11 @@ def load( else: load_method[method]() + self.update_node_dependency() + + logger.info("Total nodes: %i", len(self.nodes)) + logger.info("Total filtered nodes: %i", len(self.nodes)) + def run_dbt_ls( self, dbt_cmd: str, project_path: Path, tmp_dir: Path, env_vars: dict[str, str] ) -> dict[str, DbtNode]: @@ -313,11 +318,6 @@ def load_via_dbt_ls(self) -> None: self.nodes = nodes self.filtered_nodes = nodes - self.update_node_dependency() - - logger.info("Total nodes: %i", len(self.nodes)) - logger.info("Total filtered nodes: %i", len(self.nodes)) - def load_via_dbt_ls_file(self) -> None: """ This is between dbt ls and full manifest. It allows to use the output (needs to be json output) of the dbt ls as a @@ -401,11 +401,6 @@ def load_via_custom_parser(self) -> None: exclude=self.render_config.exclude, ) - self.update_node_dependency() - - logger.info("Total nodes: %i", len(self.nodes)) - logger.info("Total filtered nodes: %i", len(self.nodes)) - def load_from_dbt_manifest(self) -> None: """ This approach accurately loads `dbt` projects using the `manifest.yml` file. @@ -458,11 +453,6 @@ def load_from_dbt_manifest(self) -> None: exclude=self.render_config.exclude, ) - self.update_node_dependency() - - logger.info("Total nodes: %i", len(self.nodes)) - logger.info("Total filtered nodes: %i", len(self.nodes)) - def update_node_dependency(self) -> None: """ This will update the property `has_text` if node has `dbt` test diff --git a/tests/dbt/test_graph.py b/tests/dbt/test_graph.py index 7e941cb490..cdca57b7eb 100644 --- a/tests/dbt/test_graph.py +++ b/tests/dbt/test_graph.py @@ -258,6 +258,7 @@ def test_load_manifest_with_manifest(mock_load_from_dbt_manifest): (ExecutionMode.LOCAL, LoadMode.CUSTOM, "mock_load_via_custom_parser"), ], ) +@patch("cosmos.dbt.graph.DbtGraph.update_node_dependency") @patch("cosmos.dbt.graph.DbtGraph.load_via_custom_parser", return_value=None) @patch("cosmos.dbt.graph.DbtGraph.load_via_dbt_ls", return_value=None) @patch("cosmos.dbt.graph.DbtGraph.load_from_dbt_manifest", return_value=None) @@ -267,6 +268,7 @@ def test_load( mock_load_via_dbt_ls_file, mock_load_via_dbt_ls, mock_load_via_custom_parser, + mock_update_node_dependency, exec_mode, method, expected_function, @@ -282,6 +284,7 @@ def test_load( dbt_graph.load(method=method, execution_mode=exec_mode) load_function = locals()[expected_function] assert load_function.called + assert mock_update_node_dependency.called @pytest.mark.integration @@ -675,8 +678,7 @@ def test_tag_selected_node_test_exist(): profile_config=profile_config, render_config=render_config, ) - dbt_graph.load_from_dbt_manifest() - + dbt_graph.load() assert len(dbt_graph.filtered_nodes) > 0 for _, node in dbt_graph.filtered_nodes.items(): From d7985962d70d57af6b3571c8704ce81ff1446117 Mon Sep 17 00:00:00 2001 From: Dylan Harper Date: Thu, 18 Jan 2024 02:04:35 -0800 Subject: [PATCH 076/223] Add dbt build operators (#795) Add operators for `dbt build` command. [Implement DbtBuildOperator in Astronomer Cosmos](https://github.com/astronomer/astronomer-cosmos/issues/720) --- cosmos/__init__.py | 4 ++++ cosmos/operators/__init__.py | 2 ++ cosmos/operators/base.py | 7 +++++++ cosmos/operators/docker.py | 7 +++++++ cosmos/operators/kubernetes.py | 7 +++++++ cosmos/operators/local.py | 7 +++++++ cosmos/operators/virtualenv.py | 8 ++++++++ tests/operators/test_base.py | 2 ++ tests/operators/test_docker.py | 2 ++ tests/operators/test_kubernetes.py | 2 ++ tests/operators/test_local.py | 2 ++ 11 files changed, 50 insertions(+) diff --git a/cosmos/__init__.py b/cosmos/__init__.py index cdd7e5579b..7d6f634fa8 100644 --- a/cosmos/__init__.py +++ b/cosmos/__init__.py @@ -20,6 +20,7 @@ from cosmos.log import get_logger from cosmos.operators.lazy_load import MissingPackage from cosmos.operators.local import ( + DbtBuildLocalOperator, DbtDepsLocalOperator, DbtLSLocalOperator, DbtRunLocalOperator, @@ -97,6 +98,7 @@ "DbtRunLocalOperator", "DbtSeedLocalOperator", "DbtTestLocalOperator", + "DbtBuildLocalOperator", "DbtDepsLocalOperator", "DbtSnapshotLocalOperator", "DbtDag", @@ -106,12 +108,14 @@ "DbtRunDockerOperator", "DbtSeedDockerOperator", "DbtTestDockerOperator", + "DbtBuildDockerOperator", "DbtSnapshotDockerOperator", "DbtLSKubernetesOperator", "DbtRunOperationKubernetesOperator", "DbtRunKubernetesOperator", "DbtSeedKubernetesOperator", "DbtTestKubernetesOperator", + "DbtBuildKubernetesOperator", "DbtSnapshotKubernetesOperator", "ExecutionMode", "LoadMode", diff --git a/cosmos/operators/__init__.py b/cosmos/operators/__init__.py index b7e36abff5..92f53fa083 100644 --- a/cosmos/operators/__init__.py +++ b/cosmos/operators/__init__.py @@ -1,3 +1,4 @@ +from .local import DbtBuildLocalOperator as DbtBuildOperator from .local import DbtDepsLocalOperator as DbtDepsOperator from .local import DbtDocsAzureStorageLocalOperator as DbtDocsAzureStorageOperator from .local import DbtDocsLocalOperator as DbtDocsOperator @@ -16,6 +17,7 @@ "DbtSnapshotOperator", "DbtRunOperator", "DbtTestOperator", + "DbtBuildOperator", "DbtRunOperationOperator", "DbtDepsOperator", "DbtDocsOperator", diff --git a/cosmos/operators/base.py b/cosmos/operators/base.py index c46b95c591..cb1076ce98 100644 --- a/cosmos/operators/base.py +++ b/cosmos/operators/base.py @@ -239,6 +239,13 @@ def build_cmd( return dbt_cmd, env +class DbtBuildMixin: + """Mixin for dbt build command.""" + + base_cmd = ["build"] + ui_color = "#8194E0" + + class DbtLSMixin: """ Executes a dbt core ls command. diff --git a/cosmos/operators/docker.py b/cosmos/operators/docker.py index dfe6b955d7..648c6c6607 100644 --- a/cosmos/operators/docker.py +++ b/cosmos/operators/docker.py @@ -7,6 +7,7 @@ from cosmos.log import get_logger from cosmos.operators.base import ( AbstractDbtBaseOperator, + DbtBuildMixin, DbtRunMixin, DbtSeedMixin, DbtSnapshotMixin, @@ -66,6 +67,12 @@ def execute(self, context: Context) -> None: self.build_and_run_cmd(context=context) +class DbtBuildDockerOperator(DbtBuildMixin, DbtDockerBaseOperator): + """ + Executes a dbt core build command. + """ + + class DbtLSDockerOperator(DbtLSMixin, DbtDockerBaseOperator): """ Executes a dbt core ls command. diff --git a/cosmos/operators/kubernetes.py b/cosmos/operators/kubernetes.py index eb26f936e7..758c03cd26 100644 --- a/cosmos/operators/kubernetes.py +++ b/cosmos/operators/kubernetes.py @@ -9,6 +9,7 @@ from cosmos.config import ProfileConfig from cosmos.operators.base import ( AbstractDbtBaseOperator, + DbtBuildMixin, DbtRunMixin, DbtSeedMixin, DbtSnapshotMixin, @@ -100,6 +101,12 @@ def execute(self, context: Context) -> None: self.build_and_run_cmd(context=context) +class DbtBuildKubernetesOperator(DbtBuildMixin, DbtKubernetesBaseOperator): + """ + Executes a dbt core build command. + """ + + class DbtLSKubernetesOperator(DbtLSMixin, DbtKubernetesBaseOperator): """ Executes a dbt core ls command. diff --git a/cosmos/operators/local.py b/cosmos/operators/local.py index ab9b33a4e1..7c2af28f8f 100644 --- a/cosmos/operators/local.py +++ b/cosmos/operators/local.py @@ -39,6 +39,7 @@ from cosmos.log import get_logger from cosmos.operators.base import ( AbstractDbtBaseOperator, + DbtBuildMixin, DbtRunMixin, DbtSeedMixin, DbtSnapshotMixin, @@ -384,6 +385,12 @@ def on_kill(self) -> None: self.subprocess_hook.send_sigterm() +class DbtBuildLocalOperator(DbtBuildMixin, DbtLocalBaseOperator): + """ + Executes a dbt core build command. + """ + + class DbtLSLocalOperator(DbtLSMixin, DbtLocalBaseOperator): """ Executes a dbt core ls command. diff --git a/cosmos/operators/virtualenv.py b/cosmos/operators/virtualenv.py index e628fc362b..f2fd4b18d2 100644 --- a/cosmos/operators/virtualenv.py +++ b/cosmos/operators/virtualenv.py @@ -10,6 +10,7 @@ from cosmos.log import get_logger from cosmos.operators.local import ( + DbtBuildLocalOperator, DbtDocsLocalOperator, DbtLocalBaseOperator, DbtLSLocalOperator, @@ -99,6 +100,13 @@ def execute(self, context: Context) -> None: logger.info(output) +class DbtBuildVirtualenvOperator(DbtVirtualenvBaseOperator, DbtBuildLocalOperator): + """ + Executes a dbt core build command within a Python Virtual Environment, that is created before running the dbt command + and deleted just after. + """ + + class DbtLSVirtualenvOperator(DbtVirtualenvBaseOperator, DbtLSLocalOperator): """ Executes a dbt core ls command within a Python Virtual Environment, that is created before running the dbt command diff --git a/tests/operators/test_base.py b/tests/operators/test_base.py index a46b51f7fe..348efaa6e8 100644 --- a/tests/operators/test_base.py +++ b/tests/operators/test_base.py @@ -2,6 +2,7 @@ from cosmos.operators.base import ( AbstractDbtBaseOperator, + DbtBuildMixin, DbtLSMixin, DbtSeedMixin, DbtRunOperationMixin, @@ -26,6 +27,7 @@ def test_dbt_base_operator_is_abstract(): ("ls", DbtLSMixin), ("seed", DbtSeedMixin), ("run", DbtRunMixin), + ("build", DbtBuildMixin), ], ) def test_dbt_mixin_base_cmd(dbt_command, dbt_operator_class): diff --git a/tests/operators/test_docker.py b/tests/operators/test_docker.py index 520511b23d..7d989f1a00 100644 --- a/tests/operators/test_docker.py +++ b/tests/operators/test_docker.py @@ -5,6 +5,7 @@ from pendulum import datetime from cosmos.operators.docker import ( + DbtBuildDockerOperator, DbtDockerBaseOperator, DbtLSDockerOperator, DbtRunDockerOperator, @@ -84,6 +85,7 @@ def test_dbt_docker_operator_get_env(p_context_to_airflow_vars: MagicMock) -> No "ls": DbtLSDockerOperator(**base_kwargs), "run": DbtRunDockerOperator(**base_kwargs), "test": DbtTestDockerOperator(**base_kwargs), + "build": DbtBuildDockerOperator(**base_kwargs), "seed": DbtSeedDockerOperator(**base_kwargs), } diff --git a/tests/operators/test_kubernetes.py b/tests/operators/test_kubernetes.py index 638cff1408..d0bbb565d6 100644 --- a/tests/operators/test_kubernetes.py +++ b/tests/operators/test_kubernetes.py @@ -5,6 +5,7 @@ from pendulum import datetime from cosmos.operators.kubernetes import ( + DbtBuildKubernetesOperator, DbtKubernetesBaseOperator, DbtLSKubernetesOperator, DbtRunKubernetesOperator, @@ -94,6 +95,7 @@ def test_dbt_kubernetes_operator_get_env(p_context_to_airflow_vars: MagicMock) - "ls": DbtLSKubernetesOperator(**base_kwargs), "run": DbtRunKubernetesOperator(**base_kwargs), "test": DbtTestKubernetesOperator(**base_kwargs), + "build": DbtBuildKubernetesOperator(**base_kwargs), "seed": DbtSeedKubernetesOperator(**base_kwargs), } diff --git a/tests/operators/test_local.py b/tests/operators/test_local.py index aa4cb741fa..7e758b885a 100644 --- a/tests/operators/test_local.py +++ b/tests/operators/test_local.py @@ -22,6 +22,7 @@ DbtSnapshotLocalOperator, DbtRunLocalOperator, DbtTestLocalOperator, + DbtBuildLocalOperator, DbtDocsLocalOperator, DbtDocsS3LocalOperator, DbtDocsAzureStorageLocalOperator, @@ -380,6 +381,7 @@ def test_operator_execute_with_flags(mock_build_and_run_cmd, operator_class, kwa DbtLSLocalOperator, DbtSnapshotLocalOperator, DbtTestLocalOperator, + DbtBuildLocalOperator, DbtDocsLocalOperator, DbtDocsS3LocalOperator, DbtDocsAzureStorageLocalOperator, From b6db7eaace7cb9a87dda8418fb603e2d753386b7 Mon Sep 17 00:00:00 2001 From: Justin Bandoro <79104794+jbandoro@users.noreply.github.com> Date: Tue, 23 Jan 2024 03:42:22 -0800 Subject: [PATCH 077/223] Fix test dependencies after Airflow 2.8 release (#806) Once Airflow 2.8 was released, Cosmos tests started failing. There were two main issues: conflicting `pendulum` version and the installation of `apache-airflow-providers-common-io`. # Details on `pendulum`: ``` _________________ ERROR collecting tests/airflow/test_graph.py _________________ tests/airflow/test_graph.py:6: in from airflow import __version__ as airflow_version ../../../.local/share/hatch/env/virtual/astronomer-cosmos/Za_bFbg4/tests.py3.8-2.4/lib/python3.8/site-packages/airflow/__init__.py:34: in from airflow import settings ../../../.local/share/hatch/env/virtual/astronomer-cosmos/Za_bFbg4/tests.py3.8-2.4/lib/python3.8/site-packages/airflow/settings.py:49: in TIMEZONE = pendulum.tz.timezone('UTC') E TypeError: 'module' object is not callable ``` [Example here](https://github.com/astronomer/astronomer-cosmos/actions/runs/7590233614/job/20676384033). I think this is because Airflow v2.8.1 was [released today](https://github.com/apache/airflow/releases/tag/2.8.1) that now targets the 3.0.0 version of Pendulum that has the breaking API changes seen above. Any pip install of `apache-airflow<2.8.1` I think is now installing `pendulum==3.0.0` because the pendulum constraint is only specified if you install airflow [with a constraint file.](https://airflow.apache.org/docs/apache-airflow/stable/installation/installing-from-pypi.html) I don't think hatch dependencies allow constraint file referencing, so this attempt pins `pendulum` directly, kind of like what is already done for pydantic. # Details on `apache-airflow-providers-common-io`: When building an environment, the first step Hatch does is to install the project dependencies. It does not consider tool.hatch.envs.tests.overrides when first doing this. So, for all our Airflow test Matrix, Hatch first installs Airflow 2.8. As part of this, it installs apache-airflow-providers-common-io==1.2.0. This new Airflow dependency conflicts with previous versions of Airflow. When Hatch downgrades the version of Airflow, it does not uninstall apache-airflow-providers-common-io. Therefore, tests running for versions of Airflow before 2.8 were failing because of apache-airflow-providers-common-io with: ``` FAILED tests/operators/test_local.py::test_run_test_operator_with_callback - sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) no such table: task_instance [SQL: SELECT task_instance.try_number, task_instance.task_id, task_instance.dag_id, task_instance.run_id, task_instance.map_index, task_instance.start_date, task_instance.end_date, task_instance.duration, task_instance.state, task_instance.max_tries, task_instance.hostname, task_instance.unixname, task_instance.job_id, task_instance.pool, task_instance.pool_slots, task_instance.queue, task_instance.priority_weight, task_instance.operator, task_instance.custom_operator_name, task_instance.queued_dttm, task_instance.queued_by_job_id, task_instance.pid, task_instance.executor_config, task_instance.updated_at, task_instance.external_executor_id, task_instance.trigger_id, task_instance.trigger_timeout, task_instance.next_method, task_instance.next_kwargs, dag_run_1.state AS state_1, dag_run_1.id, dag_run_1.dag_id AS dag_id_1, dag_run_1.queued_at, dag_run_1.execution_date, dag_run_1.start_date AS start_date_1, dag_run_1.end_date AS end_date_1, dag_run_1.run_id AS run_id_1, dag_run_1.creating_job_id, dag_run_1.external_trigger, dag_run_1.run_type, dag_run_1.conf, dag_run_1.data_interval_start, dag_run_1.data_interval_end, dag_run_1.last_scheduling_decision, dag_run_1.dag_hash, dag_run_1.log_template_id, dag_run_1.updated_at AS updated_at_1 FROM task_instance JOIN dag_run ON dag_run.dag_id = task_instance.dag_id AND dag_run.run_id = task_instance.run_id JOIN dag_run AS dag_run_1 ON dag_run_1.dag_id = task_instance.dag_id AND dag_run_1.run_id = task_instance.run_id WHERE task_instance.dag_id = ? AND task_instance.task_id IN (?, ?) AND dag_run.execution_date >= ? AND dag_run.execution_date <= ? AND task_instance.operator = ?] [parameters: ('test-id-2', 'run', 'test', '2024-01-22 23:11:55.593478', '2024-01-22 23:11:55.593478', 'ExternalTaskMarker')] (Background on this error at: [https://sqlalche.me/e/14/e3q8\](https://sqlalche.me/e/14/e3q8/)) I did a workaround to uninstall apache-airflow-providers-common-io for all Airflow versions and only install it for Airflow 2.8. It is ugly, but seems to work. Once the tests pass, I'll merge our PR - so the CI can be back to green. We can go ahead and revisit the approach in the future. ``` We did a workaround to uninstall `apache-airflow-providers-common-io` for all Airflow versions and only install it for Airflow 2.8. It is ugly, but seems to work. Once the tests pass, I'll merge our PR - so the CI can be back to green. We can go ahead and revisit the approach in the future. Co-authored-by: Tatiana Al-Chueyr --- pyproject.toml | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5d966c91b1..67865ac84e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -147,14 +147,16 @@ packages = ["cosmos"] [tool.hatch.envs.tests] dependencies = [ "astronomer-cosmos[tests]", - "apache-airflow-providers-docker>=3.5.0", - "apache-airflow-providers-cncf-kubernetes>=5.1.1", "types-PyYAML", "types-attrs", "types-requests", "types-python-dateutil", - "apache-airflow", "Werkzeug<3.0.0", + "apache-airflow-providers-cncf-kubernetes>=5.1.1", + "apache-airflow-providers-docker>=3.5.0", +] +post-install-commands = [ + "pip uninstall -y apache-airflow-providers-common-io" ] [[tool.hatch.envs.tests.matrix]] @@ -164,12 +166,18 @@ airflow = ["2.3", "2.4", "2.5", "2.6", "2.7", "2.8"] [tool.hatch.envs.tests.overrides] matrix.airflow.dependencies = [ { value = "apache-airflow==2.3", if = ["2.3"] }, + { value = "pendulum<3.0.0", if = ["2.3"] }, { value = "apache-airflow==2.4", if = ["2.4"] }, + { value = "pendulum<3.0.0", if = ["2.4"] }, { value = "apache-airflow==2.5", if = ["2.5"] }, + { value = "pendulum<3.0.0", if = ["2.5"] }, { value = "apache-airflow==2.6", if = ["2.6"] }, + { value = "pendulum<3.0.0", if = ["2.6"] }, { value = "pydantic>=1.10.0,<2.0.0", if = ["2.6"]}, { value = "apache-airflow==2.7", if = ["2.7"] }, + { value = "pendulum<3.0.0", if = ["2.7"] }, { value = "apache-airflow==2.8", if = ["2.8"] }, + { value = "apache-airflow-providers-common-io", if = ["2.8"] }, ] [tool.hatch.envs.tests.scripts] @@ -179,7 +187,7 @@ test = 'pytest -vv --durations=0 . -m "not integration" --ignore=tests/test_exam test-cov = """pytest -vv --cov=cosmos --cov-report=term-missing --cov-report=xml --durations=0 -m "not integration" --ignore=tests/test_example_dags.py --ignore=tests/test_example_dags_no_connections.py""" # we install using the following workaround to overcome installation conflicts, such as: # apache-airflow 2.3.0 and dbt-core [0.13.0 - 1.5.2] and jinja2>=3.0.0 because these package versions have conflicting dependencies -test-integration-setup = """pip uninstall dbt-postgres dbt-databricks dbt-vertica; \ +test-integration-setup = """pip uninstall -y dbt-postgres dbt-databricks dbt-vertica; \ rm -rf airflow.*; \ airflow db init; \ pip install 'dbt-core' 'dbt-databricks' 'dbt-postgres' 'dbt-vertica' 'openlineage-airflow'""" From 06b154cde2ee2b176f0ee4af364bcd4f3dea5fb0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 23 Jan 2024 11:43:41 +0000 Subject: [PATCH 078/223] =?UTF-8?q?=E2=AC=86=20[pre-commit.ci]=20pre-commi?= =?UTF-8?q?t=20autoupdate=20(#807)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.1.13 → v0.1.14](https://github.com/astral-sh/ruff-pre-commit/compare/v0.1.13...v0.1.14) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Tatiana Al-Chueyr --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 08b189f25b..8315ef571b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -54,7 +54,7 @@ repos: - --py37-plus - --keep-runtime-typing - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.13 + rev: v0.1.14 hooks: - id: ruff args: From 1499f64c7b15da56b148cf9bcc7282ba84abd2ab Mon Sep 17 00:00:00 2001 From: Jakob Aron Hvitnov <141235900+jakob-hvitnov-telia@users.noreply.github.com> Date: Tue, 23 Jan 2024 16:04:33 +0100 Subject: [PATCH 079/223] Remove incorrect docstring from DbtLocalBaseOperator (#797) Remove incorrect docstring from `DbtLocalBaseOperator` (relatest to #796) --------- Co-authored-by: Justin Bandoro <79104794+jbandoro@users.noreply.github.com> Co-authored-by: Tatiana Al-Chueyr --- cosmos/operators/local.py | 1 - 1 file changed, 1 deletion(-) diff --git a/cosmos/operators/local.py b/cosmos/operators/local.py index 7c2af28f8f..58e08644bc 100644 --- a/cosmos/operators/local.py +++ b/cosmos/operators/local.py @@ -97,7 +97,6 @@ class DbtLocalBaseOperator(AbstractDbtBaseOperator): :param profile_name: A name to use for the dbt profile. If not provided, and no profile target is found in your project's dbt_project.yml, "cosmos_profile" is used. :param install_deps: If true, install dependencies before running the command - :param install_deps: If true, the operator will set inlets and outlets :param callback: A callback function called on after a dbt run with a path to the dbt project directory. :param target_name: A name to use for the dbt target. If not provided, and no target is found in your project's dbt_project.yml, "cosmos_target" is used. From 76e5a139e22c5b68f0f7cf2e7163e08f125975f6 Mon Sep 17 00:00:00 2001 From: Justin Bandoro <79104794+jbandoro@users.noreply.github.com> Date: Wed, 24 Jan 2024 02:02:22 -0800 Subject: [PATCH 080/223] Fix: ensure operator `execute` method is consistent across all execution base subclasses (#805) This fixes an issue reported in #804 after the refactor done in https://github.com/astronomer/astronomer-cosmos/pull/774 where the `execute` methods for `DbtLocalBaseOperator`, `DbtDockerBaseOperator`, and `DbtKubernetesBaseOperator` were different. This PR refactors the `execute` method to the `AbstractDbtBaseOperator` so it's the same for all of the local, docker and kubernetes inherited operators, and adds `build_and_run_cmd` as an abstract method since the implementation is different across the 3 different execution modes. Closes #804 --- cosmos/operators/base.py | 7 +++++++ cosmos/operators/docker.py | 3 --- cosmos/operators/kubernetes.py | 3 --- cosmos/operators/local.py | 3 --- tests/operators/test_base.py | 18 +++++++++++++++++- 5 files changed, 24 insertions(+), 10 deletions(-) diff --git a/cosmos/operators/base.py b/cosmos/operators/base.py index cb1076ce98..b0f0d335a1 100644 --- a/cosmos/operators/base.py +++ b/cosmos/operators/base.py @@ -238,6 +238,13 @@ def build_cmd( return dbt_cmd, env + @abstractmethod + def build_and_run_cmd(self, context: Context, cmd_flags: list[str]) -> Any: + """Override this method for the operator to execute the dbt command""" + + def execute(self, context: Context) -> Any | None: # type: ignore + self.build_and_run_cmd(context=context, cmd_flags=self.add_cmd_flags()) + class DbtBuildMixin: """Mixin for dbt build command.""" diff --git a/cosmos/operators/docker.py b/cosmos/operators/docker.py index 648c6c6607..848aa37096 100644 --- a/cosmos/operators/docker.py +++ b/cosmos/operators/docker.py @@ -63,9 +63,6 @@ def build_command(self, context: Context, cmd_flags: list[str] | None = None) -> self.environment: dict[str, Any] = {**env_vars, **self.environment} self.command: list[str] = dbt_cmd - def execute(self, context: Context) -> None: - self.build_and_run_cmd(context=context) - class DbtBuildDockerOperator(DbtBuildMixin, DbtDockerBaseOperator): """ diff --git a/cosmos/operators/kubernetes.py b/cosmos/operators/kubernetes.py index 758c03cd26..b45f1d741b 100644 --- a/cosmos/operators/kubernetes.py +++ b/cosmos/operators/kubernetes.py @@ -97,9 +97,6 @@ def build_kube_args(self, context: Context, cmd_flags: list[str] | None = None) self.build_env_args(env_vars) self.arguments = dbt_cmd - def execute(self, context: Context) -> None: - self.build_and_run_cmd(context=context) - class DbtBuildKubernetesOperator(DbtBuildMixin, DbtKubernetesBaseOperator): """ diff --git a/cosmos/operators/local.py b/cosmos/operators/local.py index 58e08644bc..ae2392212a 100644 --- a/cosmos/operators/local.py +++ b/cosmos/operators/local.py @@ -372,9 +372,6 @@ def build_and_run_cmd(self, context: Context, cmd_flags: list[str] | None = None logger.info(result.output) return result - def execute(self, context: Context) -> None: - self.build_and_run_cmd(context=context, cmd_flags=self.add_cmd_flags()) - def on_kill(self) -> None: if self.cancel_query_on_kill: self.subprocess_hook.log.info("Sending SIGINT signal to process group") diff --git a/tests/operators/test_base.py b/tests/operators/test_base.py index 348efaa6e8..4ac78fc5a7 100644 --- a/tests/operators/test_base.py +++ b/tests/operators/test_base.py @@ -1,4 +1,5 @@ import pytest +from unittest.mock import patch from cosmos.operators.base import ( AbstractDbtBaseOperator, @@ -14,11 +15,26 @@ def test_dbt_base_operator_is_abstract(): """Tests that the abstract base operator cannot be instantiated since the base_cmd is not defined.""" - expected_error = "Can't instantiate abstract class AbstractDbtBaseOperator with abstract methods? base_cmd" + expected_error = ( + "Can't instantiate abstract class AbstractDbtBaseOperator with abstract methods base_cmd, build_and_run_cmd" + ) with pytest.raises(TypeError, match=expected_error): AbstractDbtBaseOperator() +@pytest.mark.parametrize("cmd_flags", [["--some-flag"], []]) +@patch("cosmos.operators.base.AbstractDbtBaseOperator.build_and_run_cmd") +def test_dbt_base_operator_execute(mock_build_and_run_cmd, cmd_flags, monkeypatch): + """Tests that the base operator execute method calls the build_and_run_cmd method with the expected arguments.""" + monkeypatch.setattr(AbstractDbtBaseOperator, "add_cmd_flags", lambda _: cmd_flags) + AbstractDbtBaseOperator.__abstractmethods__ = set() + + base_operator = AbstractDbtBaseOperator(task_id="fake_task", project_dir="fake_dir") + + base_operator.execute(context={}) + mock_build_and_run_cmd.assert_called_once_with(context={}, cmd_flags=cmd_flags) + + @pytest.mark.parametrize( "dbt_command, dbt_operator_class", [ From 31db7384d59d511af4c74cdea8faed888d77aec5 Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Thu, 25 Jan 2024 11:55:50 +0000 Subject: [PATCH 081/223] Add more logs to troubleshoot custom selector (#809) A user (ZD case: 38982) reported being unable to see the test node for one specific model in a particular DAG when using RenderConfig(select=tags). They were using the custom Cosmos selector. It seems the selector worked as expected for other models and for the same model/test in a different DAG. This CR adds more logs for troubleshooting when using. --- cosmos/dbt/selector.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cosmos/dbt/selector.py b/cosmos/dbt/selector.py index 76ec31a54b..9fa2d761cc 100644 --- a/cosmos/dbt/selector.py +++ b/cosmos/dbt/selector.py @@ -289,6 +289,7 @@ def select_nodes_ids_by_intersection(self) -> set[str]: def _should_include_node(self, node_id: str, node: DbtNode) -> bool: "Checks if a single node should be included. Only runs once per node with caching." + logger.debug("Inspecting if the node <%s> should be included.", node_id) if node_id in self.visited_nodes: return node_id in self.selected_nodes @@ -296,8 +297,15 @@ def _should_include_node(self, node_id: str, node: DbtNode) -> bool: if node.resource_type == DbtResourceType.TEST: node.tags = getattr(self.nodes.get(node.depends_on[0]), "tags", []) + logger.debug( + "The test node <%s> inherited these tags from the parent node <%s>: %s", + node_id, + node.depends_on[0], + node.tags, + ) if not self._is_tags_subset(node): + logger.debug("Excluding node <%s>", node_id) return False node_config = {key: value for key, value in node.config.items() if key in SUPPORTED_CONFIG} From 285a6a99c583c15f053a1b9348668c80a3d0441e Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Thu, 25 Jan 2024 11:56:32 +0000 Subject: [PATCH 082/223] Fix custom selector when `test` node has no `depends_on` values (#814) A user reported the following issue while using Cosmos 1.3.1: ``` def _should_include_node(self, node_id: str, node: DbtNode) -> bool: "Checks if a single node should be included. Only runs once per node with caching." if node_id in self.visited_nodes: return node_id in self.selected_nodes self.visited_nodes.add(node_id) if node.resource_type == DbtResourceType.TEST: > node.tags = getattr(self.nodes.get(node.depends_on[0]), "tags", []) E IndexError: list index out of range cosmos/dbt/selector.py:298: IndexError ``` In order to reproduce this issue, it was necessary to add a tag-based select statement. Based on the error, it seems their dbt project has a test without `depends_on`. This CR adds support to this use case. Closes: #813 --- cosmos/dbt/selector.py | 2 +- tests/dbt/test_selector.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/cosmos/dbt/selector.py b/cosmos/dbt/selector.py index 9fa2d761cc..58c5c12b49 100644 --- a/cosmos/dbt/selector.py +++ b/cosmos/dbt/selector.py @@ -295,7 +295,7 @@ def _should_include_node(self, node_id: str, node: DbtNode) -> bool: self.visited_nodes.add(node_id) - if node.resource_type == DbtResourceType.TEST: + if node.resource_type == DbtResourceType.TEST and node.depends_on: node.tags = getattr(self.nodes.get(node.depends_on[0]), "tags", []) logger.debug( "The test node <%s> inherited these tags from the parent node <%s>: %s", diff --git a/tests/dbt/test_selector.py b/tests/dbt/test_selector.py index 1cf9871248..ab7842783d 100644 --- a/tests/dbt/test_selector.py +++ b/tests/dbt/test_selector.py @@ -406,3 +406,16 @@ def test_exclude_by_union_graph_selector_and_tag(): "model.dbt-proj.orphaned", ] assert sorted(selected.keys()) == expected + + +def test_node_without_depends_on_with_tag_selector_should_not_raise_exception(): + standalone_test_node = DbtNode( + unique_id=f"{DbtResourceType.TEST.value}.{SAMPLE_PROJ_PATH.stem}.standalone", + resource_type=DbtResourceType.TEST, + depends_on=[], + tags=[], + config={}, + file_path=SAMPLE_PROJ_PATH / "tests/generic/builtin.sql", + ) + nodes = {standalone_test_node.unique_id: standalone_test_node} + assert not select_nodes(project_dir=SAMPLE_PROJ_PATH, nodes=nodes, select=["tag:some-tag"]) From 2389c27a5651dee54d733ea46fed5047eb967a93 Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Fri, 26 Jan 2024 22:44:28 +0000 Subject: [PATCH 083/223] Fix forwarding selectors to test task when using `TestBehavior.AFTER_ALL` (#816) Before, when using `TestBehavior.AFTER_ALL`, it did not consider the `select` and `exclude` settings defined in the rendering configuration. This was an error since it would run all the dbt project tests, even if they were outside of the scope of the Cosmos-defined DAG. Now we take into account `select`, `selector` and `exclude` when using this test behaviour. Closes: #643 --- cosmos/airflow/graph.py | 15 +++++++++++---- cosmos/converter.py | 3 +-- cosmos/operators/base.py | 24 ++++++++++++++++++++++++ cosmos/operators/local.py | 2 +- tests/airflow/test_graph.py | 13 ++++++++++--- tests/operators/test_local.py | 16 +++++++++++----- 6 files changed, 58 insertions(+), 15 deletions(-) diff --git a/cosmos/airflow/graph.py b/cosmos/airflow/graph.py index 9d9dba83ae..8b97f4e659 100644 --- a/cosmos/airflow/graph.py +++ b/cosmos/airflow/graph.py @@ -14,6 +14,7 @@ TESTABLE_DBT_RESOURCES, DEFAULT_DBT_RESOURCES, ) +from cosmos.config import RenderConfig from cosmos.core.airflow import get_airflow_task as create_airflow_task from cosmos.core.graph.entities import Task as TaskMetadata from cosmos.dbt.graph import DbtNode @@ -66,6 +67,7 @@ def create_test_task_metadata( task_args: dict[str, Any], on_warning_callback: Callable[..., Any] | None = None, node: DbtNode | None = None, + render_config: RenderConfig | None = None, ) -> TaskMetadata: """ Create the metadata that will be used to instantiate the Airflow Task that will be used to run the Dbt test node. @@ -89,6 +91,11 @@ def create_test_task_metadata( task_args["select"] = f"source:{node.resource_name}" else: # tested with node.resource_type == DbtResourceType.SEED or DbtResourceType.SNAPSHOT task_args["select"] = node.resource_name + elif render_config is not None: # TestBehavior.AFTER_ALL + task_args["select"] = render_config.select + task_args["selector"] = render_config.selector + task_args["exclude"] = render_config.exclude + return TaskMetadata( id=test_task_name, operator_class=calculate_operator_class( @@ -212,12 +219,11 @@ def build_airflow_graph( dag: DAG, # Airflow-specific - parent DAG where to associate tasks and (optional) task groups execution_mode: ExecutionMode, # Cosmos-specific - decide what which class to use task_args: dict[str, Any], # Cosmos/DBT - used to instantiate tasks - test_behavior: TestBehavior, # Cosmos-specific: how to inject tests to Airflow DAG test_indirect_selection: TestIndirectSelection, # Cosmos/DBT - used to set test indirect selection mode dbt_project_name: str, # DBT / Cosmos - used to name test task if mode is after_all, + render_config: RenderConfig, task_group: TaskGroup | None = None, on_warning_callback: Callable[..., Any] | None = None, # argument specific to the DBT test command - node_converters: dict[DbtResourceType, Callable[..., Any]] | None = None, ) -> None: """ Instantiate dbt `nodes` as Airflow tasks within the given `task_group` (optional) or `dag` (mandatory). @@ -237,13 +243,13 @@ def build_airflow_graph( :param execution_mode: Where Cosmos should run each dbt task (e.g. ExecutionMode.LOCAL, ExecutionMode.KUBERNETES). Default is ExecutionMode.LOCAL. :param task_args: Arguments to be used to instantiate an Airflow Task - :param test_behavior: When to run `dbt` tests. Default is TestBehavior.AFTER_EACH, that runs tests after each model. :param dbt_project_name: Name of the dbt pipeline of interest :param task_group: Airflow Task Group instance :param on_warning_callback: A callback function called on warnings with additional Context variables “test_names” and “test_results” of type List. """ - node_converters = node_converters or {} + node_converters = render_config.node_converters or {} + test_behavior = render_config.test_behavior tasks_map = {} task_or_group: TaskGroup | BaseOperator @@ -279,6 +285,7 @@ def build_airflow_graph( test_indirect_selection, task_args=task_args, on_warning_callback=on_warning_callback, + render_config=render_config, ) test_task = create_airflow_task(test_meta, dag, task_group=task_group) leaves_ids = calculate_leaves(tasks_ids=list(tasks_map.keys()), nodes=nodes) diff --git a/cosmos/converter.py b/cosmos/converter.py index c2b31700b9..97e8190dd1 100644 --- a/cosmos/converter.py +++ b/cosmos/converter.py @@ -268,9 +268,8 @@ def __init__( task_group=task_group, execution_mode=execution_config.execution_mode, task_args=task_args, - test_behavior=render_config.test_behavior, test_indirect_selection=execution_config.test_indirect_selection, dbt_project_name=project_config.project_name, on_warning_callback=on_warning_callback, - node_converters=render_config.node_converters, + render_config=render_config, ) diff --git a/cosmos/operators/base.py b/cosmos/operators/base.py index b0f0d335a1..25aef77642 100644 --- a/cosmos/operators/base.py +++ b/cosmos/operators/base.py @@ -324,6 +324,30 @@ class DbtTestMixin: base_cmd = ["test"] ui_color = "#8194E0" + def __init__( + self, + exclude: str | None = None, + select: str | None = None, + selector: str | None = None, + **kwargs: Any, + ) -> None: + self.select = select + self.exclude = exclude + self.selector = selector + super().__init__(exclude=exclude, select=select, selector=selector, **kwargs) # type: ignore + + def add_cmd_flags(self) -> list[str]: + flags = [] + if self.exclude: + flags.extend(["--exclude", *self.exclude]) + + if self.select: + flags.extend(["--select", *self.select]) + + if self.selector: + flags.extend(["--selector", self.selector]) + return flags + class DbtRunOperationMixin: """ diff --git a/cosmos/operators/local.py b/cosmos/operators/local.py index ae2392212a..d277670ac3 100644 --- a/cosmos/operators/local.py +++ b/cosmos/operators/local.py @@ -447,7 +447,7 @@ def _handle_warnings(self, result: FullOutputSubprocessResult, context: Context) self.on_warning_callback and self.on_warning_callback(warning_context) def execute(self, context: Context) -> None: - result = self.build_and_run_cmd(context=context) + result = self.build_and_run_cmd(context=context, cmd_flags=self.add_cmd_flags()) should_trigger_callback = all( [ self.on_warning_callback, diff --git a/tests/airflow/test_graph.py b/tests/airflow/test_graph.py index 35e313fca1..255f8afc34 100644 --- a/tests/airflow/test_graph.py +++ b/tests/airflow/test_graph.py @@ -16,7 +16,7 @@ create_test_task_metadata, generate_task_or_group, ) -from cosmos.config import ProfileConfig +from cosmos.config import ProfileConfig, RenderConfig from cosmos.constants import ( DbtResourceType, ExecutionMode, @@ -96,7 +96,9 @@ def test_build_airflow_graph_with_after_each(): execution_mode=ExecutionMode.LOCAL, test_indirect_selection=TestIndirectSelection.EAGER, task_args=task_args, - test_behavior=TestBehavior.AFTER_EACH, + render_config=RenderConfig( + test_behavior=TestBehavior.AFTER_EACH, + ), dbt_project_name="astro_shop", ) topological_sort = [task.task_id for task in dag.topological_sort()] @@ -183,14 +185,18 @@ def test_build_airflow_graph_with_after_all(): ), ), } + render_config = RenderConfig( + select=["tag:some"], + test_behavior=TestBehavior.AFTER_ALL, + ) build_airflow_graph( nodes=sample_nodes, dag=dag, execution_mode=ExecutionMode.LOCAL, test_indirect_selection=TestIndirectSelection.EAGER, task_args=task_args, - test_behavior=TestBehavior.AFTER_ALL, dbt_project_name="astro_shop", + render_config=render_config, ) topological_sort = [task.task_id for task in dag.topological_sort()] expected_sort = ["seed_parent_seed", "parent_run", "child_run", "child2_v2_run", "astro_shop_test"] @@ -201,6 +207,7 @@ def test_build_airflow_graph_with_after_all(): assert len(dag.leaves) == 1 assert dag.leaves[0].task_id == "astro_shop_test" + assert dag.leaves[0].select == ["tag:some"] def test_calculate_operator_class(): diff --git a/tests/operators/test_local.py b/tests/operators/test_local.py index 7e758b885a..babb425ef0 100644 --- a/tests/operators/test_local.py +++ b/tests/operators/test_local.py @@ -360,7 +360,16 @@ def test_store_compiled_sql() -> None: [ (DbtSeedLocalOperator, {"full_refresh": True}, {"context": {}, "cmd_flags": ["--full-refresh"]}), (DbtRunLocalOperator, {"full_refresh": True}, {"context": {}, "cmd_flags": ["--full-refresh"]}), - (DbtTestLocalOperator, {"full_refresh": True}, {"context": {}}), + ( + DbtTestLocalOperator, + {"full_refresh": True, "select": ["tag:daily"], "exclude": ["tag:disabled"]}, + {"context": {}, "cmd_flags": ["--exclude", "tag:disabled", "--select", "tag:daily"]}, + ), + ( + DbtTestLocalOperator, + {"full_refresh": True, "selector": "nightly_snowplow"}, + {"context": {}, "cmd_flags": ["--selector", "nightly_snowplow"]}, + ), ( DbtRunOperationLocalOperator, {"args": {"days": 7, "dry_run": True}, "macro_name": "bla"}, @@ -402,10 +411,7 @@ def test_operator_execute_without_flags(mock_build_and_run_cmd, operator_class): **operator_class_kwargs.get(operator_class, {}), ) task.execute(context={}) - if operator_class == DbtTestLocalOperator: - mock_build_and_run_cmd.assert_called_once_with(context={}) - else: - mock_build_and_run_cmd.assert_called_once_with(context={}, cmd_flags=[]) + mock_build_and_run_cmd.assert_called_once_with(context={}, cmd_flags=[]) @patch("cosmos.operators.local.DbtLocalArtifactProcessor") From 4729fa1a138f3463a9a1714be6696adce5fcb7f7 Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Fri, 26 Jan 2024 23:01:03 +0000 Subject: [PATCH 084/223] Fix OpenLineage integration documentation (#810) [Cosmos docs](https://astronomer.github.io/astronomer-cosmos/configuration/lineage.html) stated that users didn't have to install any dependency to use OpenLineage with Cosmos. However, for inlets and outlets to be emitted, Airflow 2.7 users must install `apache-airflow-providers-openlineage` or `astronomer-cosmos[openlineage]`. Closes: #796 --- docs/configuration/lineage.rst | 4 ++-- docs/configuration/render-config.rst | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/configuration/lineage.rst b/docs/configuration/lineage.rst index bf099f344e..8dfcae51b9 100644 --- a/docs/configuration/lineage.rst +++ b/docs/configuration/lineage.rst @@ -9,7 +9,7 @@ and virtualenv execution methods (read `execution modes <../getting_started/exec To emit lineage events, Cosmos can use one of the following: -1. Airflow 2.7 `built-in support to OpenLineage `_, or +1. Airflow `official OpenLineage provider `_, or 2. `Additional libraries `_. No change to the user DAG files is required to use OpenLineage. @@ -18,7 +18,7 @@ No change to the user DAG files is required to use OpenLineage. Installation ------------ -If using Airflow 2.7, no other dependency is required. +If using Airflow 2.7 or higher, install ``apache-airflow-providers-openlineage``. Otherwise, install Cosmos using ``astronomer-cosmos[openlineage]``. diff --git a/docs/configuration/render-config.rst b/docs/configuration/render-config.rst index 6d669d0a5d..f3e2167125 100644 --- a/docs/configuration/render-config.rst +++ b/docs/configuration/render-config.rst @@ -7,7 +7,7 @@ It does this by exposing a ``cosmos.config.RenderConfig`` class that you can use The ``RenderConfig`` class takes the following arguments: -- ``emit_datasets``: whether or not to emit Airflow datasets to be used for data-aware scheduling. Defaults to True +- ``emit_datasets``: whether or not to emit Airflow datasets to be used for data-aware scheduling. Defaults to True. Depends on `additional dependencies `_. - ``test_behavior``: how to run tests. Defaults to running a model's tests immediately after the model is run. For more information, see the `Testing Behavior `_ section. - ``load_method``: how to load your dbt project. See `Parsing Methods `_ for more information. - ``select`` and ``exclude``: which models to include or exclude from your DAGs. See `Selecting & Excluding `_ for more information. From 430d1bfb954f5d21495f93681587be01b5a50a16 Mon Sep 17 00:00:00 2001 From: Justin Bandoro <79104794+jbandoro@users.noreply.github.com> Date: Fri, 26 Jan 2024 15:02:33 -0800 Subject: [PATCH 085/223] Use Airflow constraint file for test environment setup (#812) This PR installs apache-airflow in the hatch `pre-install-commands` section so Airflow related dependency conflicts do not need to be managed in the override section that has been removed. Installing from the Airflow constraint file for versions <=2.6 fails because PyYAML is pinned in the constraint file for those versions to 6.0.0 and [a workaround ](https://github.com/yaml/pyyaml/issues/736) is required becaused older versions of PyYAML can no longer be installed from unmodified source or sdist with the release of Cython3. Closes: #811 --- .github/workflows/test.yml | 4 ++-- pyproject.toml | 27 ++++++++------------------- 2 files changed, 10 insertions(+), 21 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index af71efc1c3..a5a66eedf5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,7 +41,7 @@ jobs: strategy: matrix: python-version: ["3.8", "3.9", "3.10"] - airflow-version: ["2.3", "2.4", "2.5", "2.6", "2.7"] + airflow-version: ["2.3", "2.4", "2.5", "2.6", "2.7", "2.8"] steps: - uses: actions/checkout@v3 with: @@ -80,7 +80,7 @@ jobs: strategy: matrix: python-version: ["3.8", "3.9", "3.10"] - airflow-version: ["2.3", "2.4", "2.5", "2.6", "2.7"] + airflow-version: ["2.3", "2.4", "2.5", "2.6", "2.7", "2.8"] services: postgres: image: postgres diff --git a/pyproject.toml b/pyproject.toml index 67865ac84e..cba71f41ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -155,30 +155,19 @@ dependencies = [ "apache-airflow-providers-cncf-kubernetes>=5.1.1", "apache-airflow-providers-docker>=3.5.0", ] -post-install-commands = [ - "pip uninstall -y apache-airflow-providers-common-io" +# Airflow install with constraint file, Airflow versions < 2.7 require a workaround for PyYAML +pre-install-commands = [""" + if [[ "2.3 2.4 2.5 2.6" =~ "{matrix:airflow}" ]]; then + echo "Cython < 3" >> /tmp/constraint.txt + pip wheel "PyYAML==6.0.0" -c /tmp/constraint.txt + fi + pip install 'apache-airflow=={matrix:airflow}' --constraint 'https://raw.githubusercontent.com/apache/airflow/constraints-{matrix:airflow}.0/constraints-{matrix:python}.txt' + """ ] - [[tool.hatch.envs.tests.matrix]] python = ["3.8", "3.9", "3.10"] airflow = ["2.3", "2.4", "2.5", "2.6", "2.7", "2.8"] -[tool.hatch.envs.tests.overrides] -matrix.airflow.dependencies = [ - { value = "apache-airflow==2.3", if = ["2.3"] }, - { value = "pendulum<3.0.0", if = ["2.3"] }, - { value = "apache-airflow==2.4", if = ["2.4"] }, - { value = "pendulum<3.0.0", if = ["2.4"] }, - { value = "apache-airflow==2.5", if = ["2.5"] }, - { value = "pendulum<3.0.0", if = ["2.5"] }, - { value = "apache-airflow==2.6", if = ["2.6"] }, - { value = "pendulum<3.0.0", if = ["2.6"] }, - { value = "pydantic>=1.10.0,<2.0.0", if = ["2.6"]}, - { value = "apache-airflow==2.7", if = ["2.7"] }, - { value = "pendulum<3.0.0", if = ["2.7"] }, - { value = "apache-airflow==2.8", if = ["2.8"] }, - { value = "apache-airflow-providers-common-io", if = ["2.8"] }, -] [tool.hatch.envs.tests.scripts] freeze = "pip freeze" From 7f5df0645e99fc10399ae91456ea2ca941ea0686 Mon Sep 17 00:00:00 2001 From: Justin Bandoro <79104794+jbandoro@users.noreply.github.com> Date: Fri, 26 Jan 2024 17:02:49 -0800 Subject: [PATCH 086/223] Update PyPI project description to better describe Cosmos (#819) ## Description I noticed that the project description in PyPi could use an update: ![image](https://github.com/astronomer/astronomer-cosmos/assets/79104794/4c4175b6-1213-4bcd-8cfd-4d57ce9a90c6) I think the original intention of Cosmos was to render and run more than only dbt DAGs but has since changed to be specific to dbt. Would welcome any other suggestions from the team/community! --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index cba71f41ba..864b0fb420 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "astronomer-cosmos" dynamic = ["version"] -description = "Render 3rd party workflows in Airflow" +description = "Orchestrate your dbt projects in Airflow" readme = "README.rst" license = "Apache-2.0" requires-python = ">=3.8" From 63665abe19de58e0c7c748e8d0298deb53f22e5c Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Sat, 27 Jan 2024 08:54:38 +0000 Subject: [PATCH 087/223] Release 1.3 (#818) **Bug fixes** * Fix: ensure ``DbtGraph.update_node_dependency`` is called for all load methods by @jbandoro in #803 * Fix: ensure operator ``execute`` method is consistent across all execution base subclasses by @jbandoro in #805 * Fix custom selector when ``test`` node has no ``depends_on`` values by @tatiana in #814 * Fix forwarding selectors to test task when using ``TestBehavior.AFTER_ALL`` by @tatiana in #816 **Others** * Docs: Remove incorrect docstring from ``DbtLocalBaseOperator`` by @jakob-hvitnov-telia in #797 * Add more logs to troubleshoot custom selector by @tatiana in #809 * Fix OpenLineage integration documentation by @tatiana in #810 * Fix test dependencies after Airflow 2.8 release by @jbandoro and @tatiana in #806 * Use Airflow constraint file for test environment setup by @jbandoro in #812 * pre-commit updates in #799, #807 --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Justin Bandoro <79104794+jbandoro@users.noreply.github.com> Co-authored-by: Jakob Aron Hvitnov <141235900+jakob-hvitnov-telia@users.noreply.github.com> --- CHANGELOG.rst | 20 ++++++++++++++++++++ cosmos/__init__.py | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ac2c4b832d..859f537426 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,26 @@ Changelog ========= +1.3.2 (2023-01-26) +------------------ + +Bug fixes + +* Fix: ensure ``DbtGraph.update_node_dependency`` is called for all load methods by @jbandoro in #803 +* Fix: ensure operator ``execute`` method is consistent across all execution base subclasses by @jbandoro in #805 +* Fix custom selector when ``test`` node has no ``depends_on`` values by @tatiana in #814 +* Fix forwarding selectors to test task when using ``TestBehavior.AFTER_ALL`` by @tatiana in #816 + +Others + +* Docs: Remove incorrect docstring from ``DbtLocalBaseOperator`` by @jakob-hvitnov-telia in #797 +* Add more logs to troubleshoot custom selector by @tatiana in #809 +* Fix OpenLineage integration documentation by @tatiana in #810 +* Fix test dependencies after Airflow 2.8 release by @jbandoro and @tatiana in #806 +* Use Airflow constraint file for test environment setup by @jbandoro in #812 +* pre-commit updates in #799, #807 + + 1.3.1 (2023-01-10) ------------------ diff --git a/cosmos/__init__.py b/cosmos/__init__.py index 7d6f634fa8..d8704b3463 100644 --- a/cosmos/__init__.py +++ b/cosmos/__init__.py @@ -5,7 +5,7 @@ Contains dags, task groups, and operators. """ -__version__ = "1.3.1" +__version__ = "1.3.2" from cosmos.airflow.dag import DbtDag From 07f70b71a821ca84181bb5fc0af4634ee17dfeeb Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 29 Jan 2024 13:00:44 -0800 Subject: [PATCH 088/223] =?UTF-8?q?=E2=AC=86=20[pre-commit.ci]=20pre-commi?= =?UTF-8?q?t=20autoupdate=20(#820)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 23.12.1 → 24.1.1](https://github.com/psf/black/compare/23.12.1...24.1.1) --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- cosmos/airflow/dag.py | 1 + cosmos/airflow/task_group.py | 1 + cosmos/dbt/parser/project.py | 1 + cosmos/profiles/base.py | 1 + dev/dags/basic_cosmos_task_group.py | 1 + dev/dags/cosmos_profile_mapping.py | 1 + dev/dags/cosmos_seed_dag.py | 1 + dev/dags/example_cosmos_sources.py | 1 + dev/dags/example_virtualenv.py | 1 + dev/dags/user_defined_profile.py | 1 + docs/generate_mappings.py | 1 + tests/test_export.py | 1 + 13 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8315ef571b..368933db38 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -60,7 +60,7 @@ repos: args: - --fix - repo: https://github.com/psf/black - rev: 23.12.1 + rev: 24.1.1 hooks: - id: black args: ["--config", "./pyproject.toml"] diff --git a/cosmos/airflow/dag.py b/cosmos/airflow/dag.py index d5465ac81c..ca2672060f 100644 --- a/cosmos/airflow/dag.py +++ b/cosmos/airflow/dag.py @@ -1,6 +1,7 @@ """ This module contains a function to render a dbt project as an Airflow DAG. """ + from __future__ import annotations from typing import Any diff --git a/cosmos/airflow/task_group.py b/cosmos/airflow/task_group.py index dcdedb685e..5171645f2e 100644 --- a/cosmos/airflow/task_group.py +++ b/cosmos/airflow/task_group.py @@ -1,6 +1,7 @@ """ This module contains a function to render a dbt project as an Airflow Task Group. """ + from __future__ import annotations from typing import Any diff --git a/cosmos/dbt/parser/project.py b/cosmos/dbt/parser/project.py index de506e02d0..c5e434996c 100644 --- a/cosmos/dbt/parser/project.py +++ b/cosmos/dbt/parser/project.py @@ -1,6 +1,7 @@ """ Used to parse and extract information from dbt projects. """ + from __future__ import annotations import os diff --git a/cosmos/profiles/base.py b/cosmos/profiles/base.py index c583c8edb0..1131dc8e10 100644 --- a/cosmos/profiles/base.py +++ b/cosmos/profiles/base.py @@ -2,6 +2,7 @@ This module contains a base class that other profile mappings should inherit from to ensure consistency. """ + from __future__ import annotations from abc import ABC, abstractmethod diff --git a/dev/dags/basic_cosmos_task_group.py b/dev/dags/basic_cosmos_task_group.py index 7319149531..4b6aae71e1 100644 --- a/dev/dags/basic_cosmos_task_group.py +++ b/dev/dags/basic_cosmos_task_group.py @@ -1,6 +1,7 @@ """ An example DAG that uses Cosmos to render a dbt project as a TaskGroup. """ + import os from datetime import datetime diff --git a/dev/dags/cosmos_profile_mapping.py b/dev/dags/cosmos_profile_mapping.py index 33619a39db..48040126ed 100644 --- a/dev/dags/cosmos_profile_mapping.py +++ b/dev/dags/cosmos_profile_mapping.py @@ -3,6 +3,7 @@ It uses the automatic profile rendering from an Airflow connection. """ + import os from datetime import datetime from pathlib import Path diff --git a/dev/dags/cosmos_seed_dag.py b/dev/dags/cosmos_seed_dag.py index cef84dd66f..b682ecc368 100644 --- a/dev/dags/cosmos_seed_dag.py +++ b/dev/dags/cosmos_seed_dag.py @@ -9,6 +9,7 @@ would be ingesting data from various sources (i.e. sftp, blob like s3 or gcs, http endpoint, database, etc.) """ + import os from pathlib import Path diff --git a/dev/dags/example_cosmos_sources.py b/dev/dags/example_cosmos_sources.py index 0553b2f10d..24359ee3be 100644 --- a/dev/dags/example_cosmos_sources.py +++ b/dev/dags/example_cosmos_sources.py @@ -11,6 +11,7 @@ It will dynamically add the new type to the enumeration ``DbtResourceType`` so that Cosmos can parse these dbt nodes and convert them into the Airflow DAG. """ + import os from datetime import datetime from pathlib import Path diff --git a/dev/dags/example_virtualenv.py b/dev/dags/example_virtualenv.py index 7b1368f8c4..d84646cec3 100644 --- a/dev/dags/example_virtualenv.py +++ b/dev/dags/example_virtualenv.py @@ -1,6 +1,7 @@ """ An example DAG that uses Cosmos to render a dbt project. """ + import os from datetime import datetime from pathlib import Path diff --git a/dev/dags/user_defined_profile.py b/dev/dags/user_defined_profile.py index 032915d0ab..22402bc53a 100644 --- a/dev/dags/user_defined_profile.py +++ b/dev/dags/user_defined_profile.py @@ -1,6 +1,7 @@ """ A DAG that uses Cosmos with a custom profile. """ + import os from datetime import datetime from pathlib import Path diff --git a/docs/generate_mappings.py b/docs/generate_mappings.py index f7f0696cf7..273187ba57 100644 --- a/docs/generate_mappings.py +++ b/docs/generate_mappings.py @@ -1,6 +1,7 @@ """ Script to generate a dedicated docs page per profile mapping. """ + from __future__ import annotations import os diff --git a/tests/test_export.py b/tests/test_export.py index 859b01e799..0c14a65582 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -1,6 +1,7 @@ """ Tests exports from the dbt provider. """ + import importlib import sys from unittest import mock From e3af9ed67b68a66805a978132b5a349a407e1732 Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Tue, 30 Jan 2024 21:48:06 +0000 Subject: [PATCH 089/223] Add Python 3.11 to CI/tests (#821) Python 3.11 was missing from the test matrices. --- .github/workflows/test.yml | 12 ++++++------ pyproject.toml | 3 ++- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a5a66eedf5..74c3524d5a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,7 +40,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10"] + python-version: ["3.8", "3.9", "3.10", "3.11"] airflow-version: ["2.3", "2.4", "2.5", "2.6", "2.7", "2.8"] steps: - uses: actions/checkout@v3 @@ -79,7 +79,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10"] + python-version: ["3.8", "3.9", "3.10", "3.11"] airflow-version: ["2.3", "2.4", "2.5", "2.6", "2.7", "2.8"] services: postgres: @@ -147,7 +147,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.10"] + python-version: ["3.11"] airflow-version: ["2.6"] services: @@ -227,7 +227,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.10"] + python-version: ["3.11"] airflow-version: ["2.7"] steps: @@ -296,10 +296,10 @@ jobs: - uses: actions/checkout@v3 with: ref: ${{ github.event.pull_request.head.sha || github.ref }} - - name: Set up Python 3.10 + - name: Set up Python 3.11 uses: actions/setup-python@v3 with: - python-version: '3.10' + python-version: '3.11' - name: Install coverage run: | pip3 install coverage diff --git a/pyproject.toml b/pyproject.toml index 864b0fb420..e2ade9b7c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ classifiers = [ "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", ] dependencies = [ "aenum", @@ -165,7 +166,7 @@ pre-install-commands = [""" """ ] [[tool.hatch.envs.tests.matrix]] -python = ["3.8", "3.9", "3.10"] +python = ["3.8", "3.9", "3.10", "3.11"] airflow = ["2.3", "2.4", "2.5", "2.6", "2.7", "2.8"] From 5ded344821145f0fc3ab46f791440e01e2a1a8ac Mon Sep 17 00:00:00 2001 From: Justin Bandoro <79104794+jbandoro@users.noreply.github.com> Date: Wed, 31 Jan 2024 04:47:52 -0800 Subject: [PATCH 090/223] Exclude unsupported Airflow versions for Python 3.11 tests (#824) After #821 was added, tests in the matrix like [this one](https://github.com/astronomer/astronomer-cosmos/actions/runs/7717880587/job/21037953569) for Airflow v2.3 and Python 3.11 are failing because the minimum Airflow version supported for 3.11 is v2.5. This PR excludes Airflow v2.3 and 2.4 from the testing matrix for 3.11. --- .github/workflows/test.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 74c3524d5a..5adcc3ac5e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -42,6 +42,11 @@ jobs: matrix: python-version: ["3.8", "3.9", "3.10", "3.11"] airflow-version: ["2.3", "2.4", "2.5", "2.6", "2.7", "2.8"] + exclude: + - python-version: "3.11" + airflow-version: "2.3" + - python-version: "3.11" + airflow-version: "2.4" steps: - uses: actions/checkout@v3 with: @@ -81,6 +86,11 @@ jobs: matrix: python-version: ["3.8", "3.9", "3.10", "3.11"] airflow-version: ["2.3", "2.4", "2.5", "2.6", "2.7", "2.8"] + exclude: + - python-version: "3.11" + airflow-version: "2.3" + - python-version: "3.11" + airflow-version: "2.4" services: postgres: image: postgres From a5660a6486029b2e5388627230fcc9db41ebd02a Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Wed, 31 Jan 2024 18:46:24 +0000 Subject: [PATCH 091/223] Fix error when running unit test `test_created_pod` with Py3.11 (#825) When the CI ran: `hatch run tests.py3.11-2.7:test-cov` It was consistently raising the exception: ``` FAILED tests/operators/test_kubernetes.py::test_created_pod - kubernetes.config.config_exception.ConfigException: Invalid kube-config file. No configuration found. ``` Example: https://github.com/astronomer/astronomer-cosmos/actions/runs/7726274202/job/21063656952 I could not reproduce this issue locally, even when using Python 3.11.8. An explanation I see is that the built-in `unittest.mock` library changed in the Python version the CI was using - and for some reason, the way the mock was set stopped working. I changed how the mock was defined, and things seem to work fine. --- tests/operators/test_kubernetes.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/operators/test_kubernetes.py b/tests/operators/test_kubernetes.py index d0bbb565d6..be8624942a 100644 --- a/tests/operators/test_kubernetes.py +++ b/tests/operators/test_kubernetes.py @@ -226,13 +226,13 @@ def cleanup(pod: str, remote_pod: str): test_operator._handle_warnings(context) -@patch("airflow.providers.cncf.kubernetes.operators.pod.KubernetesPodOperator.hook") -def test_created_pod(test_hook): - test_hook.is_in_cluster = False - test_hook._get_namespace.return_value.to_dict.return_value = "foo" +def test_created_pod(): ls_kwargs = {"env_vars": {"FOO": "BAR"}} ls_kwargs.update(base_kwargs) ls_operator = DbtLSKubernetesOperator(**ls_kwargs) + ls_operator.hook = MagicMock() + ls_operator.hook.is_in_cluster = False + ls_operator.hook._get_namespace.return_value.to_dict.return_value = "foo" ls_operator.build_kube_args(context={}, cmd_flags=MagicMock()) pod_obj = ls_operator.build_pod_request_obj() expected_result = { From ca5ef3bb2de94aae145911e8c7604f4d2a73a477 Mon Sep 17 00:00:00 2001 From: Justin Bandoro <79104794+jbandoro@users.noreply.github.com> Date: Wed, 31 Jan 2024 14:14:36 -0800 Subject: [PATCH 092/223] Add `connect_retries` to databricks profile to fix expensive integration failures (#826) ## Description Since we're seeing intermittent failures of the expensive integration test, for example [here](https://github.com/astronomer/astronomer-cosmos/actions/runs/7702383256/job/20990581739) with the error: ```shell FAILED tests/test_example_dags.py::test_example_dag[example_cosmos_python_models] - airflow.exceptions.AirflowException: ('dbt command failed. The command returned a non-zero exit code 2. Details: ', '\x1b[0m21:08:13 Running with dbt=1.7.6', '\x1b[0m21:08:15 Registered adapter: databricks=1.7.4', '\x1b[0m21:08:15 Unable to do partial parsing because saved manifest not found. Starting full parse.', '\x1b[0m21:08:18 Found 5 models, 3 seeds, 20 tests, 0 sources, 0 exposures, 0 metrics, 535 macros, 0 groups, 0 semantic models', '\x1b[0m21:08:18', '\x1b[0m21:10:52', '\x1b[0m21:10:52 Finished running in 0 hours 2 minutes and 33.98 seconds (153.98s).', '\x1b[0m21:10:52 Encountered an error:', 'Runtime Error', " HTTPSConnectionPool(host='***', port=443): Max retries exceeded with url: /sql/1.0/warehouses/*** (Caused by ResponseError('too many 503 error responses'))") ``` which can be sometimes be resolved by rerunning the action. This PR adds a [connect_retries](https://docs.getdbt.com/docs/core/connect-data-platform/databricks-setup) field to the databricks profile. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- dev/dags/example_cosmos_python_models.py | 2 +- docs/configuration/parsing-methods.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dev/dags/example_cosmos_python_models.py b/dev/dags/example_cosmos_python_models.py index 7d9a61465e..3aa3139136 100644 --- a/dev/dags/example_cosmos_python_models.py +++ b/dev/dags/example_cosmos_python_models.py @@ -29,7 +29,7 @@ target_name="dev", profile_mapping=DatabricksTokenProfileMapping( conn_id="databricks_default", - profile_args={"schema": SCHEMA}, + profile_args={"schema": SCHEMA, "connect_retries": 3}, ), ) diff --git a/docs/configuration/parsing-methods.rst b/docs/configuration/parsing-methods.rst index ddea0606ce..14dafb0212 100644 --- a/docs/configuration/parsing-methods.rst +++ b/docs/configuration/parsing-methods.rst @@ -51,7 +51,7 @@ To use this: ), render_config=RenderConfig( load_method=LoadMode.DBT_MANIFEST, - ) + ), # ..., ) From a66f1ac260016d17d199c99e8962f194f7d3a6eb Mon Sep 17 00:00:00 2001 From: Daniel van der Ende Date: Fri, 2 Feb 2024 15:58:05 +0100 Subject: [PATCH 093/223] Add Azure Container Instance as Execution Mode (#771) --- .pre-commit-config.yaml | 2 +- cosmos/__init__.py | 37 +++++ cosmos/airflow/graph.py | 15 +- cosmos/constants.py | 1 + cosmos/operators/azure_container_instance.py | 133 ++++++++++++++++ cosmos/operators/local.py | 2 +- cosmos/operators/virtualenv.py | 2 +- docs/_static/cosmos_aci_schematic.png | Bin 0 -> 54870 bytes .../jaffle_shop_azure_container_instance.png | Bin 0 -> 469356 bytes .../azure-container-instance.rst | 135 ++++++++++++++++ docs/getting_started/execution-modes.rst | 38 ++++- pyproject.toml | 24 +-- scripts/test/pre-install-airflow.sh | 14 ++ tests/airflow/test_graph.py | 8 + .../test_azure_container_instance.py | 145 ++++++++++++++++++ 15 files changed, 538 insertions(+), 18 deletions(-) create mode 100644 cosmos/operators/azure_container_instance.py create mode 100644 docs/_static/cosmos_aci_schematic.png create mode 100644 docs/_static/jaffle_shop_azure_container_instance.png create mode 100644 docs/getting_started/azure-container-instance.rst create mode 100644 scripts/test/pre-install-airflow.sh create mode 100644 tests/operators/test_azure_container_instance.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 368933db38..2017424d79 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -34,7 +34,7 @@ repos: args: - --exclude-file=tests/sample/manifest_model_version.json - --skip=**/manifest.json - - -L connexion + - -L connexion,aci - repo: https://github.com/pre-commit/pygrep-hooks rev: v1.10.0 hooks: diff --git a/cosmos/__init__.py b/cosmos/__init__.py index d8704b3463..c5a0ce8456 100644 --- a/cosmos/__init__.py +++ b/cosmos/__init__.py @@ -88,6 +88,37 @@ "kubernetes", ) +try: + from cosmos.operators.azure_container_instance import ( + DbtLSAzureContainerInstanceOperator, + DbtRunAzureContainerInstanceOperator, + DbtRunOperationAzureContainerInstanceOperator, + DbtSeedAzureContainerInstanceOperator, + DbtSnapshotAzureContainerInstanceOperator, + DbtTestAzureContainerInstanceOperator, + ) +except ImportError: + DbtLSAzureContainerInstanceOperator = MissingPackage( + "cosmos.operators.azure_container_instance.DbtLSAzureContainerInstanceOperator", "azure-container-instance" + ) + DbtRunAzureContainerInstanceOperator = MissingPackage( + "cosmos.operators.azure_container_instance.DbtRunAzureContainerInstanceOperator", "azure-container-instance" + ) + DbtRunOperationAzureContainerInstanceOperator = MissingPackage( + "cosmos.operators.azure_container_instance.DbtRunOperationAzureContainerInstanceOperator", + "azure-container-instance", + ) + DbtSeedAzureContainerInstanceOperator = MissingPackage( + "cosmos.operators.azure_container_instance.DbtSeedAzureContainerInstanceOperator", "azure-container-instance" + ) + DbtSnapshotAzureContainerInstanceOperator = MissingPackage( + "cosmos.operators.azure_container_instance.DbtSnapshotAzureContainerInstanceOperator", + "azure-container-instance", + ) + DbtTestAzureContainerInstanceOperator = MissingPackage( + "cosmos.operators.azure_container_instance.DbtTestAzureContainerInstanceOperator", "azure-container-instance" + ) + __all__ = [ "ProjectConfig", "ProfileConfig", @@ -117,6 +148,12 @@ "DbtTestKubernetesOperator", "DbtBuildKubernetesOperator", "DbtSnapshotKubernetesOperator", + "DbtLSAzureContainerInstanceOperator", + "DbtRunOperationAzureContainerInstanceOperator", + "DbtRunAzureContainerInstanceOperator", + "DbtSeedAzureContainerInstanceOperator", + "DbtTestAzureContainerInstanceOperator", + "DbtSnapshotAzureContainerInstanceOperator", "ExecutionMode", "LoadMode", "TestBehavior", diff --git a/cosmos/airflow/graph.py b/cosmos/airflow/graph.py index 8b97f4e659..1d662e7f45 100644 --- a/cosmos/airflow/graph.py +++ b/cosmos/airflow/graph.py @@ -25,6 +25,17 @@ logger = get_logger(__name__) +def _snake_case_to_camelcase(value: str) -> str: + """Convert snake_case to CamelCase + + Example: foo_bar_baz -> FooBarBaz + + :param value: Value to convert to CamelCase + :return: Converted value + """ + return "".join(x.capitalize() for x in value.lower().split("_")) + + def calculate_operator_class( execution_mode: ExecutionMode, dbt_class: str, @@ -37,7 +48,9 @@ def calculate_operator_class( :returns: path string to the correspondent Cosmos Airflow operator (e.g. cosmos.operators.localDbtSnapshotLocalOperator) """ - return f"cosmos.operators.{execution_mode.value}.{dbt_class}{execution_mode.value.capitalize()}Operator" + return ( + f"cosmos.operators.{execution_mode.value}.{dbt_class}{_snake_case_to_camelcase(execution_mode.value)}Operator" + ) def calculate_leaves(tasks_ids: list[str], nodes: dict[str, DbtNode]) -> list[str]: diff --git a/cosmos/constants.py b/cosmos/constants.py index 96c5bdd070..4741d621d6 100644 --- a/cosmos/constants.py +++ b/cosmos/constants.py @@ -50,6 +50,7 @@ class ExecutionMode(Enum): DOCKER = "docker" KUBERNETES = "kubernetes" VIRTUALENV = "virtualenv" + AZURE_CONTAINER_INSTANCE = "azure_container_instance" class TestIndirectSelection(Enum): diff --git a/cosmos/operators/azure_container_instance.py b/cosmos/operators/azure_container_instance.py new file mode 100644 index 0000000000..903524533d --- /dev/null +++ b/cosmos/operators/azure_container_instance.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +from typing import Any, Callable, Sequence + +from airflow.utils.context import Context +from cosmos.config import ProfileConfig + +from cosmos.log import get_logger +from cosmos.operators.base import ( + AbstractDbtBaseOperator, + DbtRunMixin, + DbtSeedMixin, + DbtSnapshotMixin, + DbtTestMixin, + DbtLSMixin, + DbtRunOperationMixin, +) + +logger = get_logger(__name__) + +# ACI is an optional dependency, so we need to check if it's installed +try: + from airflow.providers.microsoft.azure.operators.container_instances import AzureContainerInstancesOperator +except ImportError: + raise ImportError( + "Could not import AzureContainerInstancesOperator. Ensure you've installed the Microsoft Azure provider " + "separately or with `pip install astronomer-cosmos[...,azure-container-instance]`." + ) + + +class DbtAzureContainerInstanceBaseOperator(AzureContainerInstancesOperator, AbstractDbtBaseOperator): # type: ignore + """ + Executes a dbt core cli command in an Azure Container Instance + """ + + template_fields: Sequence[str] = tuple( + list(AbstractDbtBaseOperator.template_fields) + list(AzureContainerInstancesOperator.template_fields) + ) + + def __init__( + self, + ci_conn_id: str, + resource_group: str, + name: str, + image: str, + region: str, + profile_config: ProfileConfig | None = None, + remove_on_error: bool = False, + fail_if_exists: bool = False, + registry_conn_id: str | None = None, # need to add a default for Airflow 2.3 support + **kwargs: Any, + ) -> None: + self.profile_config = profile_config + super().__init__( + ci_conn_id=ci_conn_id, + resource_group=resource_group, + name=name, + image=image, + region=region, + remove_on_error=remove_on_error, + fail_if_exists=fail_if_exists, + registry_conn_id=registry_conn_id, + **kwargs, + ) + + def build_and_run_cmd(self, context: Context, cmd_flags: list[str] | None = None) -> None: + self.build_command(context, cmd_flags) + self.log.info(f"Running command: {self.command}") + result = super().execute(context) + logger.info(result) + + def build_command(self, context: Context, cmd_flags: list[str] | None = None) -> None: + # For the first round, we're going to assume that the command is dbt + # This means that we don't have openlineage support, but we will create a ticket + # to add that in the future + self.dbt_executable_path = "dbt" + dbt_cmd, env_vars = self.build_cmd(context=context, cmd_flags=cmd_flags) + self.environment_variables: dict[str, Any] = {**env_vars, **self.environment_variables} + self.command: list[str] = dbt_cmd + + +class DbtLSAzureContainerInstanceOperator(DbtLSMixin, DbtAzureContainerInstanceBaseOperator): + """ + Executes a dbt core ls command. + """ + + +class DbtSeedAzureContainerInstanceOperator(DbtSeedMixin, DbtAzureContainerInstanceBaseOperator): + """ + Executes a dbt core seed command. + + :param full_refresh: dbt optional arg - dbt will treat incremental models as table models + """ + + template_fields: Sequence[str] = DbtAzureContainerInstanceBaseOperator.template_fields + DbtRunMixin.template_fields # type: ignore[operator] + + +class DbtSnapshotAzureContainerInstanceOperator(DbtSnapshotMixin, DbtAzureContainerInstanceBaseOperator): + """ + Executes a dbt core snapshot command. + + """ + + +class DbtRunAzureContainerInstanceOperator(DbtRunMixin, DbtAzureContainerInstanceBaseOperator): + """ + Executes a dbt core run command. + """ + + template_fields: Sequence[str] = DbtAzureContainerInstanceBaseOperator.template_fields + DbtRunMixin.template_fields # type: ignore[operator] + + +class DbtTestAzureContainerInstanceOperator(DbtTestMixin, DbtAzureContainerInstanceBaseOperator): + """ + Executes a dbt core test command. + """ + + def __init__(self, on_warning_callback: Callable[..., Any] | None = None, **kwargs: Any) -> None: + super().__init__(**kwargs) + # as of now, on_warning_callback in azure container instance executor does nothing + self.on_warning_callback = on_warning_callback + + +class DbtRunOperationAzureContainerInstanceOperator(DbtRunOperationMixin, DbtAzureContainerInstanceBaseOperator): + """ + Executes a dbt core run-operation command. + + :param macro_name: name of macro to execute + :param args: Supply arguments to the macro. This dictionary will be mapped to the keyword arguments defined in the + selected macro. + """ + + template_fields: Sequence[str] = DbtAzureContainerInstanceBaseOperator.template_fields + DbtRunOperationMixin.template_fields # type: ignore[operator] diff --git a/cosmos/operators/local.py b/cosmos/operators/local.py index d277670ac3..123f4f5fde 100644 --- a/cosmos/operators/local.py +++ b/cosmos/operators/local.py @@ -12,7 +12,7 @@ import airflow import jinja2 from airflow import DAG -from airflow.compat.functools import cached_property +from functools import cached_property from airflow.configuration import conf from airflow.exceptions import AirflowException, AirflowSkipException from airflow.models.taskinstance import TaskInstance diff --git a/cosmos/operators/virtualenv.py b/cosmos/operators/virtualenv.py index f2fd4b18d2..6dda4dbac9 100644 --- a/cosmos/operators/virtualenv.py +++ b/cosmos/operators/virtualenv.py @@ -4,7 +4,7 @@ from tempfile import TemporaryDirectory from typing import TYPE_CHECKING, Any -from airflow.compat.functools import cached_property +from functools import cached_property from airflow.utils.python_virtualenv import prepare_virtualenv from cosmos.hooks.subprocess import FullOutputSubprocessResult diff --git a/docs/_static/cosmos_aci_schematic.png b/docs/_static/cosmos_aci_schematic.png new file mode 100644 index 0000000000000000000000000000000000000000..ece19a4269af094794fcfd8536cfd37412ebfcfd GIT binary patch literal 54870 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

I1@q4WC$;~bJ2M5lPzYl4%j+EeUl(1Gd zS04n0YO8{qG78sJ#xpz4aQZ*5flAd*c&vVN?RJj7-B^TfM()m9i3IKqO&y&wI5Say z3fBy}4Ir}l?IfVb-mka}*Of@LDlp?=kH1%Yvxnw;cf zURBBb1wByh0)oX_nVei-^e9atM(L%^Wmtjhte~cD1p}J7Q%t&*Ra$yf$CzH&rsmJ( zelt|%D`b4*!iL==)}a;M$4D_SIlUKnE0JQT?aGO~44va28y$OKFtS0KXCEW?W61Hy zL25XsVyIC4FT68ZBcM`p!2)sTVL1M^q@u4U=c^Jt+j>hUiAbLE@{; z-1rmf04k$j0POk@lfUWaW}ru8RMMxyAs2|sUjDr}B*|R>adv;e8mDs^I?zEbC&RP+ z&fddI{FOpFCba;o>9%~_{`;4|qrW`3+vj|5W{}}`eKoa?WU$#)KgNR?!&BT!2&(vIT%xE$Bbk2sx(O}ou@SOij%g>`# zu#uDCGgeao-2-O1e+Wy0!J0er6!;RjsslPL@_tny51VeHM z6zbxpD7tq1`Np$UnqHwk*8PW~%wVA1{!%8tJoXG{C009uoA(QltNK;emVugV*mjok zIWTCS=GM&qq`r>hx(}B|FN(bre&XspdmMzlS1%8cQD^2dhJ!cWKn?${bRJ!c)5wM4 zb`2`E%7{?+>Nvadf;{ZrH0eoItOdSMT8~6HP=9q0pTl7M0%awDC*C>Vls^gl%19mU zSYr*yY*1kaNdnUmKUzq~@3TRPtQ00@MHddl;??{^PtX4o53aS1XU%!RUTanx!BQrCOD2I3b?m2K@RS)v1^J{`54ee*AOL?M*2&~ zbWqr1y-)jju9D$^WV{u=BkkgpMPq{INBQb8zq;WW;}JhHc@y~L&$yTg+Y!cV5_)on zsIIUGXk6fEsIY%jQB}HUkhWae0^nY_bI^M8S=fFp(9yO2Za{9ZTL9OB$(|K~`L8j$ zmtFlpDGOCaOM#b@Xe#U`Ale;+$iB2~J!@O^TyNb1D@Pt{e@(j(--1o61z&u(7Wb#v z4zCvM!Z8rdzqS!!k>pNLfaAwy;iB2w8};Kh#8!MF^t31v_acE8;d0o=$2#*g0LwqY z{Umi@3r;|jIb$U^D%0MaB&vQY0x5#2V?D81_w=1f zgZMot3F3Oj9m4ztIBP^PU+YF=rg~bBhH)}j!wbEl@?g!en~?bhUH`E6)>`k-S#e0q zTV$!>!xguwa4{yD%D74*3yLrV2R3wjkaBzmHGCZUUA3EVjFVdcV;QHNcmDfU_;>*y ztSR-tnqT;gBNWpvEKnx_U$_4?p<-c>3bGz=L;0^`%pK?7 zf6Rc{4HN@)!Vzz9U1_xxLG5KX{$%L(pRT3Ynxf|C@8Qx@{#>>|q3w4TALl2|L=?D> ze+*7YdaT_u6g;3zAD~&&|4hb5|&e32buQ3VXNbL2*{~x*n*2F z?}d}oYB$qCGJ}x-8AEqUmk}(*ik%ih8ReRYfg5QQK4UZBX%9K30=A?2M^H;ntUi!* zV>G@gbvb^kxa`FCo0q*{frO9W zp-m{{e|X$!l5AO17_vro)(mU{R7 z8zIxoPjRs-=XEUmdP;>=W@;|_s>WFB4_x$ExitU9DMP}=y>L&}E@AcdSL3H2oC;fO z_=qRLutyW;ZU((hHK#H>wHCjs--*M8<7wf$GS$nGKS2|oYQa4{`nB<7y&Ke1s+KGjlBsEeV^k=F0$)%J7hr{|j**9hH4N+EVo1Ram zrJ-LLlYP5$I-A3qodZ6dWPkGU)Z^9WLF>s)^FADCfN(&JKsxs-vv|}=j@YolFR}l4Avs+}^ z>LMVYcY_m$RdVrsv#iHsumKdpGK6=;2XPl7_8u$7w>V;e6+~Tp(1lu1_IlvMKz9e1l`~sYqlyl|ojc3eZ0ibr(rDQE3}h1@pwS z%Ss@%uX{iay#4isEs{K$P40*D;KV+f_hHCSjJ0C<%{KL8MuNXgdMA~#?N0~N65vjG zr~HJmmVIvwZ76PbIVsb_hrXB(_kVTQTl2||Piv;?9&I_@cqx zRC)`@g_%ndIXU%7EoL*D6^c03x&A9k)7-GI2W)5QpSe)TvoOQsc$wlkSb#7}82Xz` z_4_TohhR3865Eyj;ob+wKtE<(iuS(>^hrnCH)@;vf;{(vTP&gTrtJOl8_9ini)t#X zmY;DqKG77eO&hK4s`L0fr8f0q3)-}IMK|tk-hSrROF_k3+a^W!W?)T(ArTu1KHIQ* zEinjAi2lSBXv*&_0Q|10;3)5{5wC1?8mRK8hcBNM?Y{L%I|j0Mmvo2rV~AJWLYirn z6Xu9+B<8__vMaz1D(Xs?bf0yzfXV8KH77hvLM1$Zh-xS2&m-fZ>XiF)lY#fGn?swj zt}&0!M%vw@U$lG9abttj0oR9#UL4R@oc04q^VJw|?d02VvvrFc_QeGEr+;P7-Kg-s zI`4Cok~pDlg9&&5U|fykQ1)`zHvBX-g&VeKFsa>Ae>1r!lHYyR$ljgQic9{P_Z-r)1lA~PDwr);iL%RhZ%_L2`f^#GZ$`tHLiFF75$dzsEd%vuYZPX{y zRwO0SbdH)ZD(0_Xle>!vFl9~JwxWmgW7LC|^y1VFnX;eMmA!DZA&OJ*{M95(Y=wT$ zhtOwlkzTY;`=gd2*Q=DGrLH{uOwFJ?8lG12O&vH$~Rm!kj{9Uv<5DZUv=c zNE~QcYMNQNALiKR#zE7?=%qWb>{xlwU4@twQ%wB4M-hQ2ucI1uI5PTZ9pS(^{0n}z zWy41PBXw`)o|ELRF1Ug5@+(J)b)S};Yrmo|#OOgQ*mo78pm1wh+rnReb$xw)eto@` z_@EcJHHF3N>*E(#?Ht1)N;1As9PWC0a~7;yDRYGU-kW!!_2{2vXTq#CLAY*=>f5bY zJbfwis$geDET>z60>%B+|PF02@l> zK|mXCAkSZuTKUOBlbUY`ai=82mrmQ2ggJ}~^QiPU?Q0(2Ic;lJNkjlDJh^wOPG@|9 z);{U=K$eSvlhKpGt*E3t?MXbWVP{9z+y)i8$KpwHbMleX?77lQ{Xbe*oJ$59w7u-j z#KaJ;i}HVz{s|i3S^MpRzMoE-sWYYTz5;n}7GC9nDNrW+O})AlZZ^;%-q^=AqQG{} z39Qtc48m~A^c+2yITh4-r8cLuQ&T&)n&!Eu#Yw)|WP4PUC-1J2q7V=ck*nIzv^5(k z88$J|6z5L2pBI%$(B41X|cBzs{O`xrb92$o{yL z=}**2c|uS(nftU+S(zB7+@{{!rjw&sXy~lYMK7%J#TK-*~@V6)?OGP2W$$J2M zgIlw1taY9Bz|q|BP4h4-cl_bS-d9xp9b*y#U&o9nh7V@r!T$}dx#~hk>J@4%7AkKbG9_}Vewfh4IOECru zI5g7TyB};YKT~n^*PqWRIQ=eK}G`fB5zp73OVI^D9wIoV{?>1n4$feb;NBwCBF+0$qkM$n;n^2Nt_5rnl zO?-0U2X0JFSEme;BEAlU-c?sDR|<^m?A69^_u+toA4i)D?b&ZbPSVmHw5;C7oVE7) zXw%PNx%9J&56ydX;K7HI2q@=Z3GT&j3fu*^Is&AidzxHhN?WC2l`hxx07O*J&6J9Sr6}qd-OTC z`*o?*cCp_GHI# zfr+<7VLef$L{18oq<{7{U?BoNWg)P2=#GW?wGlB`R;6qiGUdtLd;MV0C@vhL2}-9>4u6ca31u zZ|z7yWyBWb6sDp`b3Ogl-iZYDo-xPREB?Ki>dv02c$*rXgvPP3ba&cSi8)15K8#@d z^0pt(=a#fwYpVs=Ll>M|%9Cc#y@Zhp)CZ{Z(KpW17(4|#DBXtDt&e(jlwz-`*k zJi~_HRa0+Dl@gWem&lrabaogx-S5x=fimv|Eo5*>_Th|x2tPmA$-L>%BG5k}zE=pL z;_QW}-0i>F_tt+qqGs#{)Unm%1zb3Yo^QS5F!az$J;mEjZ`Nrzh2VPKS*eU_8APww z(JwU=i+oQY+C<5CzJ|++gc&tr55fe}v7tNPLTX&N)>xTua|a$z+4*3cccYJPvV8Wk z2V-p-OzDLt?}PW(4IZaOer2&jVcWPCz*bb}$kH)Fq3_qs6e7f@R5AUj-+h<+&S0?b zHtuWA*INzPOA#fzz*DISKOu0s>#x3K{1MZ7G)%?sVp4Xm7QrK zwv@wIR+i87f;7Vj-Wh0QAvGtU_O=m%BF+Q691T3aM10@fq2uq8{@jGMzUin;zlyH4 zp~%QY%CL=>!DQOsk#_lp=)~#N%7HDG&JYDH&J{EMsBDVWNfIHHG6%(WuYvd6Qo^o!-%h)BG2tBzUmYs;ok}4hZ#?X+{;&|os@q1LTZxBTEXvr$v+%Txbz+!7ZflJJg(7uC03$iLcxpS4 zKJnz&2p>0nmu9Wp3sF1KqHSOi)T!G5b?HO5g;1B~U@<2|``Xk$=Oa+k9fD0bega9- zCK}U0GilVJqht7aL(B$?)+nAAY_c&*fV1LFjpeX_=dco=5SujKmRrjdEqX6=HwCRX z+;Vo2;5S4Gr98-s$D@F)9-M>1zk{5LdbG}d& zq1-HCe%+y#pB`NPNNQBlc7LC5h`mNY3M=^VU8JYoXe@X+m~#5Ols1hIh_qI3#rbt9 zhkcx>F+3oOsJvd(9%i3Yag+dgR!qDfeiG~6_m9!I>rsG*7K-p=cz-_7K@cA|8vhCB zc3OP4-V?avRY**L1o18GDRhuoE@<#~$5lPH0LCiX(jWGqF zGY0dBd9E4xyw1#5p~r%@sz$HrWqeU&>x9#fkH)BbQ>8{==zXsf1Ilxy{7>A$3s4SZ z1LU{X^2&`2qv#pu zAQC)bPPd~meN*bI&&dcnm)qPuEJ2I}P!e7&b&bVI@zVuTmL8=_ruMOfIZWJp?frbH zzPfF~YlEyZtMm(zJFyh_#o0Cr?x|k-V6p(wA&GczjNxO2y~sIOo#Wh z;(*&ecbw#yAaC&TcD`7u8Vx?1aYg32SL^pGVIV&QdJX_~IdbjssOD{*972#MCP6Q< z1?rl4D*R4%X(%g4r?rfR>YOa_HN+Re{>yZT+G82K4Mzi12F{ot+RLUT!D7c)kj-4HtK zYYQfnJT(Lo@T&X>Yn862&08arf)p^;7@?s^)Q>Vuo3H-c3ft*$0CrC=%{xl{Q0x}p znoB&pb(()}-Y6L^5=6~422MStR2l=DSwfPXZRv*5vkf9ylFf9>48Y)e?R<;Ll42zJ zfUCMl58YBSs-7)CSvV+gclpX^6CUR2wSvkEz=K=&ouj$AkNZTMz@XhY6%_B=VdnD% zNJItiK2MR2h}dc#A#u6DUE7#fJ6FVuSE&1jtc3`WZD)P894J%bCrVKhwhG3PU;;`7 z(WJ7U{r*11U+^2BTEUA;wz~8>b1!#>0^0-Bg4BD4Yn7286`#7TAVR-0{B7k;h^>Sm z`(ByB`x_-24ECHmK$O=Nznshf?E6nu{Uar!uyfN18=Qzl(69fYcFMy>6Hty0bjv)z zMP}!Z`ROFUPH%L6#Y1OJ4Oa_6iK%ytu$uwsSY@6DA+wk>)fduax>NJE_TWQFgdjh# z{P1VD7&t!z^ECb-r!=f^Tez;jv7&(lm7IHy7c45uTLDT{(U?hb%Z9d5Jb8ZJhbR^{ zZln3(M1%3vu@;C|GT=H8fnrA?RF|gP-Eka4`qG=FZ}=}c&^N93*-pSo#G%c-e;^%> zB}+Qp9I+!R&$&c_pEt$}V4PIYDa7$z6f7m(Km__{-}r@)ACXPb|M4RV`3R6qe_11{ z#S|6D2ukR~OUBMUqYP?56`yb2{iaAU{4wu+^R53e8DRMSlkAlXTema((q#+K(@u%x zKpn-D&Z|}}im&F4x}8tUB}lp5euC;dAWxsiD^CNj!!QIE+gy?hJaxoh%hLj0NlS0S zL!k%@MJ}S$vqfDOfBA&s#r%0!9gLP-$Z)8alf~hIv|)b4LBZAH?=W)bzQ$rJeCSI| zlh*~jx!s&z@iON>Fjdfp_NY+4-<<$$Z-xl*!3Q1S_piD{`8%cR(*uUCczd+aT0pNU zauoZ&8s*6!@F@Qfy&ttvDA7Vk>4mPd&^K-w?@&EEr;i@^sF+D<#~Lf_CTrlG1tq|_ z(ft0$K*OiB0o_n1{HNBAB>n=gb2!jP+S3YAE&c+Rd&iCPc5-YU3$XKJk#dnSYe91J zhdcMrNxqA<*g-exC@f+V_w(`0Gzi$yn=;$!`VBXE#2KunQXW&zh~t}`|<2ugfxdOOCM_p@O0fy zPnhf8#_zG}olUh(S5AVfm){db*^i{`LuW2Gw-4!pWGX4)LyO7tj52<%c8X?2Wf68o zt+=O85$bJ?$=*8InE&PH<`yAx^f4|9_gDSDQ;E?a_{+_!^6}PO11J^;Jjp+01?Jt7 zK@X;ZF-GPmv*V9Ii=JA11wnn1MY(66b!fE`Kaf%g?S!G*Xvn2gT z#X)j>O0$wLv;6dgPS+#z^T(Oa%RnaGsp0+V9~Q}VB!`(Db7GkFj0v%~8FtzyUO2v( z8|9zu)~R@Rc}0YtymuVl!PmVPs2hwO!SGr>`bgAYKQK!nn-l`DFs;FMJZ4X zzQfZdryv&L-&p{!Y(9S=^1aZGd6mx+H{YQ4nCylk?UW7?z!yY$57#$SK3^XjZAGka z93k)&YE}zX^(g0oXO*`=%bXc_30+Z)$94JSf4TF+8(7?_uq{&dXJI3 zNNcFvfj1h*&_&NP3;fQB!naFpeZI8KXbXSz4GGfQdG!b}y&~;~2wXCy_#~Ss_4=u( zovIL?EJF83L}<9EKcvJCH**M?&uI5WG5D}Rh`}b^c_)|C_7IU!8Khq_Q(AOpikni> zN+S*#20WVb@FYvY3W|pqW@MkU&>O)k!h)cG+7=Vb$L3XrerfQ%<+W$-R&RH9mz?Cb zdncdYX_Xlft)QMPUl$wqtJ{3Q+7lso<9vgey!LqkAu$`dfD-fg={say-hR`X!%1(S zD80lTy}IUi>~WN!Adt6#LPUIe-8@A}xKSgDH8BP5f@OVky@t)3toBql)eikxsq*U( zp!X;^VODg!9MNj$R8bF`i8e)9`p z28{h?%EHcKH+vY37{9WBV6WtMG*A9|h+y>*{oqqxV~ycofNDZKBObV%e1hAI=)4(@ zs5QqjL}GYznd02cG}V93%3gJ~*$~&Sw$|<9W@LX$dJMj17^z$`>aEkJ?p?baC;`-7 zI!#@+T}aIiz4+Gymb2?t++jp7r7{LGDhbaRy4B)g zjN<2P^@?+!?`2M4!5QT_C+KASq}LdWIN1hZZQ4; zXIneuK}Jj%&Uyb1Y-!*LWW1C8prXmr_einu*6Ih{<9Vt8BgXmZEyq@MdOV-nY+Te+ zEU{4tI?)%@qX3d&q zk}VNMc4lOcG9)3PQYk``Y_r&RCWK^}ETyuAkSsHnvSuu0XT}zaW-w;PnC0jDd!FYr zf6ZU-_qpf3?m4gfzURDd=@H@%D1Yhz@QA~|e}o`p(nKW|&LnQu_XPyjJ|yesgCph0 zJPbJ$>hqTfr8mqvrWdV=MnD3z;{P)=Sh%p~nopx5WnxaGQ+)gE;VV-Ig2&A=pCPaB zciW|5RvU{qyM=BR^r^r^_+D%&gaJ~ar>Hwwj}~nlK|pRb*{XxqScGE4rndKefvD3n zaajETyN${NQ$2$7SNX&Jw^c`mzNg?1_B|MvI^fhs=uY!+k`OW@yRH^+X_KG#zk_C5 z1JGsNkuVN8r`sE3Dv2t>=$|C;?h@Wku04xK`WiT3Tp|*Q7Pn@+c(%sEzM^eVLQyZa zW@z#P)Jz|UAfA6~zwJUNX2QYDHx-fC(eS=6LwAF$OdT4KRivG!grnG6<9||xyO>dg zdua?wr=6j+w4;V4|Hq1PdANTMNiz$4s}gw`PovHRWxfE40c_TV$6C^X)|7j3c^#H{ zl@fFl{M6cklVwNJde78Y8LCed_Ob627Rxku_rB5Nn*e}0MtYYvN&{6W5~BbMd^rwG z=(L0@1<8zhcbx2kKSyf9s7Q_*vH#ZV82AYeZUjB!gkR|bdr*8^jzi|UgKV}x2Yl0& z1@J=qJKn5~eacs~#Fxi&`(B|sLcIF<=ex0ruMyWxF$vK5fe=mDsdxBg=TlPX2F97( z4PQC%Y5{hNa2R|7OV;0Ys4)ld(vMT=%f`Yq^_j;1%{tL!>cUBizca*|czBj}tpcR| zHE5<>sIJy{uDh?M{mf9on9YQ|9g+WLolO|9lHxQCeM&aHL1K#}&9h(=-QY2E;mPbL zCD7qO;MF+T_4}TldK(;OwYTQq9?%~boFOVuu9sqX8Po@y%$s(!27@W=^?$yECf7AY zEV2M%QMUFJ+fAf)1$?ligZHc90d&YB;+|{cx5Y@3hEdQVxwQt{GXZ_vX}cf!RbDv? zc5CAB;^XsgSnv8$mB zF61FFJSnjwrf0VUTX_d_gE(Q#%6gj~%@a*FbY^YdM6QYBcda_`M%Kh2Zc2}_GIh_8 znigrsY#N;>XL zdv+mk?yc@uoA2`AH5d01G2{-U-$0bw)o2MiX3*956o!zXp9(jrHEZ$0={WAPBUTMF z2_llXl=e;wR7zcCMqR**ZK2hQ!U^_%r{y+2|fzx>{t`;^?n%pw}7LcfIMeafvY>z zg?J5GSi={6i8+fcD-!mUP}e;Nuc@(?_axfW$#Oh*X3LI=F_C-&-Lf48!9+*m4ccw= zun}9h5MGod?Zeh@%~{hEtfTzGbXd4LWyb|Mn_)sVy-t{F^eM*qN7pI;2c)q6MeWZK zPy`Nhn&MCC$RJ=G+#B_0NLx+G8#eDXce-A}65n>bTcnw{**Kukp~m6BSd!J3#{l^S zG&y0NCNBboELZWjwuA|3AQ8pUz*6t2>F%Jc(e+%Dy+dAo0{AeoLrsPxaTFXKt^Z3X z%Z~{5DE+RaWpJ1db?87TgK++9(o&->Hji-L9$!6GLJ#ZT^vHmj8GE6Bb;NyEECY1j zXmrKZLy9a=@-29$$-eur;T=;u(wQ^BkyWdnI^#ebqF}6#4+cpY!|%Kbh=%9s5n5y6 z)gsV?kJ{9KmMf1M!}&(L$QbfAFFYWBx7r4Ig?gL^_QF?e=6o6Gmmn;|Bh;-ItWFVE z-FEtRXdGeiEUBU6;s%}mXYa5JT^a_8j{)F@<$G8Zrl}o0i2f%k#462|jP|#+E9&)} zkalKt1?w-e{d|Ad+;> z8JULfntZ^)KO$oYnD>67x1sCIIK=F{Jk7*x7K$z8hQlAj@aH>)!jGeca3rUll^n6f z+5L748;}Gcgm$51tA1SeforY@Y-w~ z_<>%70{uXO(8esb8H? zfbnG!sQf@726Kx7H3ukD>oRr9w2tTfZ^##8!C&z<6oVJ~E$NJGU8?L-iw|Gt6~a*U z3Los#;h7Jnn3;NW)X}ydBLWMa!s4%N`ZRdIHbiYTA&he;6(eqL&NKYAWlFe{6%aXCq#GiU!u_)6ANnVc+tn(d5;);8j-&n ze!4E;+sc>s^&H+$*a4}C%`a85{-lrb_@(vF;YmgGOcZvmJ_+tSMCsatvKCG8XX=xW zicHEje4*yZ1J-L>x(|8b1`U}VFGfL|$Ej7YXgDNR3zh#rzsAIZd)9fd$FPEHXPvFF z=G43~M6CDZy$n|1deu)njn25*y{z&qGqw4MpF)Ou}eYnS%D`CsU(X5L~kKe6g zOiOZ)(z45jPa^iV{p*L}N3EnYmT$d3B{< zBK}Q--J9-RfRywAhnE?dE4k~;CTjdvls4{iDb8ksRtnO}in|3Iwe z386D;l7f_>LAPPR^XN+MSeto(&|sD+gx8vwg{jMz8Uxg=F1=5xz)M1zYqlj>p0IN~v(lu(%3 zLl&3VXPYHwMo}f{jEtBd)Y``$8y%1V=0s;mxRnY^ko`1yvuOrjFxr>Zey`2k3@O3* zCQ5~3Lw(G#vNW5y={fpEHt+4IOaCUac#0(3(Z=sSF@zUxY@$#m#1GH0rwAFA3$dfD z%mEhE^!5Ddv9RkelfEVC^>vqob-~B+J2Fr_8%U~g4dDoKW^CY+h>t03SH6nSl?X%G z{83E-8;WH?GE7HGk_Fb?5Yj6p0#NuKhc$P3WAk6!J{2D)X5je-c<5N{rnn&p?; zNR!>N;0EYKU7FXwlHv)vMwoC7%I7(5QXSwUWXyVjkb`dFVmymFAx8t9Dm#)LWGIBf z^G|^28~e7SPE+Ho{DIl5b~sI^zi{etOY4T+zh4Ktd?_ zhXvB-MjiUr;$_rIOQ*)xMWRj{BDQn=O*^qbbNBWmTkKd@&?}QN#JpLW09{-ba+)^Q z<;x9!sZ4Awg|FdINac!u>v_%RkFD<=>I(w3slRYRA(luv+Siw3`Vj~Tpa!j>0} zAWzyK_vfik-3&;Un%xVb!#W!JZootjN-%70Ku||R45HZ7lHnOALjQJ(%Iii6=(us# zbpsn>1!sSUSLJ4g8>(s&#)Z z45nl%+MOW6S;+ZYNKUeV=tAoRwkRyti@_b*LT#-o$&ZM(H*7{e-@xj9C*W ze9BnRs-jOU0cU9vHoK$fzGTMz7%mvRUnc4!0dZivs3=M;N4x6%m%(~7p2*A5iWdf= zitZC@5)jD@*0V=41y`h@gHgjXa}OhP$fdDg`Yw3&7POz@I7&;KWPf&KQxxV%tBN?G zrRYjBC`Xh>@i09wNI^!4Ow{#m8*Z!~Nv|+1P6!IWTr`Y^cUltnSN>~u32x(K(oYd) zx-fjq0V9edB_|H07lW{fxU}rNh*!W5y^RrG-{=sD^QFrx(+XgyXM|!cb7}zWL&wQ? zNI>uHR{(L^paf|eozxxVv2pmU<6Dm410gp~_yxk*QX~iC9sUY#2=%}D9O8qEQQ9D< zgN8SR+Fwio+`2J3Q9sC+ZuIn4PHM_@-dbBUr*{2_NX|N-8BF1)MCjciR@gGCRqxx* zy^$!OUF7En>eTsB%n2m;?8_KrXJJz4I9VoeW6y4JbWH$SI2~1BMSO$*A8tQ)qb!Lz zr+^)+(sZSrw!fqM^ezorQbNF1jYsB2d6M@HOQh>=c-`U&N|p?DuK~A+T=C-4fKHl9KTn61e1gX+^P2h#PBlvkoz$ z{Jvx{p&xZ5l*Ns`aj>;1`Etb~8x%>C**oZ(c??iLLV|A4-cdL(hkqjUWptDteKS?0 zR`#{mlP||GJwW&WiAM3lEhG3X9Dd7Tai=$)@ys@|)%f*%i7V;ReS}O@@+RJ2cA%Wn z%z(1HWQKRF^K{w>Fmqr`Yg~u{@;a=`II^om$;=vb-KF}bg%GOx2`B1TN~gFewQ2zE ztr2;KrU~A4LSV5s^T04;YpuYvEoZ8$8zie&2$P+OOF^(6 zM!Mtkhq6Uc)gKxfBsw!TRkILt;UnUYet@a;8(3~5%4XxoZr5;zC@*w)kJC6ifARGy z$ZJ3+1(VG9eFsM_+_9Bcq|R;MvjlK4{$Nnbg#S6X6U{vAQ&*&7B*s9$k{N_vLEsIF zOeE#zj7Qz$1o=w`GpR5gufe^dQK9q=AB~_|Y@H2}U+2J-yw{;+fWphMu|zZq2?FO2 z2eqk&rAQm%)GwTt<0)ILZ`eyws$SVqU|slwz=~27*Z2DR+Wsk#JBq#lfN$}YLJkHV zV0@pr?XWns(nuRbd}##J9hLOB zsmwb({m*S*5EpU3z7qk;gg?56pu?!-WPUVi{oCRO&Kb92=S974{EQ$n3M%Ms5us1D z0N|H!&hz%O|5Cs}mtXd)&NwC~H>hsQ^vPY!DJoc)a*l9V9|z9v{l-Z-Wm8~n@?s^~HzIg#19cL}NZ0G}$ga1+%tz#Q))Z~C}R+e97_2Ea+Y=dnjbowwdDV&shP8&jc| z_yNCRzi~cy5PZyspOuIKJ_tFQJ}+b->R((dF`&FOUnz<+@6n8~D+2{+R-!+vh~uX! zg!lm@)35p&&!&`|{(8Q9*=XYif=%L={O;Bz8z3e5f9?5Dm5pdwBFuh1;N;;GOC&Gd zRFxz=iZY;l&JHtpniRN^*kw%wS4zsv%xxZA>*%Y!O0o%|Ga3${bdx;MJARjm$ zWrKac+%;&c23hxo{tZgT0eN^cN%-`XTrb@U)mJ4Al^^ZthOK6`vnfJZ`KcowtjHl+ zMo+)|EQ>BD(1q*<<8=Pw)7*S*@IrMF**uXbEE6i9sVz?k}S2hN}#lAtVO#hWq7;KQ7kiomiz+9 zP--|(_z1&Zqlg~9y{ml7-D0t&Xs;#9^!H)spKZIvNo&%#WZ*u14$~$Ju}RZEoM}y1H&56;tia^-lQ{zy zzZ0Xf_gvWkI@BycPZVd%-q?bTIr1JmU_VYZeZ{dbS`dRW zyen@cNs!=gCiNnHiK9rS@&%)}`sN&W0o|n8(yp0y%G#+oz&) zR|-uQxpXM?i&_k07?p0(@TgHh{`zSJ$n1NBIQM$o0^;>^{VRG=zp zJj^KFch|urJ8Tn&ZH&``{@?D6mfsOF_t0am`|+J+DShtKQaoauDStve#VeX>Biq!y z6#{s9Q*7L>Hh&U3Wf}MW`KjQmn&BMwCed}&)6hKKKB6n)vEfknG9@!V_eIa%X z!*3MsBSGQQG{fVRIW-xmX9B>A@bz9@fkwA)4E|$>sqv<(MC%oK)lKJ0lk*ec4RU&) zSIR0V0-x0;e?XAV`z}FkexG+I2VFVKXmHBo0S-i^F6sN}p7@$HdN=5z4Ph#jxAO!@ z`Be3$7@b6t<%euU{j1(B#-Lddrn+V6#b9G(o@*;uUVKR_KqKJy3CQ4B(1kxu)*Zip z$Oh)PYPx>P0RF^F{3nBVP9@GdcM!J3kG0B>NF^dxIL#buMo>kjy1E$OuN29ChZ6Y7BB5 zfB)^3k^$?(ALfWM+V8hc*(t0Jq!t4!>}wSWZRJJriKc<7pTDc|J;$KqN3tR^S7R}( zG;mg*eB}myqzX+|HAE}y{QGW43SKQ1%lKSFq}wd0Xo*p_8f^IBDJRy{Oc!Wo^&xs= zb%sUfwSMm8HgxGlN!aXMn)moYS=7EtHLc`Bwyh^*QnO2g9N%`XQgenD%bebNMf^~% z`|ZdJE51p3Wn_g^C%7+%rHLZ2|spq}+l0V+Z2_s<$5jzP zA2VR(?m-Qmm9q3FKgYPp;sawXXPV=20;!AY^(8;>GbbSXuf;YCvxl{`-nxDOeY#*# zxV32=VK?vm*Q|W;!H>eD=Yg#Yl6ImN%k?R}L) zM}o3iU+GD*#c63J1?)~Cvk3YKF+&JxS$g@9Te*5FL>j+QdTsJM}iw)SGcf z%fZMBG)|uT|ArXVLW>Unotx7%$=i7Q#9N7MsPBBi(#kDhRg}d*)R6;2$KeGK zqSdl94{{A^VM20Su38FsoC%EDIvc_ZQzuRrdgOGxo^}#Q5chp$UdT|d~so%XYr`gqLLED+S z(A=m-P5jEdB)7Yj9SPi@_DKLcbNU6}OTp!&bUN1>9X;%w?q@FKRbnU$+$RL;?+%Jg@hQ-7LsGE?ypT6>J-~>X+`9rtVxI#m zV$cs=L3##Os1ZHt+7p*kl(e#)R=>p$r|*GvDPqmwO|ladY)I2r!05UX zbaHuK1#y*lMdD9$@elkD&7g`)@+q*v1+4<2Me**_DieqAEk8hy>ihJf)*4nEH&HLQ zvc7ud1Q7G}46TqRq-KL?fDPfRB|J=Ck;3o^xqfsp2EEN#Y4iW??>PD`%Cb+%iG@>p z6=;fK8a7(xcw`-+EwIkxv=wMf1))f(N4xUDA3C=TtY}8acGWp^pl+zdVD`zmsba(G z5nz=lv1Hfo#mm3LbWmL0DUcjr*2~%pu5~WEi+5${e3kT&XVIb*mn7<{Q-E2q3GvTu z0mitwwv~6-fd8dJ6U{CxY=R9`qIKp%h*-MmYrF$k2&E<{R7Z8B{9|BrOM>Y9(JTUk zd9cuU$$kt{#U53ahzsTCp`EsZ2fX>|2Nji}uem%M16OQ>WWpMqs;1l@HfAu&9LA<3 zs7ilxfm&IEE#p+v36(m_XAqT$b!W=rr?Y{zHZ{A)MmRs_wpVV(eYBUa`E;X_r}G6) z-|!;*V%UJWIpra| zgMW%Xj|byFO{r@Sy>=$Hl%JsG#S>gx-tipmqrYW4jOFVcEg7&VGIhCXzPz*BkUD-E z^I^k*B$fp}a=0SFom!aqy`}d)rVIk}%kg#LD8HJg8Ig8(7NbV>Tk{hJoQ}n)P{q2i zZdgY)cp4{A4N_cMcRH^OTMW6g8Vtp5Uale_BA3QicV0(wCjL?-f_HZqOFUsm&7w^5 zHh%qGZ4egLi+U@0Ic~-KJ$GQ+`T~AaS3We*x#7&`HY21>jugVU4YjVzwI5c6w75mO2ugaD9?a$6S~VMdvJ$z8Z}!9( z^9c>{Gn>Moe|IR2QLrN8nQW=UYM#z7ii+(!?e6&H#a?eIuJz&{KQ~{8z(SGpLtWc0 z%+sO4vnYVcTNtgk_tyQLP2a`+Hq{mDl}g@xX(dU(&8MBJeUZDS53$RK;PhF{PGD*1 z_Pr!w&Ame_YU_Z2uC~+xZPs4k!P5vs4}HAzsV3zoe}^uO8iKb!cN}k0*BNr?yn}cB z%{g$ka8W;o=?`kEJrB5h*hgoatVi(&*4}!Wl&-0@PLPHF1&ws9hk8*p5!B)V)`Y3AR^9@gO%thNjAx}hu$BigU!`ErjN;V9Vfv=`ew-ES653s_ zvc%fcp(v!l@_Ml#9Kaw;`z|n+N@bX@GU+;qO2f#p{A`p%M4?GBy zFf8_+!khKX+MD@^%gn1Cx5@rNOqVd|$WAyR_m{|miIYqwv{`2Q3i!-TOt@;5XSm*y zTapQzJGbSWmruZ@Ij;z z46VEe31KH~6?II!pJ^CG7;gCl{=={$wgDc(yF>|G=sL+8(cGe}>C z@JrSipTVV~JhYC+rE^k^YKIeN4`2;yvE@sI-<<2|;F4F(27|{34nf^!DMdbUyZ!0UIqQR)$x`nYnu92yuXM2xl?`uHn`;zgJXx>m)FYU zQm!pu`8{mQPaB@R0+?Vngz2^gtlCbKzSWBx!)3q?I~poxBT-(}C)|pk>OB=D-7e(F_sWPSnp@V~> z;fX>q%*d-Ez1$EXlc-}$ij$p2M+$4?RfR0zMF`8fZ+|)b>ex=3Od0%Xss>f;1N<7R z1Joc+C=l{^vBxuFU3I;W_NrYSP07b-#oQB;rCdI7XRObE7kDlj{@s+)9i!z&)aRst zlkludHf<*%&M#`8uHZQc#V={ok?Vczw65)dRcc(r-%p&iJe}rK1wVB(ewAK0iLRR% z%HOt}J$vPxn;)k^#oyfXeo_JmZpsU%lG=-01-(x54?|ffEF82+moGZuMb?GW0TC;k zrw)38_I*X%Wd;@52CBsyg1L*I2n~J;i^gz`zOL5PDXo{EA-}pVr~d6WmIX8|jH|rm zF`P#9ihLJ^lM{Gly4iWR*b5P>JiKHYGp@4pc$T&Ay-k>?!1LCbIp-DVjbYZWp%6r$ zk8QNsj!<;{P(tG0w~mh2hJ2TcE1a}&(JApTcDczfSGC5BAK6)XRJG2_oj`n3q)3Z@ zT7P~m?MtHfnnb>0y7@!`=}C8^Oc=dJJ*U;~g<^sBw4LvDuhahd?+JP4er_mhSZyTr z8)G>6Rk#Kk{^PV4KVho&q_Nh;q^Gt?MBgiqPNg-cFV6$eT2m~vZ+}gq=5FSwx)HHg zlRJ6FQ7~?bygMm89vFXAIxY&$D*+r&1J-ZFw8(Y*JvNABH3zY_*RF&eT_$if2GQ%A zTdwnZbRDG1v5Q>VfwiaMf4>YIXbX=R5CKLK6j(dqaJqcZz+tW%v6Y*N_lVyBDR*K; zmdZQE8$$hkggUj8?IvQhX!}rxv92ez3(P~1?&PTlR;3r(4cD3(^A(9G28t7M<`r)j z?SB?&dSz?z_T!!300BapZr8!f8-r4hk`{_31J(ESKvk-P&2!^z%ZNL-?K9i`FRVvLpHuFgQF|l~3Bkow~wR#-l z_xr^2yJb&RqcF|K=vme9n@Rp6k2LptE45*7dlEd+na(4}Wa_iT9~jHazwZ+m@;SD} z`H1U<@<=s|pO7PseB&C~+p2gB&&`fby#6_J;)@aW({s0`ZziJ;-br-3ZA zqm^tid!b9>{x$zcR%_R`1aLA*+ssxj9P>{bJLF;m`Fi{YeB6Qv+SQ>vR+p>a=uwWq zb-^7jP#-(f4+ai+6E@lHxZ$RUVbAX4$b$vYD~n^BY73cR9Ej@<>W{(lURm7698c{R z4|L`&KgV)dX+K72P+17xC((T#yNfy8%mtYkJS&cAu0bt~ARr~MJ}(&Mm?dMQAG3pT zcutC9=rT-+PEwpm@CNb_{I^F&Z<)M$Hk&~DB7NzDJUdRZwQIA{&bW0%;rgnRb9y^t zJ77voG3$1jVrIX^ZP<^`I`7{L-gS5&u2peK!rtCA_b=i;-R;>BcYs%@c7S3#_zQ*TYy>ty`v6zI-{!~41!}(XugLihZ)oIskuXFGhX}I|3 zxs*SkHfh^w;6Hu2lUPL>Z3B2jrmil05&LRsYj^c?hIvz9!3o14nfQz$6O?)lV?FiL zCnYm!eqFXpxjaf?y0^sVv^R}xJyDM;tGK7$KWua9?a{81z zCWw#l#<=Y}>fCWBY305Y#hJJKvy+>H`qm|TYLDMRTgBcO&&;*)J1cD8rLXu1)Yi{j zP^*x4Fc`1Si#N3Yl*6{Tq`2U>YhSF}Q2%VJ=ue>Rkmzy#XF|bVfg#nQ*wKEafZKQL zsppxO5mV%*+RXJ~7jysE{=F~CB3d8_r#}y(2=MbyRtpR||J`%ce0*Gv?tncVEre`p zKlQ8O1AAoT+^;*SIq_%CEe>E@z?f+*9ORag>r$>E#y=#QgYpJM3EFc?|6?Qb%S{f~ zDE`_d+*KHRs>05hD_(TcEB31H!RRr^6@RPeCw~5{Wb8X{Daeg0Y-|1WYdW2JU+3aS z8M>3Q+!9wePGrjUK>=b?5gn|SeM|0nzO@TQG*!ET~38+aNmp)M$Sr37gk|B@-YvqfTea4Ne%c$kB}D7HuzNq#-PLUp5UI z5zI?q_0DL9FsPsD+AJF#oBuqk0Hzn@2KgyWS%(Q8)u>Q6d4 zm%6p&Sttuy{ezW{(pP_xevwG(`hIq#>^F5fGB_=GQaQsrUfh7j-gtmy5fB+7fm-zY zvxo^HhvgzuH~0?KbH6Qq_T%VEy8A;nm-l7X*%$Wu)(YR-&g-|n-B^3KE{dCw$>@CN z#*L^Yq;E%?m-Gk24o))Tsnk^v%G5+aUkvo*)$KCK+zEdQ>Cx`RGylU9D1J9XnFH6#*P z@qqWitl>Adq^1wg7~)S)N^Lw%ZWk|o|9&P1efn0`(6Ob@R=@Zflp1Hxh<=K->~7xN z@k??jkG7J=?JQrKd-N;8%*ts&N&9%}1)Gk6T{X3Al@&8fac3v8?$tN9Wx4tCq_hi+ zcs#McZn!}|b4PNvymB|qQxN*D=g0Fzm;1}|IjEAK_72yvhN2%~ft#7n7ar={u-FvL zka1bPKQDm8j(S}ZoUA_zo-kWW90ldPyMfaNEjbQL(@EaVU7B7Wi#C`Ns}hEhd#%p4%qzjf{WBY+cu4eoNwk0 z^}98zuG@pu**hPuUVSDS9+XmC{#q>qQTvzmS$x*tHC9k381Q}g;;ok2U(}ZCytid; z2tsy@FxqRkkz*>CmPw!8QNqXyX4+-WjW%exYqO0`srvF|U`{sIqq&rf5|MkvDaqk;8igK{LNzWc_h(_C zuE^7O57{9E++Nf!Bawx9ynchdJxPZWjrT_3rdAa)=$u|THbl~Te?!=aG>HX?G&O#I zP3n_Ap^Y2IPOW>b&g8+5^^C&Oe{es=Jh_<2@Qt-%@);19co@phx9Dr)h6HaEN~Q#h_meXR zitYA00S#cc9i6lO#o!N$vp&689{MxuQ=QRDPOW+uYgj3~%q^Mh->X*=oOJvwxGJ+# z$|i3-oXl0K%~o|*G;crEXZ29x+^Y1l3!e}hq{DWZPc_RgwNU-(3C`-~(GBmP!-^Jw z3T(Fe)rqT0SFBkH&i1*MHd?qhnmKRcOov}wP&?!%fa!f$d@ir;34Rk?hJ7RO{` zL^#;sMPX#C93$4|fdq#)*C&_GauOsRvtBoefiH|jn08y=VedEYIPGm@0*p?SRi7UD zwST{A(=X%aml79qYJ&hb1D*0$cH-m9QWf#NYgnJg-}$)cf?FX+XY^MZgFacWU)-rp zE`4;P*2Y6m@(=2g^gE}m)pcjE?^fEtovrlL-|tGb3}kU}LG3MHPitm4rN44Ytv7aN z0gY|2x_U(iX_|}TusfXBBq#gL*y63akbhEU@-ZSsKSy6to75&BCI5dmRL+i#-Xo0Y zQDATulpl`r{t2#-1x4*(Wq@Gi!q6Nm=J$PL0uIHb?6CKc>o#AtOV)X5$HRjoc4)iH zQVKu{o-++tRNyNmCq;4qJ7kVHgwp;lALJxIc8Y;wmnqXEX6sj-R~%> zo_2^eh2`}P*o}+d!@c~AKlRWu#OfH~_~W47x%j}x@;*EJDw+AF=WC)bs%1v;;o{26 zU;ag_V;=}zLDQ$bRnjk_H*9kZGTk!2m<{w~RD^e{JeoYLKHDc!ZJS0Hg}k{{dhNXU zXZG13E(+U70vr3hsv6Du#FfeDg4gWP^0$>E-CsVG!qqDeGz)vP#3^#A{?SY<`o5g? z^C^FZoj*TLXyU7;TR(rxNuTDV=CYau@!IVDRn4Ot#6$(3mCZ3C6KiC@E(`1#bnd)h zOS0&hdD;fF73?z?dT#1EZ0A)-_fAm}kl1;u$(^mB$PO`J6ZX3#sJAEnS&6$zEytK^ zY_SL&_Wb3e>pJ-bnW*W#EVRnJ({JsWOl^;(raAK$_O_`VAw3_Y<#*4XTDFThVVfK3 z==eQn;b)<$89Q1sqkrn6BD$rmjq^lwjHytV`yigMADuC@yq{_k!1{FM!}@popzsSR zcY*NpXe}P`6O1K~J{MVo)9slzFc?U*w-ki*-B)cJ5vBjZG+L-K(3sl6raRuqe&|tA zd-58um;5VS_?Qk0%rJeF|J8_1gYWThf#`vma{;Nr*Sj4<{gYL+n(j<)OkZi7_4s2~ znekWiwj65<^zA^n)Z_)yedCc< z&vQsTO{xK|iDcBVQU(+71_Ts04xBW0PlL+j5CUdzpddpE)H<*BSar&98A2n7T>+KNC>w zOLHus(X~F7&L=}UF(RHB5I0#dnE}V>2{B@s|s2-kvlh|sup@;4ZAS=NDdJ{n_ zJNnh6m2jH0o@->gBt!~sX;5oRy8qcvG+NPNlEEZY}Pgj;ri{Wc$GR>OH{rnXAt+Gd&_AgJ;>{@%1hmQ%3PxdD-4gCG_n}75%Pp{3X z3>NIxU%dzDNmI^b;w$3EHA9w*A_gLd!`(~6_F>`SrwlRPd<2V3t8?@3a|H}9W(~Tu zkCdIt-758Ep?y62<023!+XRBCqU#zS={TBlRtxjXrVk}Hn!5Up|LjjbG#FPa;G(>U z7I12?)Bc!D2yGS0e02NAY@1V+onzHwC{20lDUo?%vDI^Wr{bGYBsSH^tDz8Z;cM0|oEzPz}L@N?8B zARQr$iK#jyFzJvrnJ^Z|Ous}jFbDV$WA1_*H~1lN=8-5xu<;B*{@#C>z)$A+vGQOZh!~( zW>@7N(o-=|&%5??%UO#{HH(4-_1s@U3y;Zp4$93EChK2+Te|pPxc;1rkCS~js$syR zs5d+4Jufb4kksaU)#~RD`|DN8Trg5DxeCGM@qYG4&;^XjwMH(A%%(hmn9g6OL2W*7 zgk_~nsZJ@V!-cK>b5lx`-3?GHYJR(y{^SUiAk3)k@{@V)W5*D$6!wY z*w7avSU`k8fX6Sp&FZ@&D`9Ljky3nz*Hgmd*YY@Rsk?g*Q7*rw1q>ApVAeiXn_}e{ zy$NC2`gp>hBkfjanW{^l++ELhn_0Zhe`fdI|LJ0ql%9_Q#J(+{jU$77=r(F!meP82 zI9W)!a5cf25_s7EmP1%}Y1mX{Z}n|!&@s26v1294xeCKY*JIOuxH!GDP+pJs_MRXR z*E>OX!mk@tKW8DZVq3ON&o+iX#4MNGnOe$4CiCc<9uUk>oLnMd1mSo&vlncrsq-C~Y)wYtrI~IIiQ_Q3!jkA8wBs;G#g5q zx7jL_*365miq)r#UwdP(y;Y`M7^tZIji`pcxUQSc0=KRtylMC%+3bGP_klRpFAQ9o zoevnk_UC4n5WzR>sQpQ+=#|!wJEwki@)A@V%Gk4%{n<^&4N!fqE_3{DDQ{WZHk()> z+CT8QPwxS&r1CFqTK}1}_*xRttZMo&8mW%-#H@Zz9ep?3?0s>P1skax{&Kxbj>Y?o zRo6N5wx*ue8pH{6J?8!K;vByWZ8X-6g<0{lVfbx z1lr8ZZ+i%o=~rCWHD;63Fp+%>t5Hjx)i{cfT1SjVXpXh>=fc@hvtrP#AUzV}`ib?@ ztkPTmAH?&V1Yb#Znvb8b-UW7=?ZHQ<{)S$&kkpiyiOy6HefA?s{WtPIlMK<)w_`l% z6loUbwLHtW{oR~t$F7!)npM0Z|2VCinU%Xa((5qoX4H|@bw)LGu5!^g?X_wM;d^z| zaP2v5x#BzN+}8BtT25~*<|YG&G=uZl;WdIbS2st$M(6nW9L6{7@icTyJdqeeXD4=c z*+;8w`&96Odj}2vW?$ycf2Y2>Z@Ig6u&;Xz4F9XoPLuXGHGcIaR^225?+^|DrjdCK zz2Lx3Gb;w%&oEAI(pcJi|CmyspWnb+BRNf{y->D z!3yR~!-@fRZeSE4%M##8(TcVLurlnqDB;{pZzfR75|mmt%!rCeW(YF2et@uR=spJj zD@&S4bkMhD)jV!ykuW`mYZ3cxMR|A0rCH!P00_0>GL^yY<#n2wAk|Tzld#*OKl|S^ zb#t3*gb1YzM>i8%rHx=Mkwy!pI|X zb{V(mtDb#D`g;ABM>8CR-1~p?KK)=7%~Pold3&F$dzhV4xmiTaon?Vf={%|$7mfxW zlOVG}4s*jfP0lKjTUg%)ym>c2{z7%|jXL(U=SR;GI%xcz!lVXO0?6P0SE9gBJ)HYV-AI(jW{2CoJ|CjB;OQ{}54vNKj zZq6`4R?_*2=Li#Z1G$=`$%j zDE$vlSzo+ChYdFi)4v~`oeDjk-{?;Vw1C1QBPW5#^S?gyJCg1d^cF1M$-kCg*PgmR zS6a#na)D3mv32f?K4HOXUk`mfw2Rg&9VoF*F`-mcX76oGA>7*V7RH|}4n^YY#P-h& z{Gs1M12VYcHgVD>6yrtTeP&PzA}*o@&xA{!wp~s3{&Pj zQb&`7>0v0^|D)>MR(OG4!^ha5^;5;<;_V-cO4BC8}7Ii!f4oXXikk(3ol zC4@o_n~{)MQXz-Up-c`N#$I;(z50CKy+7Zde|FRDmc4de&*SyH_PD18EZvt9>d%mc zUL`JmpE(|t@1ip2ybUOMXBQvFBEEqnR0}}Z*qCGWT=i~WV{@Zv)DHq9m<{WX_)n~mFjH}TCz1+Q@)p#+cdy;B#e63Dd zig)zsl^rVL0uSdNn*Tyi$+)2?&+7%f?uZCtLfM5a#*0Z>NUu;ZUzAUk1n|m3J@vXK z{Io4Jn2ZQPE{#Z3YTR_R@0VJl5A`dF)T~4E@iZT>+t?4Q6D2uDU^BX2>?`vfF88Yw z7qALM5nuJan&)Gwdqt?H^L#|99Nz9&NcP<)g&A%?47u;`SYB)2CAM)iZ1(t!G)|uj@!s?Uo+cfEO@}UCqReD#06K?<o9*)j=AO1~F8#076&SA$8|jo-Yu!++`{-W2v-12Z{CF(%TTfe%6tg_QwJPl+o<=vWHmn?{&DaE zgDTu~7|?b=Oru&*clvPphgj6Ijr{$OtE1L96BEBDwwwXzXOs4!v-^@HI418`6k}06 z3{yu3s4P9M6)TrEr5?w_%Cj}UIQ9uj^}Vn#hWbX~mPG#U0}jxSQ_>K)-gt33apRD! z5+8R20vExq?2V?l0Nhl%ZK{2p42y9}ud?(-VKmb)x65CHhsatnf?yx`a|VXOw!vgX zg@|WTh^$wH_HmU1yc8AY^ryNv?^T$s&v$=)QRWp>r8N6J`nvjD|M&NsF_lu#!JTT? z&)<<>n6%aT8mv3&c5-Lz_s{CmElE$8e_q)3YN6~Vv%~nUrr#RD`tkv#9?otWi1)$# zBoB1t!5ytSpGB3DZIP1411;N{MiP)AFJ{REx^3182o{>4Wx061p(g=a_I(V(Tqdc0 zI`sk}*}wYOE0$D+j*-6n_KVl2A^rfjn;Z!tu5L3bdOj&aG?ns>bja%ORw+WPQX~5l zpePZNu32sDe#O>p1CeC|N3=MUX)B5^va9h?C=>C z2OQY>oJ%h!4by4Y_0N(v(Ux_6J(Z{*_E?>e`riJV5FwcP^0qEJB)g86KkS_ShVGPl zg;}ztMxAz`#Ls_2>lJfFSEOHLYwdodufZ*1 z$U#}gy$1*EN?(n-wwb<7we{SYGwscd`CuNp-(~BTw3D7{KuDRF6LD#$Mw#;u>jWv> z(_ee{=c1UNoWt*Suh0tcSdHxgtC&kkk(Xd&dUNHbFP>E_K2 zct?}x&dCc_k|6hQ2_et3rA&X3-FDlp?va89eFI|_08a}2JkV{Swh?N&l=-|6(=)}c zc_0pKsY6;>Sz3}4dE4w}+T0EW3$`ADv(RWP_J(}Gk{|MCGHwTGy~L6Y=m{=zEIi9b zsR)8bmbi?uHF+o}GBt_Om)rZD^h2G=7*Ru;NRl=(z2yZ)MVQ_xG*zYZ0{7?(DMYR| zXRj2pL9wW@vC^*v;`LoqA?js; z`31=gRg;asAzqFWfMLpLhWa&Tx9WRez1_5$sjzWI-`m&U$0|;~HS)MS81Q)H7`Hn` z67VrnFL;2I)sYfD*}(kfZw@XYzPx{sFGAX_ow`5W>(Rj~5k$>-*CewIJ#Ta_RmdN0 zilrKS@~;!67A82zV?)L+=GGMgp=PS%u!0w^pJrcI-uX$B4SXO8$gRZP+R2$4T$uT` z_wiWZ>9bTSE=y3U2TV= z^YTJru-&4yl}ouf?3tOWuzJFwxW6Uq@S#iX&HMiDO# zw-fvQHF^GD`W%HeU%OC-r&tj!IH899>e#_#@YVJui&YB9RL+J_nT9p1n=qQZfDDq1?rs`_~;TI`$ z)%w_e$k$i)py^NcK!*WfN;6zRK65aR?GDJY%sLN zEubN#^ShUH&L0bg?qkt2FOJuDNJ{jtSOyTFsmb#0p?#ugbHk#vtF@^jq@dQ1Cq#)( zUm;HuYfD`;#+GhOy_27mnXaDF9C`HIZ-T6Huu^Hq9Q9V^T>lx)1NLt!VpRK{^JTK6Y4KX(@ zFC4qOyM3;aY@GHqV(5*f|04BC^2O9QVGe@8gtCaIJetc99kZ|oeAr`Of3Hi;8;smG zf8AkpJ?Q${o`O*6a@vP6BOdp|7tOX>tc)J(Wmu(K;acBOdg`Ya@1sJu&;nc6=I+-Y zQ;;-~&-->e=zYa)jkZF;!tFbdu0&$)-9#Y=_-$%WPmlsT zxe_U8_Y^->8>KCYyw_DmC*u~Zc^2#Ede}EW#=~&4X`LlX1dbQbUtH(fSMEP1%1LnW zmLZCuMZz|{BIzOplaCH8s_}&daS0lArg)^Y4M3~wNQj?vTl$Gp#`0LX*1(w?A$yKA zsK%wAK|@QQJRe)tjNF`miYfVBJJH+j^cBLVj84*?Y^$U0zpNE}^=wvc6}CdoJ#cF~ z8$XmJ%e(3yy#&3lZbKJC%>MLfNxB*an@o~draE_X{2=g)4{C2|(bw`1fB4NUyHp;q zV>$o8>#SYLAq@xOb0vY{UG|@4-d~h_qqJMb?#XU_k6&cmUAqkh3(bdE z`n$H1jgBZ@W1(XqS~~gL<<7mh+GrAzDXIbZZvONp zH5T%4@9~S@Fq6loz`CHsM8_y+j^eqFS7HA(Ob`we#4%~cd)qMwHRN&Hv$er_kHvw} z6GTWbk&SIKw?h!3STME%*G2nGwU#gWK}AQ^g8yFiL~Nd^!L}Tx^Hzxxm(YC#*C90cUA;ja^l_9RMP;x z;tqa-wb-3lw>r~1{*_k-1NyybDCw@3=0S_xKUSXqCQxxll`V`mjK{76BB@~gkhJHBuy%O+br2kZilMU-AQ$-Sj`aEGHW0VO5_ivnQD^<7 zgNGC4(Gp@J!~wh=w!V(#o4FZ&fE$n>N?p@?FGT4VV*^;tog z)Ai|UhTvS|BdhJ*X@g~%re0LcjyJTr4Y%n+U!S1W+1bo6;ph*74>CH8*iJ-5OuxBZ zv39Yz@ikxYMyHrR=%|j{@4dcB#^ANpwwubatF8vhjBgjE?uJZC{8(?C>qQQjvr zbz0lBON2|e^|;5@XAf>|>F#>adDiD*^r|@3i_D*gcV$M#nGE|>H13Ecxt$z|%#gYn zc%8n`veG>xPOW+-DpX0{LA*c_>5lfy;N-aBXyF0u* zaY6)Y`9Ao~`zvPeZiW5id>2=TwZFche_v$OI+oN{Q&duuQP+F3;%gfIx|4o0fNqOrrn-Z_?dy2NbTB2-y6C=&MHE+ zF8!PtH3^!V?2Wd$RO%r4>0|%lH&Yez63i;OhY1eGHt7KwjXPTynKSBjB^8=Cy%Yj= z9m3qN!<_hj*icnPbLHSlHAjUK591Q(Op%StW{g4;T++o ztiZ+3(;|pWvc59rV~*55&i>YT3Wh}$!}Hf>+IOt~K+BPYu3y|ZA+7?q>nH4aQ$b^2 z-vCFcyjzLGGNzvPi4d@>N~?`=@}hvasK}+Z4z6)5-VB{g)`w!AsfwaC;=#Ms(YZ}X zL|!coq1%e+#8RSH2QT(iyY#$7?N z-1D+nL(cxJU$}R+G!>q1DqogAEylZflzZ=%M2_0E+S%#WB7QNeAK_fkbA z4-d%__eK}nFDV!U)glWcRTBk{_2xe+fTvjk|3XT}Ykuu7Ee$8$!Gr90N331?wa9#C? zX+h`Rq;ORB5t+7sSU>erb5n8Qy`5LOTyTM51UZSV}jGX#7b?9sj+nNgx=nD-H<5j*f@zNgP zj~?Q6{4W?12WR_{pXH~`WpLS_WjTD*sE`{gG_`=t($wl+l(x|`VpDll-{!^{hU*x zS>DjjcbyTj)N6HjjglCZoA2uzSwZ&3L1t`vwRB}VJFDoj+c%|*Zo7I{%#p7qx^~U^ zkxt`*mkC)1RSl8(k6#;YK);rv91-PV9#r#pjsfJOwVgK&%!-U3YLj*ltTYmVbah%l z?d9R&(vJ@>B~wZqqS`LRd7tgf9p!%>cqv9o1NiWtWNbG)0UWW zgoBvXC&uLh64OV^Jax;~hM<+)6Pn^QXWq+|H|f8=W85_E+9odS>r7Y|AxVQtF65VX z1T>&7?Yul7HB_}@>lTMB4F>#r#n;fEB51Q-t9?&?nufTE@X{xiMFg7cr56hZj}sHP zvJXy7L`OVTllw$w!n12~Uan{!Kjo>grNDc6qKEbdU{#ThI<981#u7MS_j_m!rD zTXun!{;YN5Ed)?zp&+Vr?0&D8GUOy{l<^I=K?LD zeCAeBY(12ggqKJ%Dcjc~>kB6=s)*cd1#O*r6qBb`+iDcVI0G?BDia(Q# z5FRaFTz66hl zj1?7TON~RJB!q*wpeHHlJsXiDNf3cI(1o0}lkwqDcPDgzjd#qjjsJrJ5WUubt8&&fAO5Kp! zMGfiUF0uFI0pgLkE1B8l)ra2-U7zi&xyDy}o}2x4lU@0)N1HImMs8?KAx>L;nY$3( zfY*=J$=(PLGsH%r_Q+K{S?JA(?zI*5GHh`07dJw|Z_SvP zW2Dz3 z@glrDXJdjew`Q&xL5#*l*zV%ci-j{PE;;O|%Vb!05x!d0TACQxd@kbVR&nfa#LJ44 zVuzll1BVuD{0XAMj*W(fIVWnrx3|{ZL_i@bHuB2QU@#dv({6&aGQ%mTxnf|WR z{Q;|^mf41#DpAyg!&|kz=;+H0FxD&0rtz80$*eH*#uJb90+y~_%s8L_=E{qbTos$t zAn*mcnP&A4CfhsrQJm+yLynn%5fL8W%o*M3I!Z4R=9hLUZbuO`*9mjGR+0(|2JN8< zOxVV8Lh7#Q1Ft*Iu<(08<|{Mf;-thf?J%#T%GpWN2B^Gc4;KA@5BkzPcvbz zl&w=lZB|dLdH95Bmjq7YJ8Io`cV1y8IWY9Z9f7pPa(~`D|? z=9(MpFPv_|prUsiF_vE3 z)Kgu4r|7ZF!$%eq>4J{2q3w0yXO7o5_CZD=qjK@u$%vQ9JLO*lYi_%6SMsJ*#YYY6 zBi46LJ}mio{N-NerepUc&rX$GGK!ua8OT65Yzp{zY;gKyaQ~C_+A=S`B%v1GCGmJZ zlEOL!%@g&E(t*SmcH^hHhd9my_{D_{RLg>pF7~)0E?aOU9tTK_X$XZ&-P53ELN=g;ImzF~`x+m3)MqDU+iV^uGo5`}A-M~wuhw5-$n*I7 ziYU%8W7r3V%)KHk;sH5ap^_7CSq!-q)Hw}1-462aq}MIF6zZ}9Jw3|~r_(E=u)_Dr zg0yqosk~RUFm_#h9R%X`dJ$K3gHB~&*E+>*(Cldfg)h4*7(YnrCM?R%w4rh>`O7Y_ zo4l6Ha$%G3EA**wmC0n+z(xOPIwc2AxFJ{14VSi;LVu>rexWef$S}Rit9_(eX@O;7 zSKB55daE2k0ds2Zn{uDy#X=?2NKT+NqFYw2p*wyuFf)o_iDT>Qq83B^Zfn5hmoKxu zKXcQ409{95oP0HrAaA&xHT!aFjRPnr7e7fcDyF(J2lmTOZH#mV*qF_{o|B|!bsQsq zhC30@Zoh>*!QYm`3}DVCMPNA)QsLSxbI$F+A1(d~6Tv0i?Q^rszm`b{n2f8Eq@cdK zL_wlDsx}cHiv@y$jW}|>-z3`tae_Bf%GlnL=!l4V43pKW4m7e$75J}cR^eI{{qgC^ z4;pfa8jRqL>bQR_UK+kI^;VZtY)(dgNG8sMvSZck-<(bHc*E%C&AhtdQq1vCR{1|N zvp3HLQ}$lPj4*Je1KWb7aIhkhUzn8eO7-dEi~e0>$-1b?i{NvtygAZO2a@S|;X%aC zgdu+^s6SU6hgd~j0Bm}vweB+F=eXKkVOo#?j+3}oG&0}E9mhmjp`pl zlUn++%h&@2Ogb}Kk0ETj!0>he2xjQD=6`_h?Nt{2^((%U;D76o7R-i7L9vJwE1+iR z8XS4Lt8go*u1u~;2!@o}8rI|OITn;Dts;iO>==V<)%#Ojy(@vG(?~GqwUjj7+)4Sx zwm88F_(=M4-SUxqS%NgYa_X%jsP$N>hf?H4N|QZGv(%Dr!SFI5%A6)b2_$&hRYurtRgeY^EG3R0duqK1qbbL=hRU-he56J)2zO#B$H?|)53Xwvsi=#6!et)<8^p9e96=D0t`}8_#>f}; zCF=C7xP7lS-x}5^4L`jarVFEEb`AM=Yv=>r=H$RMK}^-)y}8lVsCZ#=Q)njf{r`P) zduhSw=vCFezzpKOxuH#hhw3QfB7COJn>qK|1V|g~T_V+VE?kbpG#HBTYNal-`=znJ zvbA{w@N?D#1UhT$mhrscp)@5_r|hXcppnhHj7gqerLn!pq*Ps0kRn3z?Gr*L z*XC>Q@iILAilZ%YRSbYVyn)7SoAubUranDceRxw;u_#}|gqK=jb2O=Bw9DD(DfMGC zS%d$H!R21ILxczS)jh{f^t}MZuG1Hdni_H4FbqAQHXhPew+Zu|?vp04c3?S%{XX9Bt@3B@A%S4`Z?%6mH(7z77Hyp~ zP(g#*u^0|X3ErSQD<>QjDSy?Yk+xWy zKzJ7X8RDlQNRh#~E{3lQoo1ARn2V3oh1fq{1z83HbG!RcF?y%&r5w&Dwz@8Re1X^MFL(mUq=PCZfDJk zpw6rH33w&mU=f0@%{dB678}wr+k9B%pq8&R0(d8zy|CAnX@x@=^|=LiG6o3@-*^s& zu{Tr_)%31|16N*?Y!5tu+rlf1fo@4>erell-L_T!XVn_+d?2VuAF9Y3lO=BctDoD< z=$8=gE$m95Rr=c@4B$KGcIR%OOSL<86uzZPHBU=GXQB&eQd=orILanWFcr!{9e?NvTEK2iHGU~GjAGmlFXY&-^$)**c7nR#KHPO96c=q zrLGUbr`o8Dz^hW>AC?ziN4t_8Q?S6p8>Yc~+Ct6AP~Q!a@uVm{iUV-f4jB*Y_{WGk z7r2|i@C%m{erxTW8g+TyM@*H*D5AFr>NO(JNYB9(=w1Tybhm~Q!gen;LXyf2rc#Z& z5I@LKq+f~Om$`7(h^_xtLk;U3IrgHTiBwouB{xp>?Q zX+hRhXziE35DDiK+)yXFo&JkA-@gJf%ou-_H>@#4wh% z1Bn_(0l{Z#T26NJ`$=@6+kFaq9H|}ztc`=Xoyp}Rg*WIR_n|!&n~)4wj7uF#>)TlUbO6R62dWUjz=|nC%C*vJv=>499Yx!_8o~tqw#^2*Ds&BTRxqrk${Cl z1|o!o4TT36q67 zW9>>v;F2bQQowNd&8Jy^HDy_cnADL`>IZ3TIz2)Pw4PtSthqc+*)fAS&1HI=V15%t?kR;rjc<+D1i;V^pBUl#p=v&? z1lr_n=#kY-9gY=w;D77tCLt6o$g871(P7HLePGg6iW~;nH$@Z$IOS`*GG8llvfK{g zZ|Da!w#g7>vokgL7~HhA@VPy71$9$Wq!^tyess8nvTGsq?q!g)lnH68tV#gIIOiq6 zsN!Kin2Wkd`0{w5VIju!B}2oxc0Q0HywBNWh(*FBd$e6RARdT%Z}aD zgTb!VF#E``Ss4=nU^M6A_mv&B8KBb&5}-KTdLH*64f=oNes8znK78=A%*pd$U8aS; zN+7853>G~NNCP(#tfEUEbp=g#kvlXC?@bq*ITnoBzlDEcX+0{cg*@64{1gP&lWCTxzcvQvR=qe{z8WQ2C2S9sW4>Jc0B! zt9djeR2607(}(=FWvI9%^eDPi9=^0zTcu*a)aQ(zdDs=9_M=1Erk=Mrs@*0lJ~mu# z6GB&!1*zWiMMlfjSIV~Oq7^||ddejlqptBeWB+W11)#^=+K02$AedJFtB-9a0oD?Y zh(}#k8FN}iP+h?&HWwtk+@d~~KzatG(x;p5O6};)umRY|X(%-!yWNf?EV@ORjczL< z>at|e>t!CKWCee5JyO-5y zvWvFRYHe(17Ce z6*-%Mi{nuRyLV2HhLCEqY)qJ*W<|(6_q!v%d!3@^A-MRWV_$3QL-!b-0~A zO!dAG72XGR!Plf@p*ko);c7wN9rn#=QPQn5T{T(Fow*8_?JOuXrZGEES+J-X_fJW+ zal%7Fpk>cuSX2ip@O!Rb2sN`~d9KLpZMZkF;_ImRr|a9WOKVIQ+eT zI&(6yC%Op~v~P`tY-DSs;QVN|QqXl+zgqWir>2v^t*QZ~y=!K^?U0B?>?fpr~_*M>Y?d*7eOHu+pzxg@SO&uM<1k?LDC!weg>N}Z) zP}5`z1zhOAA5)3RW#9zJF}PnLq+>Z%5Xu*~0t#*nT+L3vXEo~@@!V@(mM2g>qC>V3 z6!`X6dQiWYX|jW>ihMy0(eXBdhSun%zcW}(pww4NsX#A_s4{#$x#^(HbCTqVqg`!= z^5!{FfvTwE)MXbxN-}&G8a$EcER^`yQ)g8GXlUhiCShHi8;0bn&YO>fDiJg0~lrJI%nZHQE2D zH7ZLZ@qM!aCPyTix~AH3!0q}s>CaIXTD$r`t~K= zl8?iGMt>dMNSkren8G}U!_W%1npJ0`*t7E3&1;#JV&>@!!>t-ZuX2J0*!CLujC2_W zJ?yI4iYb%REJmc#JCIFoCLH!g$?ZkLv#O}s8$SlE%IUB?LpxT% zbLLD*-r8B(a4Ot{Q^>JSJJQTN$;XE4!LT1x|+d2`2NQ0vkDA(^Y5Q zS3MctIu0CO)I_zV`PhLp5y2b{M}9vB@>GgJgi;LXcdm!`bnKRFh$I8o<1y)g%aa{m(hgBm~aCX}H6NoG|89d(v7@^M7T#m##DywgGUv*_bZYEc0V9ef;6ZkDAsj>vu40R*Fm+yQ032(1A$?k^h9N7i zEQmJ*K@C^anhv--#Nm_So~z8Fw$b3{w9qe|exYInY0P@w^DBwK+K*?wW zUg>hDh6MKMaHJJjcI7w!+|c^N8hvU0Wl#65(XMWzr^pSzNBkX0>;3uIt$h?VPnOY3 zf@JXT$&$D`pHNTvD(%{CflEg&@fxnYd%LSXjl6wOBxDuc+NBPx=V_*zA7;<%xG%3mt0o=}wd<3|b~)!H6_I-?|6 z94`{Gf}Ru$lS9hpcO=4P0rasc6SbBdB%uH06|cTPI~;hKHmVWKApxivGM!~} zor(NL{TbpXIr0dM`dz3=#Z3}o3PI=1$Y}dwE{WF89*f9?y{Ia{82TMWE zNg4Z*PVivO&~R&e?=tbH|B~$1hhG&FM(kuFPTCO+IZ0$PgDa@2Gl?Eu*xW}L`ZV?u zI>Hp2=o(4?PQAq*x&tBrTX>%@L0FPNtG3y$Qv^gee zbXakdqaEUe#~t?N3$QvlOg-cC^lk};j3uDY$66E-^1B%IU~9YXDpXB{bGrcbkkS2O zarmJqW~^&68G8zFZenee6kyYAqkRR);e6{fdO}UTOEBbOv5`oSRJ4dscwvExhf^wO&KmxB? z4iN&ap`bqeNt(YMDZT}^$T`&inM@e%`7;Tbtj!8Ie&7Ao%Z^g*9UpfsyTz~HbN3Cj-@3bUG`suKiHFlgx5+>3l~;3_cuuuoD|zqJ{Wz*={FK(JCh@CN*O<|Hhm>Icc2Th}k?U z+1BBT29H(0S39=5Eq{HGwXndHk%I z+80JuA`XO9VtiU16OkSPn-tN!kH6bidX~V z+H9UsVomDU#((PcS~*sk&y@TsEilewc0}q?Hu35cgiDE5z_x#b!D7yOLFMe9-gz%M zQLrP5nPBeLVVx6&zM#V~7@91%+zy^B5*{OAH*a3-!!RSpYND|H)JN1!=-zqMz?Bl; zPWJsTZdWBV<$q7=B)J{G4mM}rufc|Pt0Shc%Y+1_e2CP8#8W}w78N;HYA;M!RN&1( zmePnSe_LraCuU{EZWWbjV=}m=Ozx9=a~aWkmFmu`oVXB35O8PyWos-pJK*q!kt}N4 zr$^4qnk?cBP(PlO-3-E9 z2I5Jj62gl)Zu_AEaOC;!-d|u{qWK*f5(a}Q%U+)gcfjpEmj+klX+;kARR`*Fj`6_e zdRzl@{QMz;ArN=B{N^}Y4O@sxlAxOMA4Lm1jX2M#Z8{>!OUs*K-;NFg8y7d)^i0I6kNF;ZZg?Kf}FM2uOt{c(@FRF66ulOd_pjwP#FT6dApV1Cm}MMd=HiJ>KQSVVCD zH2J%#fY_V>9s3x9`O5fdQ8Bw_ZJ$GoP+LzkQ5G=(cxcJptVP$ci(;rJ z)6G9I)N`q~SDCws=eU>GNhS6C|IaayOC#);z+tyhrg|J|&Qe^MTgTZ$Mbr#+l>LcY z`eyV=X(i6q+G+JVy1F>;EeSZZwlM(9ej!GAHf8z&CLlYEKQ62kYVS107*5;)KWTfSPZ z{&0KN|5){?k3>TbZ|&(Rs`AV;`w?l&OQ<<>}o zT=nZ}y94KAU;iLeqq+*KhM9;t7V7_4p#jicc2kd$4z}OuG-hC%_nzs+o0hfNHnL2v zM-kQN1{3lM3zBMameS>}VM#A!^GX_OL=%@C%K53Du+d(QTrEFp}Ejt{kaUu6O$@70( z@EA1156b)x$%2PE9NR7MO}tBhgc9nEYG21EYs1xgeaa&G`kcjWAo?|WfE7h@uU29F zDxr`2-^Wi;O`1z5qPC1DK|&O;C+?2d!_ z@E2$S-8xudCWXQJzixoLfdO%pfT|Iv)T=dVEJ7M8&Kcio2sHbVRCcM}4xJbsSH^xQ zSrg$|*JBayKj63%jsSQE0c4)Swg|3Bff+-! z4|>fB7oTl2Oh#BB5V17ICnk=&^qA@>C}r6H)56}Y9idW3n{}h2*la{04VFM7z`h%C zV80X>Xb`EIp*?zl!I0JYL@HyYu{HsD@9_CdiUQwtkGWKkRDzPxr3#r+W`pM}b2Sz0h6-j2&Q|Q#YM$d}|=u)te0_EAu?|w=8QX#6`A`MPb+uh%G-qZ4GmK2jBHel}WU_ zyV6gGc(AQ^V!lhnOW-~`F=usoe0`wNghwg3P5s0O4HcF_rTnu94Y;v|380=7KpkZkPz?WDmP>!+N|TUSLX5ELSs5}?}=)5Rhd zN7&`(@TA~!!dAG3y}?ef)o(D*2{h%@n#FDftq%*dPFFbyx7X642mXXh!u}079q^0H zm|}v=-+juA5gJf^7706thQae;ua2>^8?9D7j5v;tm~=Wje%!rdCv!}5a~2T;W#d28`gw`D^>qX7QLA{_ZhWQ01_?+DwWfIr4uFpGwFtBemcoC zA|qt3jkdk8^edMoJrRNVcl&+VOf~)hA>HZR&$j{X@g=S>hIqjgHBPv>Z^m|EUVK6Z zx`A}kt3%_vFpE}sgf*Ec7_?m7GESM4fk!c6^4b7C5ZK70kW6{lM^x&Vpa{C*?{fH! zkTBU?P`@8GDK7wT2H+*Ar&)o;eV;b*R>N(I@S>RA<9YVCmRaEB`biiV-I~e8uQ%D($&4nGl5%6#j{<5huZEnJ!V&30Owa~eS{AZ1S8IJ*M&2n zc%+07Aq5~XyV^==KDSBlaFFf+x7+`4=d581V_0#j@q64AJ6yOQo?hedCnHKD)&XWk zlhbsu!k&4DIJjRQ{_m6UR(Jx` zHbD+Jn@0HL%^hTl*YpK{GKu!6iTZlm%jY&^V0YP5I!Ks$f1B0+qgOqKb&|?r&nxgf zdX+J%-$J9W|DJpz3gW_CxKUqRH&LdP!gwJ=+(*qd?5jGBMdlV0Vb(=XU54IwpSOtl z{s{3Wi%L8l)DAlvplGCg_Tj}RAI^v<+jffR8v3?}X&u|`%Yh|ti}EoFuHN(NO6;l2 zbt=lcm6PZ77$e*ojt(5aFeg^=Cl$apN4VQlRSI}Wuq7!(7*-kqPd^xjY6t}ks+q5sX(ua8v zu(ZPk^gg8*Y{KkimUaj1{z5o-8)V)T?1;AhAuq{s`ccy|dmQUlN^jh~u$TSLLP~-2 zHP#I&v35%Bz;W#Fp(DCEMQ*yBlMQyb+Ls+u`N}-66l|ot;KyW6P7b08Dgr>qK1{5v zUx}LP(fa!gDR!Ptp@r!c&LRMw*j&a(P0}i_NFf#A8C_+YXbGh^QUxw4YUrJPlxf-L zeRUPyp8$DaF^P7!`bwDY-6jTk25;+F&px^k)m5KyICs43?Kl%<7@|Ex=jU6Ig@ zNH}#*u&0GO($uztGm-!jfm2SfrmvW?Xs~N3@Dr>T;-tL^n=$EKw*J;^n&HP%J+xFs zBd5bXjN!Ew}?Y;As@ z7Nuk(dcge>bdn8^g>a&}K2rMBY6#b+kW?l;-hCq#e2GsTI*&)7#N9t``GdrVyk!ibO(7G~gXBFKMjEJd$4 z%B}Z#l!_c+(1#zOD^gEF;8$3Ad1c_n1c;Et#7X8S4|(#$*4Q-Ee0>{9YHB$5Uc{UM zsdQ~jOA2XpYcuiTQP1P!jMyckG<`X1M?Sd3tSr{NpG)O$!ha^o<%bjwI9mKW_QQ3NP)AiHK|=*kuXAS^}kn4H`L8Ey!>G zkE=HigsT1j|LtoTvX*6(kV>*-H&fXvLJNwJ2qj61#%$IoAykrOvJ_>RP*j*PB&00a zvd&myY|UVBj9GrieZTMf{(OJuuldV4&UIa{dcMU`Q84wD;u|O9`;Mo{2d~Ui7J}%cJO?_ zYqRiBl=jZ42RunE+s|{Nqss-El_*PJrc=FU5SpfNH7~Hpi`}x(hKV@#3PC z#J__E_;lS?28Bnt9Ba8*$o4_x9oP7U(ZjouQ86hdi0u5%-AI|LA|B{&Wb;{FyaI0F z?%b1s(8Y$CLueCl#hw!)1eui+Uj9;%LRR~jn23v_C7#YqdW#G4p<<~SJb-n!*S?H4 z67EHg7%yB;BSjl^E+5BB!8-79PUauyW)x$C6}UwPlp34eVP5~wn3jU(+L~9!A$RG} z(Qy^j9v1%DJ^Qh5Vx~iZ*%d{+&{(g`H>y+z(3M~!F}tEg3mFSM3ks1TJPq|Y@QrqB zS=s`q{tOY_EFQCcKv>*Y7jBYBJnFOF72_y{JfH7%FmD~|vs`Qg+IIe)(}R2~`Jp?W zr3-%M!23^9aD~5Z^%>-^Y-Cwsl;hKNOsNf4m4bAYrz00V!ysd;W)>SGpS~;)6IjCk zM48HlZN#kA(CplI;Y3^(_s?-&?8>o1E9mSUvB!grGb$)m&Ov{wW1b**+iwq-#wqDl z$=MeByKU<{NqNsLJ^~G!`-o<59@l3lE+K9v&#ide?W?{+P?2SN@lVccAzHg;6xcgt zsTpVT9c95U1M*tFcsXO>SnJpidmbEXGX=USGaDMm8QwP(ug)ZDsJyoRiYi?k8^~j_ zMq7+lUC-1HL~~^A##h{PM4$7ud3_^TsnKfKO{Cm!n*R^xR~vM}VB3@h z`*eXPxp)9_FI}lb6Rt$lUzilksa$9D?s*exko#FTy)&zPkdai@)Nz&DwRmrp8CLH} zh7Twnt_m`_GjP*DV(oLF>oM)p8)k_4+|Nt*D4j~0W5t8ivwuq8K(w|J)&naa3%$Y7 zz83XGkAnV&t3ZI8_KpvSvy#5Hw?pIL2gK$9}7 zw&s%+&T@_Lb2oA&^uq>X0%N@wW;EW^sH6R4d5F}4i{5#Oc8j0Tcvt)ubh$R` zVRsR4#EDDGEAy4_a zsOZ=gtlSE1H-CZfi534~Qw#z4lm{$>7VZbXRr{;W?K&^6!HmahY-% zJk;Z8^U8>a6Gv3iUH6~;mg|-@E0)yeL%{dHHP?VdLbbXz2}Fx&88#Pon(A{Dz}X`> z4wdHEB5WMTt&{{40&*QF?gJ44hXA3`m^imx^!ms-wMd+xv_(pN%W>pWJam2cEw+PF z(ON+Y=f?Sf!Da;E3~oY@NZ^Er8MticXF0$byX5t^GtJF`^c{CWF1^_l@F3T*VahKUNGv+CGkBu_I!khLVtAny7<$2*~6o%l`8rY=Fv1lC$Iq1$?ulPGWZbhrX_7TBuef(_l z$fnXBhDZ7@*VY*N8~LLaYTHn%FlVYh#pgcs_*$Aeb2lYrkmR^j?~Q^=A>aJj%%NPF zQ*dtzFPwvk0SYMc^^Nnz_(Pke4Mi>Nk5)DWqFhk#dgCB+e(?Q5`j5e4qa6!l({9xXHQbS;0aHxFfKC@w3>a-{YLYow?_?CFFMz7|8y?P3pkK29#aivV z{ZHDM{%9P&trzK7yGBT}RRlJEbB}yWW~_NKYvvz+evmu#dN4!t(Yq>#OAjD#3@G9K zf9D%C*c~~!w+r)qE(!0_>w-kN8%ffUImB?3lB-d#(J4wJuM>X@wLWG=IUx5n6y(hN zI}PC&Pc5FmV=j|>e-j-lZLkUyc*L!lwKDhCUj@6^uhdUK9K8PD-LxF>zme zy;aZ^v0~`!B-hG)vX1P)n9iVqs2G~nMh=T*^$<3(eoKhQg%C8Jzw?P}mo@Y7(#mI1 zFI~c+Zv68n7Gd<&=XEa*b$f{+>EO7HhVXGFi`cIuxj3$O9T_pIbA>iDrS__9-Ix+@ zJA*#H=2meTts}IOI*44_jq>RU4b|v?KXeP7o_aAhLZ&PayuYw*>9fz9ezFA0n0ek< zK@1o__f=Q-!~TkS%zIwdNV(AQJ`p-+zhZ;%i`9>?E9*CODuxy5w|(Hf$}NgtAz^}* z%eCxk`mC9yQTsW`rm&!~pSH6{c3xdSeK7r+|MNEmC(XS;nxKS7`c0by#<%=Wm)QpI zC~1Obte$VS1W+f1u@vscH7wLfZx zwQMXl#Z{ER6?Q$34L7Cudltao3uDg}QCFVVy%hG!pr*$-YB3|gjVb-LQ^n6U`Efkp zuK4+jc(vtA?amLEk^6mggju$0DnC~rH~E{0jD?FOm#7G0ui1N!eXEKCy*DQ8MRhs6 zlo*~iq^&`9hk52p-#)v5&72-c`0@&098A?th;CX$LN#KZUisr51l#a8Yx zT*jN*+HC*%nRn<-tBy}g7eAH12^v^qGb#MDn3faH@tc#E!ro2cJ?pYExaki8H2 zzQtil9QjY-V<|g3wp}xEOC7a7+s4~OB0_|)JJ*>amrfi-nO(pK%0)7 zlmrGZp&%#tPt#jhLQd-T?aOTs6fmpN_wUFy30*_*5>%8F71icmeyllI*5kUfs;Z0! z-k*zJ)`&S8cAxW@2fiWDz@@E=07UL+yZpfbO*W2(MBh7auJcRj?W}ur{}GH1Xvh4jxD2w4ER*|QK5%U@CJj@YB+3gw z#CRm>A;|c{me7v$b;=Y^D8qU<_i{2Ug98683Hy<@zA$*TERvfwyus;&5;-+eAFw6d z^$IUuAWwABosYn*dRhRzGEV3WG~xq7lOoWoyE-N=mqtIZvv8<(EA!jAucJLZV!+nj zLQyj*?&=QuRpeRBcu7Rgw#?H%or=X9NFr8O3#57!D+j*J*a!pOqV?F+|4lg`UVjutMcENC2le58r|EV1Oi@OG&K z2G^$KYuc8aQ{|yQ>4qPurppf>zL>bQgmOPw;Mq`<^Fbk%F88YHo4I?|;rTtz_Xxi{ z&uHA6*VS47b;3l~K%wJ~obb*=cm2Ii)I7g^G$v;_^RJQJMDapzCeGew_UUD$w0a6s znwFRPrBIWGrWYM|B~siYxhq~fDBKBY9^&jCR+1KIEQ&4Xf z3OF-TF8a`J;WaT%$Do*_$GNccoi!B?2}!)HXtG(3iy$SO;#HJH{Bu`e(oz}rY`I`B z{K3MR?HGt2LzJdYfm3AIEefC|E~mM|+U+{(S_g)Lso-sWOnM{zee!pfDw|07$_mM}+!b9fs$1fjElmObV zq+^zhao29o4&;4(v5TVi;N>%%m*fHW7scbed@TF0n4$}LJgka^Cz%J`em%%E-ggT5 zaXQMeG5ILj+Q;MwF8YaNe!qcO=%Qu6o0-?7Fh3|f9{g4KI|Y1fk0h%B)4NcuotjDY5_|*_t?Z`(LR)4iUW=jnO~>;O zIVLJ9OmSu?u;4z2BlUy=T}_UNN5+qOt}8sGi_*cr&%+P+r{F{gaP+-XQYb!@eK$4? zu`zKqXg?EgM5*UttvhQmgAa()w_CXy6Sq?~)fuxkMLWbJJSAt{mIP7fM}+LzJX`&)H=`T7Eio)SNco4s zKtij3<<7e|tl~|l#${BT^gPUu;Z9mQO5G_dQ*U6%Se(P(OMJrjbq8J3B&lrgMt0!H zod&PzGrUa1_@onhS?5zZ9*+Aol7|)UytFy+8z14kZ*u08U$9X_QJ<S=Po>V;{&?z}?- zw#O+QCP*i4O#lA~H zcd*86QLZDV2&gwdt#TPV@2MyVm`()Mh6a1pS*OBcH_?DgaRW_veEoBL%cu^4XD7Pr zSIjmxf3`X#jgd-&zTL$VvRd;D79p!`#dHrXTjHh2lzouU;hvQ8ni^>eaV~VD-ccv3 zt;=W+AauNRCs~8Ffb!vk70m$!0Rz$G_g46_N*&EzUUzlghaE=7;6LC#hqaK$9A#AuuBk;Hn( zuOq3;meX#vDz6S#pUVDlTH>mNjRsS{8Bx9Z_WYztFpIr1l=;J4u@;pyE z<@h5cWvPL2z9i%Ep=1KD=j%<^q%!BD@Nsh9=iOhe1-z2;zi$9rkvE>de2(^A%wPK^ zRac_k-T&#`>i$uWSBXzPSg1vV@wZGxae2K}Hh!T{+~mq}gvPrJ7{KdcFZb+A^H4J_ z=dUrH5dos9;~FXY{rI;7nNr2J!~GQ7@WL1U;lW3E;Zk>2hmE*AVTslJETNn0;lvc12aW4(b+h26XfwdNSOxsZYlO;Q~A-Gya4iioa2jo%EcsMDRfsiaiD*r z{!)yN+}Tu2V%o-d)AN^*r!2i#$R6ao2cE$+NxI5?`IoW=b}A1RjtM}|F?L&WXB`g( zJTn&?_ksoQqG!^i=y268qP`aQ?o%4E@*r7^9%|>Sa~?ZYY%xWhVeUIx=HeWa(r$Ay zt@RE$FVZf0y_~lm4Xs^qLnSPum9{K>8 z4eTQU8;>L!evK76mmmJA24v@x@dB)egh$sECAHrUR(b2D9qv^+W%xjd|NhR|3dFCM zy|+Um)W8C%>k~d0kUL=i^`zc}xpZIRt)?jvLQA~tsK$O?%=JHRIX_Bn^@Et<0Lm}e z);j(3XTL{b>Ef&uYtHB9>hpNs2ouA{lh(mMns|@#0y4DO ztU%%}|4y9A&gY6)ukEwu_R0*JJ=S7swrEBUj(W=bwap((Vo3#zdw*0;q|<0MLz_WS zYuYj7b}goHx@Wvg;0JGom)rUI7bP0dih7(7Ay_dfo`*B$jUQ&`yBBWF8~?&e5arEz ze2$^B>r^)}okk8cMbdzx{TRoc%((h1GF;yVBNrz;?;lI15OBh0C(}j%>$0Q7UsX|Y zKNEG^?^J;HSeYoHJRib(|M8U&5vQ@dTg4Y|dv!_wo{Zxu%fwF=aAH2Mcs-nZlNWXa zqo9ZX73rASdZhLG?A0GqXME;lKhK(q4!3XSoqwx1^%@vD0oe=;TiY6_=a#g?Cnt$Qwy>A561c$4k%MmFvC`i{2`TMn|1LpSU>OqXzV~jLcPBdHw*l zzvt{h5Xe2;ms7juacBCkQN!Gd^eR7dgok+i@volV&kwKFXv?xMU{%}(e-zJ;U6GEGVCQ2%Gv|@I|G+%_jmD zu)t2KFPRNetj&K`s^gYA*}JPHgb!@(_1dd%h$}pAE8SIdLVyYUWu(!l;2#ZDdEV^p11r#n<$QMr!)a)8p|4p!_-#_TGv4aV)a0aE z^9pDs?{emgj{Y;RzKz-py}`-5D-S76@mFB`xGHbgPb>xQ8?Q=AkM%0<9rTz9q#F_LZ{c>C2y~_jQWG`H&fV)X6Cqu@W*VDt7X02SZZlLi;fX z>HLUWV!z!>@^-_2#u%DD*4JqNdC^h{k!wC3U{cnXByk*(qx@Qy)n*?J%}l%P`J-K- z^zF8e&}*1HFK1Ag9=rD3NGlNgIU0xVC{qcv1Jz@@^nCXLkJI2!Cyzo$5YYUXt*fbQ zH_#JSn*@0o(+eRx3t(>yA=Y~Yhy@`JZnV^AW8AlYmB?S{@x-q;^J;CJphFC}^`8Xb zG~DJV4Wy0=_LLTIKapd3qER>^Xeby*%1C75!h%~hp+lOC1bI%rsCU|Qr3{O zSr~*G`~%8V1^Vz!YNV}jH!nq_%OYKGTrn&m!hC_MXV`CX!$(HXjS zG|1Z06Dp~DxTin{j3EmpM`qRvZ=PM_nAX)-b$;seQPWM3ewvQUqqNf{S~X1D_5cXw z{{}bzyfi@}Z}#UG&m!ptDA(Hso@~)9({b7oSN(Of<$6fJbQp|SX6lXIINyoq2)gry zA%#Z*xPr(HP4udl&YP11y8%Z95_#115EDK;{8fqdBPl26bU7uaZEV*mU_NQp>`4r{ zR9@`MlCBl;Yt?i~YiIp+DPLp^ahRoY1+dtM6TZs$_GkgwC(9V%mxbo@w$|ZY_LN}0 z((>$8rYPrf%%6uu>LJ40d{UM`pl(h4Zl>xu$O2V5y#_AclXP;cD%y7g?i)PUum@;6 zEefjxu097&7OwznQRUM*+Q)IRO%AoAv)`_FL`Cc&_O@4jFfEW)5k#K2qqfX8PwP&- zbhm|lYkwoon_O%&{HDHL)W}?MKcwMLL&|II%ov;o(A9VmjWB5q3c%?xhO}i>k6H3t?)g~1 zC+zIO4$`YL^h=5pK#yFvpV_Ax!~Rl@qHj|b(o#Jre?d6DE&^{`i+m2o6JWE%@fWC|k+x?|j*%IBPt!unr5{2SZR$mI; z@w>yl^Yg48LSWb$yXOXKPm{2_8gTxTkaf~hspi5ZjJwyhN9fB6#TM-3mIZDc)%+cI z^5WIyXG@`QxG-1$JRK?;ox;Mz%us@ZEG}=u*{@kiz$VupjT_BB)wl)--YoQL)I&6M zwx8&?aGaqz9qQblCR_-;vhMngF8p*^wU~JRHxn(I&j^g$^|UR}Fze~(JPPP>;Y8OJ zh03tDpC-wl1U9sQovTY@g(8I9`|xyfeD%i;A4c#|y7cXFS0VJ3yz1$nB7`#sFQpoe zB^Ngrlp(b(EHB7pibYrf@?FmWOUAp$cqOj5KHzQHTR)HpmU<-~W10uc= zFqMx$+J}C=1m8o%)?3wKh*H?i_VOL+d#^?7bY8lNHt`Qgyq~RT83R4)nw*rJ`tqlI znX{$2%OR1nlYf#H0|&Yb)jejLCCEpF;YPmOT+X~cmV0bvYamgnA?cNC;jOe?;4&Wd zX&D-~(w9X|78l%zrct&oy0jX=3~e~y_v*6*sReprEnDTimB}9&(oE-tmY|+bn&DId zZg>NMV8z3)2u;OtI=w`R^)Yz7D)4DSOc0BcMpbr`r^R5eD8XmMV9T4%ce;f|l|^6z zKP&}nUmQ#mW{g>4JWJ(vR_M&L>a%lVI!|DB`tyFf{Mk_g2|!-07Tkwai9FTlZ7Ugx z(nI(bI9}tIEWpQ6tM1c66xqlTYq(A{x9=_p_Z{c@grNxgZ}{tc;w8D-u)M*8n!l~7 zS#OA{PCrA-VXyit`vcWTt{rzrn>F7>xGAEn>CG_X=7)Tvb`B$fJDTAk(Ty(j$!8k7 zzh>8;UVZzgu?VkYA|v!_ML3+q;MbaixQh$d7a2>-Ng6Hi9D5}jAD{z2uh}${_6AkN zY`zq@`#w}*cKnUUb$``m=auN4CP2lIlLH#@yX$@K2 z^{^BbsoruUM~>wXtz}s1lBlt(BWFBU6GA|ZD~R>^2re@5BU|}%QRQ5Wg5&x&K`*oG z#^T8oGh^^VZ4>Z9?z?Vlv=?BWo2tNB)3r#4I)6G2-YXN?g!z*K&UF4K8x z=D?#j@+BHr$ER&Rc5j>Y?#;xYyw=`cxCAL!DN%p4rpZ4A~SCi z+eGDp!I6Zb1^LjjxS?GSvsSc%+J{~l^IOzgXr50{J)^%ZU#BlK@tM{0q`XnF)6ASL5rjKZ+Ah+-rccc z;o;oh_u@e1&9kYWh?s{I_Zjn1AO9h9&5qp)Vg!R+itV5SP!vS|J3MOI(LWWT&1H4t zFfSyeEsv71DG98O6Y9cZH&UIns7by)t?)=2fwm@`yoYb zRY%?eWo?LDIG|!Y%m1lp`*B&=W#EGtV&NDQz7P0mg|nrSHB&5}(AV#Y!KNwL7xE-7 z**qHSP5E(&&6zxIaQMJEo3_i3r{rvf$dd2j2jpPsba#y738A)yl5cfx87UXT;{$P_K9ZegbKW~PB>uXfvXFPV ztecFZ`l3w-R7CUlDjnR+J0=yD&<^>M)j602ujJwaSQwPSfEAa$gVka#%$5F=4-tcX zqS9^zEk2GWM!3dOEh9n8hJgd8_c=P+tXtcYTDGAmGgsI6mS>M}a^>i{i-bqGXS#M| z{&n{LXF~__QZa90yLsbwg%8H;M?4w}O}7BuDJ0p#mq@nq?E1x^tCcm0VwgjnwL4Xi z^78@U}PDxpoa*y9vTe9tD41G<&y z!1s}`d?vsiD`*g-%{L{D{wWe%Tmh9e9jDiho(;Z|0NW2diH3MH6#^iY=spVVnjrCl z=bOsrF|qqI$h~BFQPuH*uB(s@bd~55->R4zAeoT6se$KH;yToy!5e){lNCc@(*XgAH-59tP_Dj98?aO6yRuT8bh_@8H=YYeqT-wk~pPkSrF zXL4jk#Q4k3@x7R6>6``gALny~Zyh^sawbA0Zd+s305g343TWS_C*+6TrsCu4^y%T{ zYaX1>(L}j!^3xdZ#%?rrZS{IqLq?F?CQz%j!c|`PB9dH6)dS`&0P$ zler)PvJ~Jt_mAl6OQ0_&ln={s7X+vFgEFeSFjS87_yALQQr8qf|4#vXQ}F<}mlA)a z?k+v%o4+WTbQeMdRR4I~*S}}SCd^XY58y1=0--1q&I|vK@T#Lcgn)JzM$g;Qd)fGf z%Uf)xfbkZPHDQgD;B2nn6~%_J?37q;WLpRq$>*i=k4C@l)J@OVQQQk#V%YQ{63(H> z_MBO|k;=h6%MMf z*`{=JUM?Us@o`{L$pz*1QC_zjh=J-X8zT+I`X=~Nb}%n&9q{~08utyCVsFN;vIhrO z`wXLW;{L%v;8Qo+4d~D$D&C**hBmkbeKOc}%bIvboONg$E8inu)~lNsPe@5NV<8h* z=IuK&@N?^@(KIS}?!h}wr)^{R4vxfrc>BV#O0R_r6Vy6Ny43?^5zYYjGY+zTC=vVzt+f_bX1~J}u0xWsaG%w8gwE{yohf$9lJA(wN&978MM_ zRLs+bqu@zWY}F-Q?;7}(qwo``8w=jn#2$&|F(uUZg>Dk$%7_qs@vO?t5&UfUj3LNF zP@0VRH8^w7YeL?CZQ*S05qcIVe+gecwZ?CnL#5DkL6CtONDY8?lf23oPKWiAxCBB` zJkgrsH99-Jc?V5)kzCqTZoW zjMfzGqKLOOIuEi;^NE2`+>NMC&!FEfaE1Rpz|tH*ddwkrE*Dmt0vVe9q@qTw(R+P_ zt!=>jlpr4vNVAW1voMQO2^_9{xOCNBzw3}+1Dhh;fA zn8W{wNybt>(u-XQjffIo6#0RZ1*l;>6D5gBgls6dz1)%_>cw zD(Wb)np?+w{rR|)E@xNrZ81N zu?$*+Q5FjkCzgIQFa-eoMQC}rO9SM#Ma)O9xp1OxK_`F2g(>@R2HSe+d zlN%Nt)FSJLZaglGCh1~j76^0@24%8JtY0K7wpD`Dv4;tTcCQ~BCx2`DpLRc?rum2X z3av(*gd>$LE)KK_V;AXZW73Lqi*JcWRZIscq!Yp&rxB* zM^FiiZ;u~^2O0JRfM>ts^}-Le$GluGVOq3qY>xsJrJbGQ;i99P0@6%^=R)9XCt!gu zmL|dv-2qY|q_?EAsrA8XOmM2&33D;?KIJtpfN&=1ePVRp1b20GGm~fQ%OEn*pCY!o@#8iLx98f zNg3L|^!wjrGVjAV{%6wS7zO9`MHX$AUPGm{B*2tW0QpC8KPG9Uqn^us-oU*Ra{EzIJqWBSr$UvKLCGwq z7^=)tU8>zjED-dPTLb}c&Sg{zScHCCIhIv$8YldLkWV%;1I8Uezu=8}^On07IR6Vj z!;_l;cp^)Z$&m@6`Ai!^<{s7kWy594MlNp`I_EpkOgC2t{9rg#SA? z?r+FF!0KbiW-5>5gMrl7n&OxOiaJBEO1=8ZC^x{PoLV#a)ic#)JV~5+uPGKIy@+U$ zt1pNm_R_{?AR?%ga2i1TBAk?>0xMxX`cdKWNo>&BbVWq3p>rigL$7PNlyvR@mo1k{!b;^RZl?6t0*ilvWe&TH`AI z2LGRSEr*3zsr|8OE6A}%YtU|mXyD~Q{*~|pBK&$ONZk-6c*yDILtbbV8XK)q$ih*y zTfo@WKqfm%4u+rUOJqLl>QvgntmuI5h3PTd6EGL`;E_U@8gDV|dHljf8|?K1;KHql zA%ego+ys;SZe`WyA0c6I^ae}gElbB}WrK#0a93kyXjTm#%xx>NU3>kk3z~x18!XlB zFN({cg3!_fh%zyLkY0+bh^0NQ3?KVl7gN(k%K~G==E|3l$j*33j$+AypFRlDi^y!aG$sG2vOiNRs?CaBvT@u8)h{@^Q>v0?P`M7XZR(1N*27+}J0yvTYdA#m`JS&0OuseFpmNE>{}>Ap2ffnuIt_I3P^P?RMP$Y*G_xRAWu zyWz)0q071IC;>*3*?u6$p+@?=p$tKkCg%>Eem2eDHFIp(A0fFB2ogVjBw(h2lb2do zPToUJ*9A`qMt^6rt_A(WyLw5N|C@J3_Bnw~*jkWK2aW$&X`G#WTyqgt+l^28+?%Gp z!o={yD(m@Mm@ANJH0n?jq|AD2f361{nVKtsV`p%iphcCTU6Rj5$!ESE9$xKOnMSOn zaiiVki|id^-=p2OSCt{g5QQe0GvA(tw3KR|+SnN&_I6w1SfRe7U+LvxGIgECRN2Xi z-7x=76_no8l3N&vNlY<_%ve7ih@Ue;r(~LsP&OS@JVP2loru|*Jvmf^*1E;W>X>@t z`!DmySb#II5hKB|(*ZeE0~{ml z8)csWXQw{m5WU_8o1u(?lgNN5Y?U3W&3xCj8w5kQM2|A`(u5JX;?9x_neRZIt_lZy z6e|p+gopRl=GeOyBlvt#e2S~o3kfsC4<&tsX93Nhzm*Ev!(2;*h9+?=Z+2(=O9j*w zi^5{yqgIUYv5FecndExAM9z(BE06~*hVzU#wX^o49=2RVR+v0S`xs?16?yD7|DWtlt@+t%xfX6o0O-R z|aFqEm#z` zt^qzNMEpzGYPZG`fhz(k-TrN3dKn9(vosJj5<}Aj5@z)SAj7aR>N--GC}#a$rzm=f zHMNHR-Oz}uyqdx!%s-Y7P?QES=v7xCI;N!>Y*CKSp+U3H4gSZWeB3)}G5^$BR}S() z#kjxSDutthl?>yx{g>~=A5x+XQ!W0q5DI*6P2kdkI zDX*49Sg2@79L|n`8$c&wpxs4LaQB1eg>c(=m^)+hsX15N(H8LAH6Vqm4IF-wAS+vXkV!T*9PDkfMA zr;l*o+6)-UhX-$M26#O?qQ~ree>(}9*LiN+TEdbIu?RqG;6+FSOJb}(nH^!vl1nqa zxfWWh7J7WjBq`1a!@`~6k5Gp!V`@zp+RO`W!ya~Pw_>1i@tvk2BuQS^IiQ)5drsj1{Y zI<3tfx>Psu2lX6zMF_f+&BnCLu}|vIGY-?U5Q!x~GRJ~}4jUOKTTaEl-G4=4ZXJn+ z&QgZS$Hl#*0NcGRweuL~jjg8MjbRu3cT=wjpxefxwwn5-X(2ClQ&9yAuv&1o2OL5mfgmL56TU0AydcQM?CTh8&>*sJ`#)(tAw-1L ziBGvyx!#+x;Gu+Cm_j5HG8QWQIt5^-)?QR=ou2IRoOy*B`w#*c`r9@aO z1GQwzxpD5V4g0Yb{X;{Lv5E{qm<;DO!Kyn)3pMh^XslvJ8~FrqF0KOzCWjk4CWfE~o@7=PqLbEEOZ_O)LE%hhS4| zCt^!&TKyhxn2zlC8O=?f;@d=(7|yu>)?X+pe0T89E-%&m&RCcdYrEZ=guF2KEQo9V z7kGGOFIX_GE=^0Jbp|(6?bcKV;#+LzkV@lt7klca6zm|YIk?!r!QCy36lpXnn?GHn@WDqG4-jR8!wSH4&0oqLP@ z^|PUW23ziwkbOQ&1K$^mF38A2mZi@!pW5==c7k@^fahm|rNHpJtDTsn!8CnDIjttX z48+t>z%1Rn9(?`3oAQC1FB_N$Covc_OqJbT>oMz}H`^QuS2 z;H`Kp&N7t4TjofCDU0E?xCHPI)-ySHkDm#u@EMwv__7FJe!vZD`rT zI1_9c`2UOU)fHjkS2l-dW0NiX7~y8s)E*M{OOTn{S{>LcW7{%K7rKqKcQDOKM*FKL z23vU0DraM3pz+Kc3^#0u{=QS3wojh|aL7`MTDmHi*+?J7nZqGFXKqrpJYRuaC z?J)mk*xrBk_l~nRz!A-KXMnBF?HG_pbca5e589nP8><-DqwC0z_`Cqwv=y^0(_i(y5rHNES z2GeDpJfQhsov$Vd5Mg9;S4D>Z@@+7{BfyZ6#nvzbG?p@qF)gyF;qQvLw)xlKCg<*4 zXlIsT%MtY953nxUx)HGE95!Eo(Ge(}f8~^S5^f{J2PE_Jh@m)jLgHvJ_G)W>rw-Gq zuiC(I6u7@Q*>%~|)8W5D7z{RkIrsuXAT9J&j(K*4SLLwHK9D!itQg zqj8v|`Hj{CpuGwAE%}@nR*fz_c5Vv$(03O!95DawuTIQZ*w~JVk0rEkdQv)XTVXAQ z$=a;RC17&@|KY7FJZOO9;Nb1(&+1VzkSL-zh&5VIu5WuJ%KnWS^NS*X0y>O<)){1r zXMO{*n>Kz^L+4!pR663;#$wOg262Af+krZjmc$nm0@$I`wXbgsmFG-~kOjfMR(cn( z`J8oh z_6@IWfEj9C9|6h8tn&nudGHSG2i!KlrP z<25Sbl2OvqM}8q&)d~Yom`lLk>I>f*znI}K)N#4;V`Zi_d5Y%u!p2XouG&BOlALp_ zYNI%G&RB^MeyyEM{M9(KTKD-3S%YydUX=Z@11bXo#zAYFd2R>95Ze z4+Lp&Ey}FEY4+mcf#%@Af5N&*h`2%Fsy&Si!zWX*<Q!($vvUHf6kI z6#-`h*4tKL>x=m|P-P^z>#%%EHlJ zHoJ}^zu#&vaoJrUYb6$r%oYsIwSO&rV6ch@8=%gNsT298EI{;e2l^k(RK%q0Kr^VC z<+HR5a8t)9=p?+#8^iLJp!%c}lj&zFb%M zuv8fDd%6Qv;y&z5VH(A^4j!S`CeGy#^&z?n9eL>^ZobnAf9+5u!9~kv)}5V zLAn7YeEIFZZxbMo%76KFf8{!0tO)Yznl}*Ed%NeVi60*rOz*H2eo(82 ztc?8l6mM7>wv&XT^Tj)4`B(x1;|-8n^vVmdN(N`5dW zt6qT`G-+*k#DDYTKyTx&-VbH#BpMn2Yyn;Fc|(xcm(P8qj-py*&~;xzIn832ixTaZ z2M?p9X|NhP^3n@PLubrz(Z$ECXGA0xq+kbP&?wiqM%_{i7=K5-%09=HF zLR|0W1}=S3X0>$RSW}QjJy{slZG=8+0^NvQt&4oNa8;OpaaGz__&V}C2&L+5+F5_F zl@Knvx%zm*XII0wO{PW390UU*Cx4M@%nS0El= zp7M~K_TRkq(@i{5&%Nh`HyQ}1du39%rGhvN{Gk~zxn!UKI^y)1f#|=OaaU{4i=cYZ znrQ*Dr*ZyI?t%=<md7NC3QUy83WIKRpLTBfv@?`>G;&@B} zsbHd<;#A6j9nhZ9hpF-;5?f^8rUZk3c0|AYC_%qRzFwzq{xA~d=!p^fsPJY1vJ1VX zI@*y)tF(qnnF>HHS3X`IfUC5{j3Jqbxu2?Q7kkK6c#uIk<&TnAwVWj!Sy+O)&->p` z371+H3twL5?U=flL@3*S*@mc_59LfGUMS#7x--07;+tN(ph8*qe-qo*|KUoGq;+TY zLr2FNkv*Hi0^FbvH$a~7#g?%E8MC!yZDx45CHPT>M9+LRlk*>^YKOhW-5@N{I9(po z@@nVj6)Cd+*wROpq7{#nzd2R3^UldMHzAVF`1-hL1UwF$A_R3H5rpdT%Ob(Wn`Pk{ zyP7$POXL)(SVSyf@pHdk?xhz{{IJ75yfwNOq^H7_s4;4 z`Tz*5O3@B|t=arGWZ>oE>28Ic{DrJ6Z&KJPE;A=HhS?4BbT5;YXE^`m+MQYNJ!u&@JbX z7IL%e5!n({258SzJ|$S6vemv%CU6WifE(Rj<`y}iH^vE&xZ5;71v~*)&h)>o9bdMe ztoL5~eDfFk|D)>7x4=h zLfOqCWlKyYTg=!pA~P7~Y0UC__IkZ~f4;xlZEiR7pLw2ho$H+Ebv@4G?nM*?Tj5MW zqfXaS&`7QJHe3{Riv!>2_dlH9ai-Fwr1wwk=s)W{0{Tu=OfzyeVDr@Xacdn-iWqwd zjxtsNrgomfcqO$gVht06@8W>YS^2W;(HL=o{=@mb_Ds`aC>ga6RkzL?zZQnJ!@lK) z4*Ydv-of8}(%l0;1)^#|w<^$IR$%_%AFHaxtF8Z7RShKkYgP5qVGLY|YTLdz2AW0E zDUO)0R6Sc(cG_N!(Mx<3XjN7IuT>TMD^|0pvwIwfFVRgoP)mn4MP8*O(sq&Y=`g&l zv=uNvm@0=5!=(!P?o13k#7%kp=YV}1n0)K8GWCYCAV{ZyrzB;pE(Hhc2Z3!#2vKfR z_1hgh;X|yU5)`Vl=4*sm_Mw`JP{j zm<)O(I2*j(V2Rn83D%ptr@O>()X$5(u76$QH{r5yef{q{7uf5+50VAksPr{VAx-bn zE%-(-$7pOu17TMw@Xg>TmQZI|`%CewXG75tBzvE9X6RlVdx2~3dCxaOO^;m0`84SO zv*AWx(Ls(_(08o$`OwsfU+o{Ed5=v zN$xwg&QliHBfrm+1-w|_y`0+kOi4lVUQX%IMl-;B8^tyiVa!YveM<*Dd=)2yAV?SR zM4)zt&5VcMV4Nu+o2{+;-U@MF`|;KBRC~^NX0$B3%xF`b-Y^*64(HFAe|Em_PifzO zB#k+l#;>x*Z{TObSLFFizVD>r>&3A)jJ zxU|^(#vr<;^STu=@L=rw8z##4$2*s`E45!5=Pks3`lAo10rdfoSNGxsN10xNqSM$` zvl!Li(92*$95YV~<$^A5l1ynu02;J`Xa<^HvL_p@;R;~+!}fM&dVHd(EsISAw6C&r zRR3$h9!=U-`l9?~lfZ_lUqUSc4^N>G@MQIM7OY#2x-kkJLI-2V znroiMzCh0UQ>vK;F#ZP!<-=9i;Hv2&PySwl&BOL`)B=AePJrEe2_U?4ENuf~GmbJ$ z_UnZ4usDr12;%R-qpke<<<}HHq9Zu>f*KeFT--m%?Na=x1E5JD*ePJ#8uPY7W-0Zp zyHpQU$zq-`n9HAHbDr8WGx=E861+?#ReCu*$%-29*UV zV3lEA3&M(@0Nq9Ra;CS_ThxK)L*BRGK0l=XRRz>>M;lTVdAlteU@@z%-8sNyu0_** zziog;)4fEqXmPe^*8vS6vHi^3c6 z)QHv-b9>F*L?c@ni1qG`Em9IAN-TyJh7I-tRVu$O8_bjONekz<>R9b8^k{G&| z^XQ=235L)z; zT&<`!t7C0=6y}L5|(xXgu{*Nzw*RJK*0*Ygb*gK)u^hCYK*O|7ulzwl{*{8_82##Y_wZ}X@>}?e zC_Os!)9dxez0tj6Yaqvhn@sWxrZzcf`tG3adWv@w+b*%Pc=vtT9y_ya=Peh8q)|2@ zU=i*a!!I?rurr<%Vhsw3Dl0;2*KH?84F3i**b=J`TJ*OP1lJJUY#>M*Rxv}fk zw>cOTVMuU9zcF{z&TSRu?e4z!S2@rFDhGmiuqp5-w5Rm<=JP8$2TL*p2_+J$1Sr6FOjD!jD< z9k3vwNw4+x?X_X;SWGlOn;6ToPPmQuVU+5)B#&~pgN%6H;k#DLgXqEF13sjrw>-h6`dS$`7=zeng4ZFzZ1eP6p-bcM{b~<0 zP=N}*I-?0e^U+e=UA!WEL1(w=+sr?8lJ~CyAdVYN+TCL_ws8WI!MD1c@VlGOd#s#p zHNM%Kgw2uQuZ1bHcs}O(kbeXLIdd!hKUXj7{M|gcBIb=H)Phd45qxLhAH~3tiOkPU zpnvxsGW*p|8`hDBhf_kmp?u6HKsJ%#5%Yu!_a9%5@vM0svVr$)M=>1q)SlQ?ic6w9 zZ&?=gr$L*ZF?;hLSaj1XuUg|dhzO>!@&DDIup1B-iWDOC^}rk`#Zeh@cRh-$qTOxA zSmD*to+Y3%rDE#AL5bB%R!H9s)E&Nz=I&@nNF~(~tWU1asI?0t;Mh5|rVMfV82ke! zB{M|=tF{H}v4wYKKPe5QGG+Z!yEqEgF()w);=yV*Mh^48IBQsHOZmg-@;QnZq3Uu! z_{iN~z8Zn}2Vj+GR6^BzxxFEtQCfcB(Puql)3xuHJ zb6Cy1svqM4LW!FH|Mq@%NyW<};Yok$FQM*3`SyyF$`n+g73=?KZ`@xZxVlswPI(eP zMUJ;&Gg#Gr&fK7}%J~Netq?okX;74zZego{glbdYtu!H3hEgM!&&)F^pAwfDvtwr` za^5oELyV=7S}&QdudoM|YgM@we`r=nkY<&-MkXVhE>u(+8TU+X&;8pGoJg=+FB9jF z@_u${1GYLGr9I=8V>SP9o|C2c7x$}*J&;KVX>9^~QQiRvu!{Q@w93AVdqQminKoq^ zjJ5=G>INJle9SNE*7kP@+!q(X5bRcaU)I~h#XzX4;Kt#o`~~mr(|ZW;>z+;~;p8!l zziA9O&fy)0fvQ*%ssgIR32_RIU}{c&xFiow==_TRI27`p5YC*_K5jn=w(vfFZ!Sf! z_ay{@|DE`q-SJi$igB>LddSzlMp0*{V{Xy<@28RjuTl} za8K)-G5yd8?j2xp0R>{6>9!HX>`C#QrA@zjFR&N}PgvbgKxt#Rhf@FDf5}#^t%>o- zU{m1a2KfI_D&~5KAD9?{HW0=>lKCBqxnFdRIdGablEydx13<#NiQ_qh?kyM>`X)fK zXY!Uu0}|}wGhY0$zV-s0_G0t z(`G~qYiGblv^&MmhI$$wdFzbhXcZP|nyoZ8@kR9y?kbcK1@`mNIr_gt{`B*oR7j)0 zAK3(m($|^tQ#?~K!XH^IN==np_h`^v;wJ7D&f>oW_Jyr?ew@MDgQ+1AiJp}sDi{N1 zRY|7oAD3&8fJl{|h>1HJ-t4A?B@20ee9)4%k5HNp$CB=b%(1yvw6mO<7c+rIHA;M? zOm(oizaCfL3Q0gI)>b8Qz~(+L32RU9z51uQukFMfA`>}d|HI({A!|(jgT-(@x@n!4 zNyV;DvPYdVzf+NRf@8FhOAtX2sC14G(cz@dhIz?iBfnX(W(ie*{~#-3AarUW>Orq#x2 zyJFvb3$y{*M<7N@A0zSGYouux5BrN&7Tum;ye>PrHa5ThEiM6d5hyI&j+d?P41}Xq}H{9B~9zGMO{#qK&*~8m* z6PkdFRvwBcL3N*{s`O3`ee$f7_6mX_-bS3)v<`?|l9!$>OFtAj2$l@oiFg~9ndu)? z7Iks(Tl-m2L>~LtdQLzt_+BG!h$U`4*T202EE!l-&@I8{3Mx%gX>yA$I(Xh%8ueFp zvG^%U@M}=eUx&jW#w*KV<&(Cv)D2u0a$+k;P*Iw^Y)wq2eX$PUm}4z#Vc_nF8+t6@$-i z;WksiO5)4ED}Of=A0Nh}=JZ0D)A6u!_%0>`>1it3e${qW_hO|7+8)3t^(%Jwi>sKU**{XWXRiZ&`cJW#J1 z6m;%p-DRNNI{BDWJ znqco&hGpk0VhS^I$hMZqwgU6}K=*TtnzQ4v-z4u_`M09>qxG~n%Mo@kCf2uM{tQcB zJ>;P5kR=q9iKdk7`R5}E)WlWbRAFiT{n_inK}P*C|MX)|WCf{QfuJKa``GeIL_=JJ?zk4hX*Z?^vDBaBn*TM1ph zcqfm zf$vVct#O})v2qevY;jx#a1Q|8rsqJ#F=T4JC}#tAH!*Vwvt5?3w)qTjfM9N&fD`Is zn-?}i8f{asPG{F|0%uA>WY9|0&WaFt5;6A|*{=ik??70e7D$qif%A;{x3T4LZwjNj z|B4#oj$CFBq|yepke~Ak8su&OzoIsxDY{(0-b`=IUz*R%$UFXnisAOh+zId6_gbP* zbJV5b$_HwvI1Ju%D_&2X!?=)4kQvH`q2JQ!kyN(9#r3|N+X&-O$BFgoR^aLD&Gha9^9>Z^+sI6J zs04H1q5dAndjiz2N9KDe&Jcm94omC5q09>+g^bOcj2g8+MvXKt&grK`1tSLGm}qJ< zce}*EG_Z;wl}AUdHOJw#`vl8h%QCfSIPfuA4?#IRY^0Bp!#b+ynqAJ|(giqm{5nQM z2nBK|BJ5#8xUUcC+uCezkABJ9{O8L@*cq0R(#Ek@@tKb-t}|uQ0)+?;UGLMtsDO}< zrC>kW*28YBsz20Iu<>3EJ5v-vn+dan{hZqJ1bOi~0wm`@(NABXHe1gWo35Jon-?>|UmTyCawbAfVp{p#=EYeKnQV^B@Kc{K;YfJlz3#Fp^_-}l6{dIP+^737obo5 zw|owMjnjJ*HSGT)6Sq_+!vDM&BEsd6F|zz47&{XFN+DF3-*Z-2(1>uCCVBl-4G?-D0%`%sS81wq)t6J4(4Ac`;0|Bajj68@LfMZ{MPL7Zw+8$PNdw*D@--l{Gzve( zzPQ^*PvH{U^U+-nx6gdVBEM2Ifv-mbz;t+kbmU7VA;hms?OF#)>etDQ4IT(VDy zr7&Kf^WT74_w1)h2g_c%`fV#Ob>aBr6tG`{Tl)F-Fb>46<%f!nItKYiDFwvB@Z)Ovf5ClHMO7*OAra-E}15(~Cn zfcuLQzrE#IECOE7s||e zvH@q^KJ_9#B$i1f+=HRDKzZTcO#SVXC_iZQDS72c;>ttu4yREO{!qD7w$4(d<4REA zK;-Z*O?OhJDsJ6e1RRXW>q^ltgIlu2W*T=5bvHKkU#r6P+G!Ne;U^yx^7akmWtm^+ zt`?27nLiGDe{?-Jj$1xLyP--G{Gg;BMEBh3-ON*HIZYxS7v>MW^GGm_ns$fV&T5)T zoq2`Ek_3+PG#zSXMafZ|M+{TOq_Vu@QS;MiaL4K>`t&kBzH%?%IyI*v+xsFs1i0{X z2;!ATjlU+wd>V}0ctFK*>yqWU*;qG`i_$AqexXTRX|-(UjuTx)t{>5-KTc^;EAKmp zftdz*x?dglx}OH_3+NoJZttIkksL$5 z=L!M-x{tpc!lIxQS(djU1*G-6Gl5^pbGH( zjV0NxVVe%XnAv`ZgqcSc@|5aBH<7uV^p*7d8qsHaMZbuBB{_lJO5D>zzUW4Y?& zV?S0PGo-V1uWpG9{Edfp@nK|sw{Q74$ddGdi4}#!A?%l$$ZK+-uQPTJWBjzukM)f^ zS(Z72Io-FIPnAfjauSG5C~ejjrv7{Aj@b9=9DUF(0#}mozX{(c%C955CyY(!5_S(Z z!nE|K}zwh#-5bk0oo#fig#rZWk@Jxg|9)9V_@6h8~%@OQWLSnL=fG4_-^%<=vanS);|#8vu_Q&C~CE z=V;s7^h(y$JeUdGqn_^-kA@8VnLQ*3R{*r6O_!a zHUX}ThXe-`GK5j8=#0+Nzrp@f7{wv?23>Uz@ejth&rPbKukadDL(ELSTnQg&Ldr1{ zjq-&)SD$Puqy0#|(%Iw&^3UyHTeFQnl5rD{npubPU--_qO)~cBWD5&wsSNzv;f2p_ zCg4oZzR{&Q`#OT&?)4{+=tWT0uL>K zQ+SYYv{i(VNPR$hWG)`R>UA4d&zInbL$ffGSFfp{4U0^-n`Cy_#cpCnKwOG|uxLYV$ z@aLhn^LY)|2OgRG-@t|lR4*#@#l(< zqH33=+Y+~8%B+z2A2`Bv5?)oa6<>2^^|=l1?itWz(-+D=%U>b?_i&f0u$M^G;m|Et zBeS;6DdL=dm8~?6C-z{ZRZqw|EQ}N`$vgDEW(>wLkh&rTzr8|Q?$vC%x~(!tqI~yO zCVrqq6}^-1_5)PFhI?K--hATZP(1a;Mj^EJ(LLgouJ)u#kHNSQx35cd?lU+u{*`KU zGXLk-r%%;QHF~9n+@DDC)}mGK`Q^~P6%Y<3iJLHw?&Wk^l8SUGj+Yb&Qs-A+KGQaV z^!{Yi9+{J{zV;u45GQ|}dsp?OK6wX5l6~AnCyX|uK0Z@3n|yU2M4yvY1=+%xG*O49 zfKOUB4?{9_n3ADf~y&_9RZ01b6gkS8Ap43zGEBI8xGc(VsCQXELMkhf-WeO~L_-L- zz?q`2niDo-wq~=Hby#xD&Q?6WNNI}89RFb{HMC0U^L@JMoTBgc)A3!cIrdF75Gws-kr% ziBdeM(%GQK$fi$`*PY*_1}FvU!(QO% zg%O8x$}FOrB3y{`7Zw&;&|{>~Fk8%Zd-LBL1UCf2zb4)p6WY&y5QtU(JDj}FaUo~M z*4KWOW$((nkoXvT#c*V#m=G=AvHJG(0f(eY{i*Wc`&~-%4&;sywE;w!c^o`u&{fg| zHAG80p^#|kFl`73>&(ua+{TMxrVl#ngca7y-SE`L+*9B9=t^YLPwva<6-K4gdm({~ z)6#lnE}Z%P9A+d-^e^}{TTOB_{XbL zi{o7&()!b&6(eFDnlwmj3f%C z&L6b8Y;g6xR}Uv*N7%tT0$oA+igp<1@fHbW^(9EeM2~OmM!!i@eqZH$Iwx{?=y$=x zEs2dURA$Z0&vG5N|C@EbhsXdCBo#vy2`cE~Ot$7l2=JC+id5VuOgRnfe$kDQ(VT)31WD10p+OZv=bF-vIhPL1s{vY?vG}UXfs8>Ix)>IhhAW?92}Z z)q95OP+e|-9R+7NX97TBdb$?ThnnM2=tZaP+;w4jXgxs%#tf+Js@yvl>&2Muev_@6 zDPJjNL{*@TOzYN30Ea~R9bSz}1CIm`gox>SFMC4t{u!UwX0m|0g_3(j95=6a=8j6_ zP3$$l6OUz2{yhIm?!x!nZBG0jPa#Z9qO~aE?iHw|P;;*#*eq)7&hJ0gf2ZEfYwpK^ zpl1Q)b~>2zw|GSui=)ECo!%_opRsXv&?V`qjoikC&8I286{gm8xl0a>pgFhQm=;=` z%*PwxOjtg%_%MnK-3v7Q@Nqb0vramn0u)vP&jPCUU zLU{4}uRXhTV`ALEtz-3w)^|IO5Ft3~(#lJR3PLe%8?EWP>#Y2Rf$pbYG}p&00(w!H zjUt5e*RE-u9*ykbR&M@$O+^CuR<`}?ivPT4AT%UemHQC)pl+NdO+vZcIKvXS6nubj zXJ4$X1+$=+nVKJnYfp#IR}A;x>$Wr=2{QQTb^VFHjTPS4Xs8RD$h)kFKYJrLHE1WQp}=4Lex)dzoQ3veXr1%Wguu2rP%qB?fM zxVgEeM}W=Mek(L-g3@h#@3ZBpaZR5rtW^7yFkVM;F7M~hNadWFZTBF3(0A+JQrw%z zqfdRqpLvSI-_qvU@3iz@T3SONn!C=tZU-{~25&=U`S-iYb$LuO8Ez-oSUq1Ef)IG< z@_B1NhYxThi(7{c#C|F_H6nXcS)Z{)eq~B#{knQpPd75?}#mu=ec$b=I7m0iH%6%x5Q?j zD#0t<&h(5q)66Ta*Q=TW zm<-nnfxqL|vw_qrOie|KFlL09FRbvYTo)5=4??7W97OO%Jeq)8J;nMgVzgBk<2puTo|*wl-F* zU%ss}2hISUCV2ODs)C{^3O~athQRN9+C|Hb$2pS1Wo`P$za3gs5~Drc@2i)vkC0+S z0HCVT#zY-qk}b>m-d3!&e(c01nR_hZh1uKt6QGjF>}P2f{sIVuDoe@P!hvdmN;R(a z(5*}h!10dM&|ELuxY6-i2NENR62X4O;KwAZn6rrYl9yt4ybZuEFZwUvVu#osx~0=KfjNPk!p0jB#nrmiQ;u8r0Hdmi?MyA z_S#r+;;V}}1c}*o?Tq0QURhY}gnr@VH2a}=t!NR3ZwKtzycgKI02MCQxa$$$Hw?ME zD8IB4JY$?|R*E?dJ;GH&?ObP~637j~5(^KS32VA0LX|2D{R1U_zoO3}giN{bhAL1K zyE{fLU+t##ERv5nul|sJP|=1G1FqG`P0%XO#}Z178sW2LYFZ`y|6v2jnOJ&3p zv9i=3h~L3$vLlD62`X^g!ZFBzx+~&Nc(PwbAxYbu=SH%T*T)NE)*rCL5M=E- z*{QiYPq8t?GZ{IcdL!dKtj)KpuKIg|(oV&5>C(-D>PRDtpxy4uMj9UtCGJs7_U|a& z?whY$Z2VkBacJ<~%_^0E?Z*^tn4-Ev-#cnI(Uf=GObe>IXcn1&cS%s)fN?k!{B*Ap#w|)b&wn*urzd_yTS3AAMV!;wlRM& zOvj??z~d924i$^S<$8ifjp4L`QLp9HsR3<7cV3Hi@j)Y*>3khu#S&W*L8NZ+Dbf|9 z_UKNkn>+RKp&;k3`id)l1+C-TKH1l5JX4R@IcolSDnsBmfJ?dn-Yh%W_vX94;2Nyu z$?<<#qmoSAMvoS5=PaK;dwi^vk{6Z2Uys~r4O+|PcEWU8XN1uYOENWLQ-AnCkN$GC zr?g8VbjXQ@82h)0Z!m6?>}z7kT)QVcl-UOR-bfb!(J8ir>l!v>N$9=21GU{?QWWuo zUfK=2dZ@WTQ5`^rX1AP|03H=X;{<$OdzGMz;JU`oS5n6XU@cn(icmnpKysjY(}&bE z=!Az>*ZD6fOO1JTMEG2TU(_S#!2Dmcd4525Wq#f;XT(jJS*JHOZHHyO31+$3#Xs#@EI)G$TLG{~Y#RA?F2N3BAJ!uD`v#7cIpM&pB`SdKp2x z&8yhB@c7X8TTSO>_53|50&*nmB4dPI;Y&vXJG$Rl4ykK>zxn0qAy#={)uzo#JU}s5 zs_T}2p*S&P>&-ncbg~M2PIY;71z6&ViUjeiw{(YI?(l{3nZtsUtRqP(#%~q!d=X=3-fY)XQob8 z?dCmsyle>2HPs$aL8NZ-=0wGG$zf$XT4X$uME(SHXs4Zxe4}{Z#L2O z9D0jW5kd$nrn4uHyV|dPn@RD`Ok~1Ef$z(U*yT$r+rH1VixQ3_&2Q^{EsoDTXwO1r z8jguN+4^B?!%wA`cYr6Pu>&Txyv-!y7dMqOpSdpy*9Qz9wC8Po|G<|Y*76!Rw{%RD zn=$sQ-=9#aGP(!P-9rm8-(%>}zrG&8x-r=~bB0ZRI`Nb;&?pNy5BL8zAwgv@<;oC1 zZf|uNfwC{mT!#5hPFaBog&UeH!Oe{u5B*3L=GG@~j>D62a<&YZN6djxq`F?sD-sXPH(E-NzwoMbG=29qWUXwd`g#b}_t^ zFzq)_Oyp%Bi%&o((k7ut5gL%k5Ae7;dmGTaFNJUDNWnFH%Is872i(BGXTv}C>Qgk8nSvj{j^Ef zCzsxAk^!FHc%GYI?oZ0RBKM7+=PtG__>*LsGysi>Z@Rv0!L^WQdTZWuB=3(ovSr^c z&9&nv^8RJ~1&ADq3P$4&mmX|5T`kO?X~{Il>g@zxncre4AYj>bX>m~V9uL-xfik&$ zGw;O-R8Zl@v&?y*x1hvK(#t9Aj@ApTlW;4X9$50@#9&c@n( ztlSb}VK7u=wuFH}n3=z$9zX$t`itM&#Q>Y#?*fFdX7=w_FNTx+rL-4v?-k~b+XCrEI?>Jb=2ouy!xAutTy7Fgz(3dQVVghJY(?Xjsw9yz zry~W(_Qdrt`3!P47Y0V*OZ4lnj&W)zDZgwuV%MzxEje9wQ4AZk5J$4vLPv88?N?l9op^(zR5iIQv zH-25l*ttbV=f2AJfj(kqB}~!x+tx z>PHaIjeWV+9Q*F4;vD*s_M0tJj*77(izwl11J??e!dF{`fg7^<3w-YXIJOK z9id_{aiGVBvN!yj*F?))(E5q1Igam1;cT95z|uZIdr;#`<71;JT;j^E$XB03H5(n> zzx+qS&OWu+AdJ0h6L_Q45ZshJSkz=m{;yxZM$Sio-ZM9{N{~HQR~3~MXJ(YR_}tui zd!tmkj00P5|5%3Rl&SwMh6!RgkP8zA4hv-OEGa}~z@DwH`((~c^&FK!JQI|rBE*;m z{xo7o3bRZp`VPNaeY^y!!l9Kwcc7n}0JCb(=t>{AsgKA-;|%Um5Acuk)S}OM+KUk% zx6cZELaN+=n(+*{^DVeIuxlrfcBK2#+2>_#i1PNSfVf`5LGx3UF5dl+BhQ2oo~~z} z(W*{dULaKN+-E$t?dQAouz~X=rQ~mkqUCRqZcQ;xMvVUCDwWfxO9|fA%*6D9`R$P+ ztSHa*gz)ddK#^2zWZUJkIj#L&@IAu3T7@6*nCo*7l!1hS>*(yKckoRh>?T?W^=Q8Qq8NM= z?))nPH$legO5#z@B19-Pn3!8HPfc0ib+PMlYf#V z@-Rd7w+23bPVLwX{K}99a&-G|?TXGC#F!nrje8ycvPlE}()-glxye9vP;ACrIXcDeQ3^HB3EYJbvA}1)I#eOETs8}#+$9~Q@ZOYM?L#vHM zw<7e26~C<6mrr6BU6k(dGVLCtDJjl#&^__Ff!cR7GHAx(+TocJrN5}g^~T$fm9L|u zx^&DHub#MYT&P7_cqu(ihkE7slssqmqOC9j(L4|A7h(2>$2RK=p;+Vg?Y8<37AF!- zFp6oK&9Ymaiz@e7jwa?!7tK9;MQxS2QO%dC)8KkI!@8W!!POPc4a>_{1^HC<@PyU3k61f{24G0l$82F(28V1 zGCUg+N9;Pw!n8r8n{q<-AEDZ!nS-KAyo6{t!&MA^b#?zO#(NV3lAq=@S%tloSoU>? z63}ApLUO# zJtxDVOj|#C$WRw^S18MSCvb=s`lL6Yo|?q*QKe-jIyZjTc1)%jygvIHen>(*@k45( z`|pd^f2NO54i4V>5^Uv=Fh^ z)oA=`PL3J5M${C+E`-%l@0Zbz*Q=?Jk>ii{_Ct&7wW7^kUwub7 z9l$eKu}gjWOD?X8e$wHhcV@Q32<e&^{TO&!ZDE?zx&5@cbu3= z_l2YnUQBKyU(jm1`uFh6O+Q|`8}ywUUwSpzx$w|v8Fh5`-M;gf z+)qLue`5<=xu2a^9;^%oo)*Hz4mRxG$reRd?vhGuDKlRnG_Yy)qWVJAJS5Ift@M)h zYPfCC2;hh$MOt*kiHH9jHnWTG|E2veAxCiJoCi3 z(CCf2H_%UZzl!A*ZMWJU@Q?;?2*h^#)sF<`j_5g7ZjuEw!Z@Rf)T_~lTzcl7RyD1* zcwt$ZegTJ#m6-xUi~lez4oZuKU8jY*db8D%gn{pbo{DNQ;t--3d4T=aTosLns4z*5 z4*6r>Fb@s?sXc;4ot2Bi#4~5?SUU2ad|9I zwqsQtg{yMaHx9XjZkh%@j07R>F@26eLWqyrp%QzCe*pU%1t&$`@U-S#FI~_oF^uUC zi(xzTe!wrs3SrkiCFn1za+fgv^ipr1m{-m+!d#2YwDr5m(Ux_Q7cwq32BWBALQ!59 z@mBcM30n6Lels;_QSV}i$&bs{0}Fg!(6T=Ep6Jw0VgBs%X>)ZriETd7HdDRtLgaQ? zTAhV3DmTP&mK#38tCz)P@u6uBtvU%+pW=G|c3^QL+%qfv&-PH1cEt9AQm^sO;Z~6) z3&Zi=Uhf2ge=9^9+4SgSRyH3t<+@^4%A`H!dKauOGV~iMyWG)4hf6sE(kaVlLX7LvRC-J8m~M?-H+Fyv*MJ zsC@}0zF<+)980gAn{zl8J{N@S^I|ky9#CqHWlrlkPnZFb{4EO>?1I|n+^ZnKE)oMj zZ%AU?IRvCrAwa{Onu~{Bcn%P%;0QcG>ykQ0uk?lz zgg?LPHqi*--`zt=%(5sr50NGKlN}Eeipb+yUZdO48O!qrM?W+27DUjmM+V-#c(L1P zYVOoi{AP8bbI+l+Mu?pTyFO0&8a%4@EBIJys+z3{V55Uv(>g^-&Y*gaVZ1A&QyPM1 zXrMX!B$yHT&4J zVdH62#*dv;D>gxyM7OuY7IUM8Z4zjrJWppp4cJ1ei!gQ2%Lu~niuw6F4$j3wnDWi)D5$(P;E-22~c_7zj9r>jIeO@!=O5+TCu?uagMHC z6puR=|8dk(tTnuPq(*go&Gdwt0(1RWYGEG?{nDs1ow)hOg{xcK{vJ!? zC%z{`qq2mhU;cDIwW#y|Ky#`0C-0uR<`{c z*_>mqsLiSpr=F5OyO@S6yV?7-Q9_pRY{`tXma!R=M2%Mydb9NPIB-U`>%{51h)2MN zE3L-LcstgNPZxO41%%#jeL$~tu>edo^h)C4Wl`d*wM*h1T_QsOt@I6JF%5?k(GyAT#tToTXV0V%4mp&oJOm=F^0%!Q zU1&a8ajk|X5!8(e21NO;Z8722d;LC<0Eur#nj$c5B0A^*ZX=1Y`FC!_qA=0h>16#% zq7a;VW8VqL()SOjMC3o)RYB<>smX$~^8bX(9+KYqJ=UnSPJ>vmVh>jX&$wU~4c+Oj zC%t9apSFu;nT~M!e%7j}=$p(7?gH2<5aA}yTO1pRS0y}=Ag{ZDulJ=SlxlX)F^$^^ zptLr3NVg5hkAtF-L|s+%ey}@Z{24AJcp=73ccy@nRqv$;^hxf8zDMRw@s6Pk>ua#Z z*eyRJ#^?7D*}NAf%|K4IR_nk_mK8?1Jo6@()ZBko-rR}Ah> zjMFr@?%8JL`F_1**?;Zf&_lQ=(^eLZNS6T?Ox{aeaGGR`VqwmmgXjb!O`6@jEVeIx zqsJ*bXyUa89rfMe6YMVmk1EQ_va_C42DogB!xak) z3B!?kqM*sT!&qrUz-x-}=OC3p)(df5p)Oiqow2qo18!g(IUxWkC43T?I1#lDg_E4s zxP_qyHE`5d9|(t$KdOqGgN%UMAbU?Q=sS3l^XI|3$#oN8*NkM{34`z+TZwP)zID`R zxCX&U-rpQ9{%Rd$<& zdNr+j1bX1&k#}l3K92JSzsCaqpKPwgeAP%Bok!KspKwn~JIaj$;@93%(l)v*F zUAj3ZPLO2B%IPC3De}8>^zg26nX=d?`qAqlB0>T`|0JxiHkS$r5vFM~#7A2I#`t<~ z(MgWXmf5EXl~)xuJIoo8kSqHp*&B88KBOb=lX0IJ?+}!(B zTsU`cw6)pE>CnUj_J`$}Z?+hzSMINES~L?C2mE6nuY6y8_Ohi77#Piz1cJ6OdqRf^$udA#((y5Z4yAh<8=H)!I`k(H zqHj&njNik3NVg7cZqM)g{}_AoaH#(OkK4Xv8M2m`N|A&_lx3zO%91vbVk)9SNZDqy z#FV9}RI+45QAtxN$}(f9Br!zRn3<3%%Nz`jG0S~?KHuv5{oU7nUH4zEE1Egyy}aJ9 z?fracD(b2o6N9?Rmx_5y>v4=FfqTq#*#>&-{-ig{lw_Kn{Fr~=^TrEnft)dSG%%=K z3QUurJeGz3n%Vs!|5|+5(EO`lH@}ttX%_pC)nwG8-Da8hy5wXE?t+XD5TqHTX>rvp z%G_4L9h6w=7F(&r|N8KB5=Se4lbkF{3~`5zWc2i;Z!_SK3bX~*$=B2Ezv*oQ#Q7|v zU&nQ0&8~+$y7tcE(y5tQWOJn=gLQjV%^~F?37owOS*qi!)CbMU;a3s2{Tg&JKci4r z&~be*_ey;~8=MKnzAT#~=khpg!%2(`Qtg9o>4VU`XYrd?Xo|KnHw7G}ZkzbD2e5Z`*hK-5rBey~& z&LOVUKKQqA3iVDA6hMvb2As#+4Ll^EG0?e!q}r6FHzgT>y&)+s+?|i53bqA~Lsz%O zC^AW$*AFrn_EkVcT)tbUmIiOH(njv%dyE~JN9~m^-14JO;ql7+w`X^PrCUyWbh4@H zM!b*nok>)UDN}*9PX@(sgd+mZ(X!$>_hr624)(`oZRJ^ZjeU#j58w96qa$sS*=!Ws z5+#@`4E)g>opM_RvILloU##~0eCa#6r^W^*VJH8gXfC{G&9#OfzgFvIc1n}{-#?+g zmc+flTT!ZRwetrGuz5B7hhrCN2P|QmA6f4k?zVnn;HhAfR$E#=ApN7FD^wLu=2QYeNi%=aYJX{5ILh?&xFqT6sB=3>BoFjU6P{T{zc!|^+N*+ zpNKl|1$CT||1OrS9>9`)d#0Nj^R1K9YT|Q|ZQ+ zBg1Qb5$mKL&B!{gZNIX*&)}TK)eQkpj()!5679K8Sex2{rEe!wzi)%z`f_VKEYx%Q zq%F?9lthZmAA-6k9_>Ps8rQ_0zlyM84XullYbO}R8mURl1ze>hx`QhZ_XbxLe_v&n z#c1%K7F+y!H$@y%1s>_}PX#p{cq)?`JsU4Fb8iCf`q@@nR$OT(q}xAgBSH0x{SSy3 zQY0D>tU-^wX#MhhB{sVVSs>16Zi9KToh5PZeaYN`K+)p5FWqG-todM!tZ4N)??r`R z30V}cUR){63uk{K>*GZSaq1U2s%yo`=t88^E8$fT;OPG-Xt)`o9Jf{ikX_Zu>I23? z>#{FaZT69ba-x<@+XI)mAEP=Gts7e&sT3NoK>A5LY%kL#?f6Gkt>F+<= zl3;6i*NfmCe7@c!O$&A~lj8E84+L&%BkaR9)K>8y#1i2bH6xSuplIj?Ba<~%R@v{DWvHyjqm9n!nOM;z`$-SF5Z>!%b} zRBm+=%B_FcrwEG6GUD{=@6TQ7=u?=OI@2P}cakFZ;wi9+pcdjeI6{hK4|%A^GMdC0 zlQMdJa2c>GOQ@TNfJgY%2Ype}IIdtn>$jF4UN=7#^EDU&%G6ap5i|o?dE?O?{?jhi z_m8c7-PV#lcs+Sl#Li?b_U5=XeSEykZ*yxX&7b)4Y3TIPLOk4I?vW$jR2!1yxWx|Y zI9eCdV0pqNU*3M&=wrP@wQeq;miojfX9s#~9f!3XoA;okqxrDXdC4>3{yRFvC$~NV z8|5+37|P)6&aQO&0Jv}=7t+C{H#fI=G~g-lq7^7}ZD6rSZyX&| zF`y>0tBD^j6@?#t*im^eF1rx?5Dk<~pH(?k)qMdlsh;x^YjDcF*N6o>A5JK5JuV$3 z9Xjq?dB#UC_$yvJqxDgeoXwkRS${E#=QQbs;M4xP%E58juv6n1fedCgpQ+Pv{%cZA z|Ak3$rN#(G%dK{qM0Vk>(%VTg+Z`;VO6+B4weD9STI>bsT>PIhyBwn4f|z4!^L2Z<({Sobuec zkV9h3?*Evdms_$!8J@Gi3<-=XY zUnJg1BYJ(f1Q4QFu-0DvkB#4j8R9yaF8ZA8J)SZ7WI-Qc>WIjKvX7~Y{2uMXjHv!0 zBKdCz?+EH&`|Aw2k2O7$1-}-Q?9G@_vI*OAoi15Y;+yqm<%Ic<$guAWEBy=Uv+*$+SjG4uD1-lgZ(jH7;(Jfn>hZ? zYunXl2PR`lFRQn9NmowXYeMzY9=xx;Q~BCs(4@dxASpSQRN!kr>$TVov#&uu?^5=K zd=Ed1b7^x4LElMnvM+7Vck#)C4?8DvP1@xBymc9ZTODSUmv>SlbK)E{zpOZ-josNx zX$IKt%+-efWjl?e`Jd)K`Jbb~9THvJLHUpz zEeV8H2>Fr3v?Tx87_>}xHyEDWm9M8p_OkT#s8fpBUzDfdfG7_zg-ca`Z;Z7p>Xd_1+?(7(jfvPDgPSAAGk4lFRm=I?Pn*- zX0T`i_wrGTlo8=3=L^wOo4FOwv$s%vd9@e9AA3^A*L-Aq)&< z_Vuk>e2fXP&UG&HYatafJ#}schijKblRtdxW7JL_7<;hqz`O?{ikJhTDPufhXCCy1 zR#4I}-5fthUOyM!^*CK1-#z7~M}3q+f8fh`eJ!VDZtv-vhXNZ;9~`*Z7)<5&q_l*6 zpP&0-8&O4ZvlE~EdbAqdqn_2$gWTKQ*+o05qsiq6HC|UfdChC>2=m^&3z5-DbN*S@mn@nDmk9h3)@z{cZ9Ad)=(u?ZIMIqS}!ERh&yy3)B@>RU97* zXU^j&gq1km52G|~d(S$`v-g`(u;Z9VG6ewTaAF}(n$d9Aj!i4s9vj{${$Y5aDBYem zzs-Ogzn*u3HFVcbuGg5Dv7SU&j=M|{=}ZeoA0J@}SOJoQIH^`Xh@c6Dgoh2!n&;&V?( zjIT6%P-5D%MxkIA8?kM%29Rh-Q!Te<$m!19YQ?a3n|zr~eO;MjpA)}WxE19?gI9Vzp7cDJfO z9_3>s19c1=`I}{Bu0Wqp6^@5!x^OKLfp%Aq54w`1gWMNRCl{-D>Z)l|ZV;j7M; zyOYN;&pDpH4Xiltd*t=c`$&AWB`{Q+eYrMJiWwBod7o9cNDu=L7kVh;XpsPX$GgFo zV*Ke0Rm$px9ruKy;nI?uobF$@Td-kY!04|#)mgf3(#*j&Bao6Nmcnj*54$(Do!88hZy<)gZ zD_M?J>}~#Z-Trx}`=)khS&|QM-wm!XL96JN;Oj*J8e5C65@xmUjE)5`jMqH}4&8l8 zz=Y-OH#@OTn(1&AQ9Mp_C!j6%DG2ivdyQ3?{>cb2+>19hQwvpZR>C=C)8b2CU87&^ z%W;_dPm>??LlS4nlw={Ncg=|b$361+3pf7evF@SgjuN)oKK4J_)?{!ho~(#n#=y-M;h0e3(m zrFf&!2W;E*yfaLC;FiM=NGXcgJ?+sY!|i8mH?$ER*h|L@&^au1tS_M?ZjvBubqg8ns6pWPhCWV5rD!Mn$~5a|FotGkWg|u71y^z z7VgR&nv&{d8yh8Sqa+dCTRkynec~CH%J6P}Zk}gt{1pA3l-hi5N?MLz_1e%X6$9HU zVX!O*=t$&1EssYa+f{?QOSgpt7}kfiEA%Gm@jWSGf2Swy(mYhRIC@_l`->*4fLA@l zja;zfGUvN$Ux=zki(Rt`OuMeTE2Vbh3 zSU_RFqYEL7=sYH3lva^h@FK4JBD)BHiVHFj61$5{BBl#*)YECgM_OQ{HrVp&y(h+5q{l

qyA*8Liv$^P}9OK7R)i=ellfy3@8R|kQ5Bs}y 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 H&LZp#5lJce(3QZfrg)8p;N+H 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 z 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

XzD-G~`mRG4lAv1k8fCB-fMzHP+d=ck}Jn)?VDi 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%0D_4?HZ$L6R<$(Yl zkY_l&sw{=qe1f6x@&GS&cgH-$+YIPS+GKEyu_Qq%&iFyN9~YHzfXf-+an>0Mb?+cL;1b^TVlvEg)CVnNm4W+ zQOr!X6h)|1%Cw;pLWtSaR6>PRDls9c6h)G4#@MAW*%LGNvCfUbZOrn!eZJrC^ZcIY zdCeafFXPQWd(>cbD7<^*A?YAS96OIEaba63WnHYop!mIA( z@1*z9TUeGlk-*N0G-0I>j^o==)?(xnP z-S9@A+M4p1Ub*WVcIziV&@Ott65(AdLg#+l6YBy%IV&a|dJV_5Ee|QIRjusm+5)fgr)P;@IchmIx2+|j$ zM?DuSSA924mX+!|H;Mb$$t_kbUg;a3n_Pmxe(QlF^ZBkPP8nZ0Ps}QD@g%HZjeSoU zj|)2mD@%pOs5pxHy)rKvI7Rh;tyk@mg-J{Y}ASJHGDkd{a++Y z(K*#w40N75u|HB5YDg2Obf`vCRH892x8<;DT8K&=e*a8HhW9wjuEAUFH2CGRku3_0 z>W0`&>XihEQ!hP}TFg7(-w0AP&v{fk;J*tEuVWiZESeWd&M#Ef##|L!%aN1X_5ll^ z9yCH*_hm9HLhIb&e&;7&3_zXw;o2|u2qU~!GE5ie5u*Thjgwl>R*EE7H>0F5{w+BS zIAtY?&j>w2s#S)ls~}c8wMvVZj}0V+JMSt|&ugvgFE(V+PBqs$XS+M#oUL=V^MGmF z58;QsP0EgoRWfhCeS+ zlLN0^bf~V<USQ;_rULmu+`% zb?sU>_q~c~VYrpO%H8PJQbc7*0zNuol8+mLbe zS!CZF@!b_I?($%nXc488ByNKNh{aA33nu z>34*h>A#L@aqy6OvSYNX2~YAjJx7&~z6y~~a%xlvpIgO&jLSOTt%emFCCj@KTLd4l z(G`Ou5!pmIH5xC)k=VAb)KwM-ItK{Y1J!5myjBdIyS*OzFcV(;9x-!Xx)LFHAXp=d zspr8>Py~VwJ*NE=l17WFVzv2uB=O-uf)?NtdS~ecbT*SHa$FP�CTg2KGeGPCXmX zSsR{_*_^J$FF)H5S37rL|Mt|Fa+Ek&3pq5zXfHJxaRmg})6Z1~YS?}*}`-Ju@b zveFAjGZ7o^BzQ-)j;TYvl?mp&?Q@Yao5E)yB!H~Sr9nrQV3Qd7DLa{rKw5#A+xIKn#6b_oH6ZEhMu{6;-HyjQNF z`%GorMWjFlNHS&-BClZPJiHb^wAej#ewTECrTWjcTYe<|eq+<<+Z6@zqt}~ARV8`P zX?05(uJ<4537);<4%}BcHF)LjlbvU`$L&^m|Lw>u?_swu<=bD=xCDAPcpTh22@=er{6fPZm!vr>oAtYFKYHNV8!*F3( zawbIe>%xEzM1vwSV&kk6_x*lKDv*_A`7o!;W=XY}6$(SomVD~ZAo8S%LFPcobpPn5 zJrUz&PpBN=UBxcgcue&f?R8qa3~{^lT?hV)lXCYeCuPgw!|is9Z*y8c+lxB0u=hEl zB%T4(^xmAd5z^;gD5b^6Tl&v$iN=_LjhkiO9nt|$BbWx@i?*pG{UvIAtjBc7g8^H8BeQ5xG z{fEvx!P;d(f^lC0znwAm1Y-Q#r8IXMs6X7$|3Dg_*J@BnNvnvwhsnTy3W1M}mu%*{ z9=R`^%xTdM6g96S&M(pkrg+F0ArpmUnouR?s|Cv0k9v_CPD_Sc`H{|zDU#TA8#EC?$41A1erC*G?^EW1HT{Il>A@P!+J;>~| zTa*EwJ|1dZlhD{^f+Oon_)iZg`#TQN8=}*b)!H3+&m6}TN7!D0dbHwaY}hpyMprgZ zJjHZj{CBkfPuzz^#AoT-zK6Z;dZH z0L7SPmvVr)EE7%YX!nDOf=i<>{z>)@=aDTl$dsrhEm&FcQzzb+mJGtJLfTj%MygT` ziVYU(aZU}QN|R+ofAR9h0K^)Wofw{NElIsXH>k4E<#}Xz$5A~qTcm~OjNZm`W475r zjh_i`hsPP=$6mW0!l}+iK_h?5F54L}_pL-cW;3@$oDw9zd`7F%jW|)}0R1^c7#;&L zb04gF&^Uh@NOj`+`Hck*9lj+79;I|ZS7SW_G*WUP?GWO|b6S{tB^Gvf@vYjpIdc*A zN`UgViWIL*(Z#ME=w^7`O_4f!B1h<#(x*pG3{eUNlV?~d%4-=o=cao~7|yUQOu z?b|jY&q-e|5b0UF^XcKe`oyUBUpEC1PkO0h2ql}%PJP<$Eozd(IVuuA8GR$==sdBn z%x^DPfT!nJ0REj7GZpxyx4$x4f>P{Zv~uHWA@P&;-Kr{%1IEIWU5w&RTCU*stuU$n z<>8O}-pBfd`IK!5WbW|Quf!UjaHvFfx&LlE|MnN>QkGNEhTxTOeS-k+ojcPT=rK^^ z;)U!@+B{w?!`o*$%#?$>aEajZVA;jx!O-x^_MM`!?Q{Jj_{F@_UaG}vO&YR*+{nik ziL2g!@Lhh9#iNDgxyLz6rP04}OZI>GHPBn^vU00mPs3(zcy=xm#fWH3jt4gkjy?q{ zzskhCJnRk^e&5M}czLUKJExuh($R- zo-jfdEc2Q5qV;2yi#c62295kjS`#OGRVh`R=@Y{_Y}E3pz?^VT{G-106f#FG-ud_9 zUE@g3cHErY&l9Kc;V1h^gsD9tK*VCrhJf&5BMD+K>C^eA^Ln;_OFaWo6+Pj{7e7Fv zjf;5?_KA9e=2uW;F;P#csfTt`LMUgcYUhGhrKl5W<8OV(%YSai*VmSnlHfORN|# zVpG|ZmN^%0(1}H#`+Ym(IA**UjBt{6O(_c3MVSZ0eMuPvR7F*7qW z&w}=evZFpvSG&+cOH*fK+}1dq z!o+UmiIa{{5ALI!PRzF(t@Yp!IJr=dK379}^m`{EACJ15&R;UyY>T`!Mp$$yM_+I8 zL-T6v^AKb)1vwISBDr!!Lfy!pX!meU;A-}3A z$&~AHE+D(!$F#MR6B1Eu2{H7I^&Mk6{N?4pe0tgL_OwomeZuvxzQeHZxk@qR&L|Z7 zx)WXQmEbHz(9v!vhXTd$GAOXM%M+^wIZ#pO4u*v;?l0NZ7#xZX-p+uJD+z4wE*;Cv zSBX{XPfe_N-P~%hlm`9{KU@E;n~oV=>Rcy?-m@Ig+)_L(_NBr<)RtlqnlQ9-`zJWN zNsT@K%36`(c?WYaMMnkF4{3XC?IJ{UT%5ZROAQBtGHqAG8$sthQ4&4AFgiMF_SdSy zbH0x+&l3aRw@?16KdLq{nwWC^dnse|^meIAj#nHMJHVi`P97*ClYTvD)T^YJ==0Xj z8de2fdMwA?Fz8XIlg+Nbr_%2aqg+_*rhD$nlLQvtSicNr)^HxRJYn+Q*j(A&B!kI#g~%oeub(U6A|!V^l7a#d!-qL2Hhcfg%1C2FQZTRY#NyBKqWd ztU`b*F6qz-uZze0ylA^vcOd!V#h**y1!k+*jStigL7I?Ko2`Gc(Db`BuZ87ehI9M?@aJP5D(V8H;#7ab|~Id6v|i(7;v9kPqR{W_>*1VVH9UBWA*uUE&mCc*R6p;t6vhx zXo7>zVKD5xpk~c+wren)DlxViFluVF>+@6!>I)|t=v~I3&5te*EuGHJiowuyKhF1G z{F(2@(Jf&sdCD>;|0Z(WpHj1(#SUkn2|f$PnCGh0MO5 zW$}b<#$;ALw}z}jr?1{ZVm4W>E|6&WS@Fu2u`%DE!M!0U z+k=1QUQ_inz&qfXjC^s5FX-0LpQ6tTgCg=WDY@01e;pWLk-Im^KvkH3Hnk2Q_o76z zu<*&3dUKw#vdE8vk4klF*%pXh>F=`W3V$`LZ4jJ!a?Z4|U{?CO+2a_gqyx3eM#q0` z-hJ$o@0(1G2zv=nx+vK?ar2>v>&z!gBEKn>xB-Y9* zjy+$~+tr=4>m&`=9IA7Lp$1Vw^n`z%x&kr7(Tw~jJ}02>C$^@n$M0}GJv$YFz|X`; zD^fLvBWCB9ir861xS!a$krkc1xAAjxJKw;SF_ZTI|!owtK zZ09h8o+AUQXyf;RYw&UVrWJ)PeXb1?LD{aQn0b%ekodVyCH8EnF|TxMz6F`HnKuY& zwo`i_dv-pmkco{n0i=K=M#`m%+y=Rd)~kt3$9;>Ym82V-jI(eQKHX-P+L+pkmZM%e z;@#Qw;ckKk|FwEX9SsJuRvNE_0q0rO^kNJ~jI>>fKWeItht$9sbWy7|pH@WM`o*nX zlbz62FArFy751SDcwSfXanBISYw#0XF=2L5w!@9767$GB2b!rT!#uL6=Vd5hJeXp_ ze`E-5+a_JQ6}i(AhPZokpLI}=RvVX^y&z6Rq|u9)oQlyCOL#=@f-Tc7nQEdWlZccT zlVLSYN_Dpy;O4ge*xShNr39Qd!$gu@v_GDQuBn9G>A}K zY!GV4*kdNBpi6Q6_ir)BmUeOBV*GX3j3lOMh7bPdK+`tbu{lWDzL`~jkiDZnCmJ)b znP(mhbW{cH-iTNbhYrNN#;jGt@!{UsZ=Aaz>^lP97l1V=bZb8!Rze8# z;fs+*4n?G(a6#75w5B+e1Ujr#jAVQ^*`mWMIYk1JnKvGEZ61~G96m2YRhQbC2_Grk zA3WYoCIyKz&&^iM-U9{EhWMWJlsyyvwoJ2w?6P2DJlMcq(&M>pSr$LcN(prW3$HY# z`*Jn^WFqk#Um)etO8!W1y@mHuVtj%{*Rl`=$%9sf+HloktY$zW7u+GSs{bxL$^JRA z7`8a&);_@fD&TaOtl>9j8}!Z0^eayQe+o%i!?=G9mq2*mbFI`o@=Bw(oicBs)TnXN zf$JlK-NVSTM|jPxHCelO#-5q1izh#+kbQ{60a1K(=~j3*$M1m(09`3%&C)9j^$8M7 zI_Vpf!7qAz%e8TC@1f@58&kR&lw{*bnQ=L$XOtO8*2mRrF=$DDBm3-x>04+cD@$v* zeE$7(#LV{#e4Bb%@CG0YUd+T4^>$Ib-)@@-E9I>$rugp|Vzcr;zUrHF4|tb>RJ82#4?hlK4_j*U9rn;#X513EcsfCu}TwQ&!bj9gFH!X5M7 zSgP-8%pa|qEoLhNOe{)Mit86+m8VW>4gS|8e`j}*gs!QX@46;=T{N(Tje_xA{6->> zEy5d%1`~-=RV z_Q|h2l5j%>M-hGFeOpP5h_g)sy)dv3%*}HkZOUXO-Y0Ij1BWr!Q)M>b93Hau%#a&! zrUuAa{EaCMlUaiEvTq`@I_WX0*rLS_PIC- zKxnBxT$Om-3z|%xqOhHB8yY-*Q=9Lf8u9p2=)7(owtSILFw4rU5;T9>a%j)DuG62U ze?8n1`86_?b4~&+PEkZd!?nDkB}U1V0o!2;p#P+E{Jz_m_DoESE}m5(KcM$Ih_>rC zcKcK3X9YIk+J31QRV_`exC4q8?7W|UNmx3pJ{o+xvMo}0 z?dm}MEhus^$2|jZW{^aTzddccu0Z3-)VMsfFsf!HskOL<-z#dx3s(y+a=asl1FwB7 z${R28!o!rIm3jk`1c3&n;sCA4EXaMckh@omc?fggOVYH{@4IrF>5h&4cTR#M?j`Tc zz~CppwhuYm(^zju9|Dn=t-BZP_*4ofX#oBO4fp5bF;hI5Z1G+j_l)Cn#M^E&?=#?YM8|0njX6;%3diY{lvEJ1JN^{Ni+~@G z^o22tGh2H294r<0Y76`{B_L>PIhT7;k+u}Au(kPHWdn6BZd8X9TDX!vJ zkUHoK_!pgW22U5HG_$;@#di>nHXn6}og2o-pnnGbRXYizOQb2(U=679O`TisTNv<*5$E979baWSkv*Y6cMUk^0brmIm~4#lI5 zZiu06=UFqhL5FkzvE*BQ zn@66`%D*Vtz=qp%r{VqA&3PVq4kAc5Wyhh;;YIkNko8~qE!T&H&CPpPms_+ejFP!J z3O5yT%DyNle7LIU!1eojG2cB{8&{z!h>bPf%+q%U2eV;4?Uh}sghsaJ!2ARKuon6G zm4=trxCCgEj8#*Y?DU!zwxk8kJzn$_dCT*!X~AxGJeJ}sf&Jb_{VfuJd1k&MF^dC{ z2*g!L$$=D#Jd5cZDD>c-eSEEGs`ygqSiain-!l6lqpfaU$YSfE^#+;FafXGoNF{3! z%mGGbf(G8`g&L%5MXIU_lg*vDeu%3pb5eVzkCk@}6@B2A z;m1%Q+ks|c)`slyI%!#QpDM*qeIMA}%e>a--tA}qe^!oUCAL$=OTAJoj-6>(4-jQF>|PRTOs zP39!r&WR(|6Rz}BABt%_v+p*f7lu=i!XIKw0;#`7lqLA}jadT6PUHoJm7HUaQ{Ee06a}7}-qtKF)t8*okw{JXU1F0!Ra2aU z+&pGzm8fUIOn2Wx@YPK>Mp3_oQu&Et{&Nz$h$h<|+!~A6DWS^0Gofn*8JmA>Y`elq z@b{N7BYv_ZAJ+Km(FMVD?-KG_P+$E*sFixd>XP}N{wZRJH7SFZVLoNI9RIB<*qw=# z7TUjmqbu0FkITqp_CC(ZonF`K*p?3SMhq`8R0X?gGPoz7o4jjH5yzLv3_Dxy|G=Nh zb#Q~CkoAIB@oZO@GV{oB8R1cTTF;q%qwD!9K)%cmxp9LfuZYLEkfGEqJ*rUIlu28i z9w<0oofroagWp+$=a?E|&$3KOx+wlnx_8V2LBQoYHzOr4v8%I{I zjGsmYFzTO`zaTdDi@L4IX?qHcQQH#qc2-zI;F{XDGydCeY~Qj5aNTQ=zw?$`bO zuKuO>&)@FkqZYP@Yg!o%Yroc9=)V=wuqPogf}Qewg%TQYc&g-XsqI0QsO^wlpk~~J ze<(&6DkSUhkuxk>Pxp8Bw-cTs>X%vPvHIrThYy)uCR;gui@c~Y+H1n$W?Wm}D1_XI z!u%ZLaK7cKW3Hl5ECgjQvNSY!_zdm4cc?KEtFsS545v<=77D*ljMl}*t1%DX<4+2@SPDz zc-mx&fpOOyBe-iBn2$$wg-lLAogbKPdlV`Fq0jnTaszv|>JxJ>aU2ivpnUKm-Hc?N z!Jtls8*}=uJxD60c0=E74S3Lo>r*#S!<1Omo%-?c3uUy^AW(kk6V0jEL{TX{SyuC+ zRbO4V>rIz%=A>Etx;}+j?JUx>09yX|qR9bJ5Dtt^lhR)XG>h`B35f@7P&n_-E?{RFzLo*s^OjkRiPpJ3!!tjEb8W}10IZe4~B;H2x?36XLgN*&Q0tf ze%ZsU0*5 zWO)indaFb@#Gr8Yf(ucEyu6EZSDzANi7poxood}Pj_#)2ey#PS>309Y(w2DUPUX4H zvh;+_V26rGj()zT2bg}dR1=1WjQa?-mt4H6Z9dn>LXyu<4>)W=i}Mz zEL(&j6lyVocbOygd0trc%{0Q&4-tP}UsEhq(^8%u`QTwCRlpDu3C7>p^ee`$#c7ggsQZdfunK6I>+xiA&dIY zkje}7Q{!)}rz=|~pIHBFjL*irKKWHq_`RL&8E_vR1&r0uX@`1J9pUKrFQJg_zitYZ zIXW0t@nXughW>f)25%<4{*^W4wh~dXJ!a7fi4~g^3+~O(;@KLu)W`f9lLFRfA|wGxK0f253JhbBR3^^9WfFOH0Lojd zmAI`Qx&H}gp_5nE5?qP@Lq^^aT{}@f{OeanR>fNloGR4Vn{6v=6LAkRL-@YHedU?n@KDR0I^k5ce?| z8oa#_iQr*x#xArLKDaZf5cY-w_-7Aw3+`;jdfLJ{Er+OYZ>Yv3dE^^-E~7jNn~LcS_4`AX}{xe?T3Q zK6&5jSy?ZSxidvLok@}eAJ!f}4X(z+|GC&fVKG~rL<$YgVC=6;Z)dkOJH=5g!8Ue) z$k!oCi0xo50lq;; zz57+it7}^RY{gfD-|T7a&a1iMM~%5XN`jtL9lSO^dA^@@V>OWfn%qE(_mo=mJ;(~ zw9rUF~8C7x*jDa4O~9J2G4nx&@U$Z8gW>fTnXqC=mPZ zf6alhq%hDwSX=|QN~8w~si(G^M4EMIOtLgl%I{7$dNY(m}k5k zu5tt9gs+N#FsJ@_H3c!DpCSG2wrA{r9MNXza$pGEa0wmyya`qdT#QI)h9>S;yF4nM zj8r8aa_7qJxrl6d_6vEawH|5D9%G;oPHKd0(n1BAV-iLJ(BnU{UH05+`XB*~zcP|? zkf_kpro%Vg&fUPlMPmY;xqdZtPdS=*KBi0@9}IBzY)!cD^glfT^G2+26jDL=cv;|w zg9=w>N(Fw$Iv!N~{eFy{vi5Gb1o6~B?;ZI| zFQJTW5Dri~mHEH@;A?vl31V>VHi@iAtSCLf?z(EaB_ErKJOY}=>ge*GKejk=pfVi- zwPTaJ}`7n_==5t4W8 z!v;tKT9Wogmv?v5o`wK1Ozv6mWvp5T457Y{xl|9TyrP1ph0z|ra;{krwWhYn@> zypR{=J4}6o@c65Tp^NtQ*j4y)ts>evR}J&_q|vk1p%{qdK^1y4%v+29)rqkL>5rd_ z600{wq-1oP8G| z?^s)Qz%kP#pN!e!330BI&Oln`X;b?^wfrLnc`XIx)s}`YnNJz^R1x zp*HUGLXIc~jUN>ye~U za)gcVlq|L0XU_NYDh&M0R=Dlr3|>5%+27YksJbcWS)k<*CUp5w@bgGUG;m{Rwb{gs zMKK;B9ztFGaPiBT+c}#7XRwt=}WhPA;$GH zQJ~F+H;5{qx#*T7cfxuX=d6rUBCaLSfh$1p|o9*932Ga3x^ZN*;)T(jx7N8*Mo6-Fmp@|FbAqJAsk=m|>ytH4)KuM&jx zHhcTD{^t#&XZ6*tf-0!b1)ACBSTf~kH1Z znm0o#&wJ)d&TB|a9YNAk4VGAP4HmOIqh&KzOwYqb$^XV{U)SnvluM{IjWj)g{id-0 zAvMPys|7xYW6<8Yb7}IGL1rw{`lzVeAjou9C)?S+@`m>Z^e~6LHXlg`3p}glom9MvN3i z;o&YiZOwXIn*_j-R|33@L%4d1#L0`uYiZ@nUkun5J^}*7&2rntq8aLMgOL9{zeyer zTlc9pWl=mJ*P<$ko`e$I7ON0z>TW>S0TBh9Pl{ZFA7{bkzCWV7hkd`A21-f*w$BUH zY}J^ynpi&UQF#jU7rN4sYoVZtXncaV=$vQ%%@mjE#-y8Phf2iwr?ZkI z_K`MI-%smodQ)5zzjT@tUc+)K2m>mEiMZTw;WszsmKpuNwBi%t+BwG9Vapb5SgvH0 zla%_P25@SMRp?@N{=(~vU#z3JMo)bqp8@%Yu`GM+0t@SI$YVj%E_fdsYY08!+3S#> z+K+%B(gT)8u1NBX!ZmumBAylM0j=C9IBiK2><$kad>4C`x91JV>wCMm44G@E`%zKm zz_A}XA0Hb4-gl?Y|Im5H#=0p&Qs^y*UBXu((lChdHfnsEzzc7{?zJhE7OEbljVbQ? zHFHs%^ZsR2dEsVn{1%<58P-zKsN`P2{V^@7Xa3orWiK$V?@#bj(MQFLu7R5texI*` zx=|pPx9a$WAO7v;mJJ!MhaoSmo(1i}OUhJJ$SD-I~fYLX@RicG6!Z&TU*)ZbEky*u;Lt@vMZ;PlJ^(zh+FrT6a_GXvLb z8EKx~x?i*Z>o@-X3PbjnHhum`b0OrBRjpS0mQ>@l|2Zrc%q7t4ghhc3I%3q;_L_E| zGh1EjrWD}fC!OJllU5ty_w+*2%3mA(wm7q|c#|XZa|OR2n~B(E;(&cN0lU`F9^)nX z&6Jbz2Xn#x_SF~VyLJf=?pA$RcPviGZr$}3A_s@Oi1{93*fnV*{LgL=RUu0Z;YP^U zPx3PTUQdV71ZiNPqXe#PnjUjnFgAT6)G-Q>0cqKPIKzb7i#x!{{XEg2PyRW-{4isUBbyyB?sTCzC&8Tqv`f_gwnaDnpGAc&S@kxzfaCXt9VQkIOz8F_NwQQ;cHrqH zX21=3X~*Y=e}^A^4L=0&PL)C%+=CmSdGD`@)j69x=sm0BefBIAiV41YT zl5vhdx7t^1hGtug+PyGT3>Lio3TQrB@tq+TU!XN!ywidw9T2DOc1(Zi{6aqjZ$e7l zD9m`d)q=7^xL<+!{ z+xKdFzf(C>+44lSfyo|`-}?&b(>UuM7K<*mE{S}KF2!x&vE#D$hLpQkI4ZP^KYAo& zw>@vNeJE+JbPU1q!&osJM*M3qeiOeoP$ou#*D>v|X$P8|H*v& z^>6z{Lrx^72Px|MzZm1_C?%i_lWZVK zEV36K-g$+%RjtVLffk{*^{k>R?hD!tjqE%{Jt>-wFA~-O%M4PpvfaFwbr;G6(iDp9 zTl3+d%j-aY-~E4Nk3AdCedRlI`b|y*^UB+Z(D-@c+X&u^g(FeE6)ZnrfomUwu(lFg z8ezA*lt;#7Y2j_a?}z(+*5FxJo@Qc{9AHofE}6crovpdv&Xg*J5A=Eq7C?2!;!i_O z!%~Mm@*b&0r~G-VFga`!i`iLZ5FUqzn@a?%>>fyvq1LMi&KEi9ummHqh87Np6OE}; z_oi&%!q-~(k!-okL3>gOis;j`MQK>3&cYiu`HQIL)7yxbNfvywr3m=Te!HYqyPpsz5#p&%0lp2hzsiQgE@gOcwm?oLr@T*{^a+{&?VF^9 z-I16A@FA3g=E|`7O{xpx?D8(>M_qEmoSdJhpWXnv6dPYS5()8Z_z#O#hB?9xg{aY^ z$fThKyO7bOQ^@30PSE4RZ`|-(fAP64u4SD|jT!-A6}ExYl0(h$qu+`fe#!Z3C;^@p zSCszrZ?5m^I(XRVQ@_3i^JDVsX97Xuz19WOz0`^Wrq_MS_6Jf=7_2Y*Ug{c1{o-Oa zH?g&`(&v{W7OSWGt+do8koX;XlS6cUP-~N}p3995_JqiAqOsFwOkAY-c6}|EZ05}l zVNJd*LArw-<2&w+UP$XWV}keoxWkWsAx-#E&Hq>Im!vFA6xQJ0STX46B0sliKa(s_ z#jFR$cDI$4tqdzfBcN}7ocA`nwYe{Qv&k)q>7c)Q3eTb(%qp@pdd7b8fEm7+UuUto zW6H8hH0|@Q9Ufe$Yx^Xa{5%u0$6N{pDnsqg?%Xeuq#JA8x#wS4Uw)uArM*VFehp!N zD8E|udXUCvd2h%WXybdHCJ=by_lL~Rn`y?cD4bd+X7#Y)AE0}E?Q*8cK}O6437>)7 zjRKcA^gb|UXUP#T)+U=>e94EHkiiKCtODkn1Fd?(07}Cf)Rhi79gO>t#ki$xAKHWG z%#QUlhXXsOtZ}W*VAIc~u@1VcUqd{r7l8H^){8w0y`w-dTjyHz8F=H(%r?)egE|v{n#_SwxOK?t6GyfKkgkk;KuUgOH z=_zE4W03G*P`j_-<}gv4Z;VwWcI1%rq20O5y|23p0HxKn#|n5lV2*9m6lK~^N&zy(vUv@E^wpSkg%)2YKW_;>*|GwjCloq9XOcO%z2KOr4Vf-VSumI+-6GRI zzlPeLFTzN^zP?chx*!DYx6M3_Pdz&xsmR@3zw)dJ;&glb+T!I*;b3voz4D_@Ky_;- z0Y;;UgRezLKk}bUF_JIS^l?4O7I!r8u_EVqv^>9D1yl=@!Fb$|6)YF!uv?$o~yRT!}mTzX)?WM7s8=F=y^etaz3N)ofPCsk=Rf7)gO!Kxou zSf0V|jhr2>bxWn^Dgry64&`9X`})he>BXD8k539L8i$L;Xl?ZRHh)L>-(JxlPxzBr z?8kk6G&0b*1m%YFmA$yKpvrjk5M! zvyBYIx0?y~smFK1%c0*~nQo0)QA96Q?*dT4fEU02Y$nA$nVBNQGhJ^@+hI5I2Gelv z%oNo+WVZfmByR4FJyS`zYbWYtCDQerTl#Jh_P2TDMks49;zc_xUJblTbywpBTFMFR z87qNVvtF-iux>EgC`iZ-Be2-Vqp?>;L}S^9^Ph?^S9Wil{~jKl+#FG!o*&DBo+K^g zpQI}U4c#WBuI9S1nm`8~0ihYiK(n2hLF*A4!n1s2*6V~s zU6B~pV&r^1w7g(8p7F#VjwL!#i`mbR8@_c;jlZ={t8AHgWTGQz%yC>`D@0;<|=Dz53UnVU$&94pc3NzHs)kn7p~t9X%_%cJ}!11-p}TjCrRE z49ua7y<0&_)7bB4tCfO7k;SW(f*)S3bk6ydTbvaecHqfxjIh}?68|=@%Wx= zC&Uo?yht&kVwc*112y!P2flxzDJPS2REb&)isn&!(aE*b`s9Q!QQD~y^RuGPvWEr{ zeGEMUKNk1L$vRaOr-3IBi$)Isy^7Qtsh1ek`?L>WmQbrwRFujjRdoLw4re6}#yd9= zHvLYss%|svv<&U!woG=j654k#^v{Y{^0Wx|oSqVgie-<@Jr~r! zv~FbQlCkMVlbPl3ot?nhm)4fxMe+?`hrzG!Qu*ssCdJFriu*5z4Qqc(pdQV^ycKv_ zfw3u|rKMoazZ5S|&6cjDsESREnYhk%2i-Mi9Tt%IUi_*mSgu>-|?bfiiE&A@O zB^<~qL7jB$L3=L4#ZUZ{F+PnB8%($wLzLD7A%NBe4Ji9-Imw2QE9i-Zf?MrV^jcWI z!7_@yy~p|PNs8LrEfmC`GU^y_VGA3xrY^#3t~DdyoEIs}PS})*lqFb#%I*ag9U*|1 zKF)IkfIOBZa=bp9_d0z3MdyUGC2jd!_1!Vbwze{tiYz-A__TMT31R)Nd@V2%MNd*4 z???|j#`W9||NE3&-CMKJ)rIVf7CFy>O*`X-T`6i-F8@GSNgQ!ZDTKKCr!Z2P)Bl}J z61+UXExN>!^enI-Ub%`t1M1j;Sy_);yL6#)`m#HES_{uld8pO}+ZP=a+$Z_Bco{YI zfsrB=b-5evbIALLoe4#LcID+_OH={T^(wcmWOtoJYi1C3{}e0X$2#GgyhbQD|BFSk z+-e2^JP=RF$YUbzV(?%yKX{JCXyi}VyyDNc%rcUfljHHTS1fovSeSSf zW@JDlJU%v?(qgqo5tSwv``-m zWPWV)pi*AFY&ZXZY@K;L)ZO3zZ6(AcvJ|G2tt_b|>y#x)nxb+QnF^`MRJO)!N|L=I zikKp4V?xL_V+lzN3CS{J-_2k!#w@=xx~}hieed7>hldFBS4LUrqV(!ia)cYTaGk__TQ;* z;%fi4&3(6gTkoCGbbSi;_UOlJ6S2MPDBWGxQYJ>cRQuC&O+TBCic`Do zT+RUK_zDFRW>?e{^vUlcu6++X=;s%$PQAr$h{9~saZiGav18*RD`ZiOVA~q3K%cG} z3HjtFvw88z0uP^?$9vkj^*h}7AP@()r#J%X2Q!!)w= zGY~PZi$AM^edb=f+3;_n|4kN5s8mvSqZN?*A2oMA;E~?qH`x+Wj#pBR+vyosI-hcP z(CDdQUMfCyvRt&nVrNE{bemfdr2t+e-c(g_Wx3K4op(RXc=P2N5t1Rgtnt4GB)&h% zN6^2Uql{4mXM;G$>`f}q0L|@>o+^qWgrLe;`;M}x@)oI_$DF?QykrqxCK@swGS)vY zB2Nd6?4J;8==quk(^>7+_3LrD5A9;{QjDsl%!CPA4QLcYA^@DAyOz^6`Hv213Sqb; z>!!na+tzY6e>6YWJt4+PLFtxo!mf+6d?Z;-D~lt8^*%C;rSCh_7m1}PPvrI?eEm}> zBT`|QY$U!1g)oBX(ddEc|1;>8K<^?orR;v3MX@HOa14<`dgTK!1i9v_;s=N?471m? z`4X$+okLGR21UG;xoUc`Z`{=o>W`4S)G}|zbmotoc(w(#KB%H)spS<^3;+$mt3bo@ ztyQ4mezY(Xymm1fXO0TU_lp<^EvDb9wB|9WfrS2OWl-|TXdyFE!#}*3k zhY^53Pyqfwzql3Z^uO?jxFccvY0+V@&qgRgyK5M{62Br0 z`~Ao$yO5|tb7dwCtn|uM#zU`*0&TR_;;j2Qyu2t+@;*JBXFMP!TxTQ0Fn0XF4aDxd zc`IsFTLmqv)L5%E3^5SQ;CcQB*Ft@?y<_Wj*WwCx0 zQ7$2gUT+Y)s-?+tcNG+L0z@Sq%&UTqeWV4=uC)fL8`7D)iIJST9A=4F4A6e!dsR`yj``8>rUfa+3=6 z-7nk5ck2yY+<(Dn-+0#Tvy@nq1nf)2$1P{N6bq+bwXRhQ{jy*B>FZ15`@7^MUn`QV zsa3Afs$hmjb8*K~;itq87hNf`gZ%jJkIPD?RTo*fdBh|+;DCDDqlbS*4Zgn6u5$ib%@Q~=v2 z#1i5xJ1~Qd=apOPC+#8`SwoebxR z09aEUJF+r-6Toolz+!;*!570Zot!Ks<>9{-Mh0_D`7I% zB&$>HqUNb$qZ-HS@!6K_rS$4$h~#K$RV4ecD46Y8(Ij0KK@*b~O%-K9?nG9x0`i6Q7&mVCaQLI29^j(!Ue{!`H6JQwg4 zz>01e@J;OCPXCaj=|znBEuD_7;u%(env~%YC0LR_Vd-k=Cn~W9>-y9bcOt`R)_MmB zGF`jU#q99-6T39R=c*bed>)QrW z?+SAw6<4`GF#(L}Z^m*>T`~q94cEcRXs~y+Z9_`|tF0{N#*0Oxz$k>0t+p)h)t}ef z?8uU64AQlGSz%F2FVJ9{+f&*Jm(7?;l`q9b;~Z|#Pt?NTv`jUSZ#ZaAo!`@E8%a`2 z2)4_f%K6}#45a57VE?e~O1grK5NM(i;!&hn;l^5t{!68QX(zgi?;Fm&6_R^_go!M5 zRml!MBC*2F+o$&FX1^pxfgwyBk#(Zi1v|!r89rN54mcvcbsN0ri1eACjy^>vPnf5-ZXm4 z@=9u_KV#b}Ny)brCZ90XHs1NWZs=#ZVwRN`v8A(@nV{*dbf!u6;(?>J-9GI#iq3>A zODE+DSmQ~2M=IKdd_W99m|MYLs3gcSf`g3dlM0--;1zz}gWsk&vQE7q@-h`T$1dw+ zYNxJ4gat>HG30vo5p6KJzxwhw4Z&y2CYzJSTxAS?+%q?RZArc5W_$>;PPdl=1!K&Y zmJkLvL`N~|Em*&+I>C)~dqqCJ4P7eEU#wfZ3N+tfywnEGgx1IFiI{bL#qP02Vj@`;p5H&H@_oc?y;`qRYICYF_zVSqr3x>Fky7ZW|$QaZ-0;}?vdqBB`xD0p*n z@YN$~hhXh9^}ZpQ_NY@o2{&T!gcm!xr_8fJ`Z0;Xe64~>e{3gXY1|L&2+aFw@hQMe zECRS90bIA#lBUQW{i(=*XFt5CF)uE*t_sXK)_|X7>j29pVT@`f*-r)2dzbvqmGO+O zc)*sR8vu-HQnY4{$3_gv_LY4fP4dwBSgf_&ODYvd93nNrg?%M^J0;K@yW)j{2sm8! zv1L<(!|SWZ(&Jh?uZy0_gq_SF9yj;C#6+gbV^^JXK%O^$JusT6zNnlR3}#;NP2e~~ zfG~>p&pT_dCrv$&s4mZ`SZAi9V)7dsSmruH1=ADbNt=|%=C%eurFcaB)bmcEde;rT zjX#j&*tP6Q?ar;KS@>*9wZBnFUZi(b#;@QWyBlQ+!K^XFKKVZCwn{L7epgWd(oH`3 zrSHB7=enB?IB&pIb46Dn;yJ@O!pJ@IN9`@c8n3z}5;XiX(Wdu&TlDf}=MSdoay~PNO!UY8y zy;tRgyJl|0?ra&=#2AdX-40O=K;D$H45rBux+hd8vI(@Mv!aCVnd|O_B@WaqCN{!u zTI0YG*dy9R=rdkut!9>wP ztk96lw@WIeXt1YflM0xfZKGD`{OL3u93haQXEyQ%Qf8=62`=vZhO>Xn~0>B6*_T?c2|6JJDQ2`rF?yudD&!RP)5v(_#63%6+WZ!4 zOGWV;iG&a%%9gA#|480lomO(;<+`ERbByex%=zUeXvbZMIKOVZP4@-9w6C;|^}b_N zoP)DAA1B}6bQ605ucJq2%6zG*nM?Y_L6_;M;dZ}?nyg_2UoC0ziHzu+#2;ylS8vWW z(S9&FeWKjr)P1l!hssF)Kn^*Pw~64ee?5}Hcf5@zGg7BTZ&vj7K1$lfeSQ0O@Lx}o zicMeMzMa{*v2&&O{MC~7B#Pf|ZTrS>5+>*(QCo!9bzOKAMj#+-uE+pHJZ zt$NALYX|x-A}q^}6L;!C1FU8l?qwQ!E|k)N{$ZT68*EF`5V8UjG+lIn{o?i+1qgi+ zqTcM+Eaibum-0A34L=#r>IlBCPE)K8jsaWB-ST+NVAEDu!jI{X2%KgP)TT#H z&G&wQ+V+5tz7JdGdp-o?*{OxEnlXxpks!4dPofUwJfIG^0uzesFRJHqKXJ@&puae(@QAtdAO1?F=Y_pq&pN-Xw&nf+GBjD%JNN|k5fru$IQj&=&j^FPi7w0kQA-*< zS_vsTjdInXqh;>TbzJyMvG?;ec;C{`vVmOR_j+`6iYUsZL#o!o!*F1#H1MX&nFqga z?O(g1P7C%ta{ZH+(=~`q9J)1mTcMe}YzT3?^))3_&>ouI^WKA&W!LQP|NN-6J%GGR z1XrN9oq0ps(pBfMIaJ&3`S~x!BtvR3$q3x(D0T<w9du>%FyvIs+opw zzP^!*Qt6EPtm@9cj5YEaxXf)bp1fr-Q2l^(wo$RJCKY|re8o?(?nx+lFU#Te_Y(m} z((Qa-oO{$=b{&N-HbBxh$J>-g-L~9NQ6ZPS)Y$$Yt#BQyEUd%aY;yVm61#vF0*K;k zU~*!?0=p&uDBi984i>EUr8dAftV9&AlrwOcm0wJUf40sFhHP8iLA~~ZPT}M05 zSosh}ESoEV!w%oX>{tDdIn?WbC|u7HMy+|&OWT$ZB@q+OM=UmHWyfM72a0Z%wJ9A_ zyXP&xBWYV>-IjaE9hXI&Zfq?0YV_#BK~1aDlqB8n)}bfXS!=p%wX%BUc`$Vu#*z~6e*1G#n0zrIdp zfNyp4cmnU`DS7@QA^ycC#P$Ysjgjm#ed6+ZDE_YmegZ81r`nFQqjPrK3tf!eyaQ_# z&-GsEZIA6lO+7Aj2`0V{qhG#88hU<9>zW9zE@wG^{H5!Bcs=8uYuWRn=84ynZs`rj z&ie+o1)ed++-Z;f4F1e%I@IpHln_pTsSV+U-6r!vV$3fjU-MomTy69k!d-5@CtRzL zj>}n`$Opewa^q#Y1@5`u5NxIzf@S;Q#tas}+;&3-78+1GuwyMnl=8~uWBD8lI$_D9tlQ`ki^uo!n6eE{l z@{CXCT4L@?;)m>O2pk;>ociRs-J~$s?%gf0tsZmiQU}oIxv?Pm3j9-HVVj+9hw|i< zC-d+RY$7)({o&}1b)>-!zg-wB%ApW;@UL%>SrrR?L8z?>%BAIxI~7C})-24ExLS?0 ze=5n1(}&rDzD}U9hR4T+`4pGf@Nr=mZe~2(V_I2u!v6(oDrW~|cqCR?lttmlA%y#G zR&8PDC-*Rs@M)Kxy}hKjyLp2`qzTu=MH+s^BG&8EGQ5_;MEs7)`Ju)kCoj_8pK|2$ zk0JuON7!IR+2zB0MlS)3HoKyt_jv|&Kolpk7WY9fE|+zDkzTb_cQXPH`C6?0=;8G> z%M~f~>p_fh-B*lo-9FoJvh=rj8_zklF*P0bMf+*-0slynA%pv{!ZL_)hb6knf+Y?5 z-e<8p7kY#&eTJ{^+$zFR9IkK?VST7D!eIB-Yw|LWl3c1?DqXO>KjgV|afUzs)W|i= zxf4H~xuHMgu}_W)BNozxcw)(8)Hw&H<5#sMTIL$^`!XqT)txe^M{mb(#5(E9lmqLh ziw&zPS+Poek@^%ZnN?wG1KXiVu3cVE+9c9#rl7@K-n(>wWbi$Pstn%ErYF>IW81KD zZcpA3A{R@A%^g_gzXO;But5b4)o-i_hP>TNo_20%QC1l`oVlNCQ2?ueZ4ZSNM$7e@ zOD-bfS3>!(KT3G;w?0#;N6l}-AhfmnchTPeta34`uj13+LMLE}3#TS%;CrVeS?`dF zjM^$PSjvSN;>RK_w^^!BnTC`cBy4~xqmQ&Ak=x4^#i54iDVki2gAnQ1x0;w0NnG*t zJ9+NI@EaCGQ~tV$uR5_^3HGQTcc4#nP)`#SM|q)(L4&m7Td_EzM5`SH2Qy<0CsuTi zRUtRfD9ig-gs*?tQi->x`mWhxVoH;wWnSQG9Je{olM?1UYw)}!kCgsCG=8_KscBe} zG&(1wfmwrjV#<0qBFfWR{g)(b=MxdqMCuyOcjkFu2Gs`X+GWCenxLh_-k4u+h!$h5 z>x!=RxOfRu)LG)(_q|Y*V8*%V!YFh0qPZqbKb5|OwwXE`^hL(@VT-pv%5rA^X5f7P z0H+cPtyz(po}Qu=2YgBthV9mb>te_@_&0|@$2%(S#`l_d#Y%iUH%ESZh*K1cpwW4< z2ELwbQeVqy%t~L0_Z=p?<2S|J>e;8b>+frRzBG)GmNq&y`$;S@%S1!3_}0|uv(aQu z&jKf}VYh24ftxdM?#WSA%#)*8p~P#zV9sKP1?w2A6YY7-9S<}8y|TlD0z&U#tw{%4 zRAntg20R|;Y3ZP2ShBm+PqH389JIO1kIzWDqlMt}{uTukSx~zZ!QWeez5HkSG=nvO(6;>G_Gec0m2m%aHy?_CV z{;jgg00G9oY^SHe&;?Ao+yEOB5tQ?wp>xoRzv}{KuLeY!y)hFyD#8s)9P<}2K>9LW zlVA{JJ*?;!VM#}}QW6hf}MIZZ+|irC(z5hVuoD>epMv4lie)-Db78%!p+ z2_v#(NE1I)Fx86?54-f_K+#aV73X*iBA_?>m--7zS@b8PGt4NHWKs-B7^y7W35McM z!3@xDBhH^u)oD$BSTU*TNv~@sZ83gIZ@outtpYI1J%|*nPZ_dB9E#82(L!7|GbB2< zhpy})o1Hdfn;(QotjItbq--~QuXp4mom#QmZjOuhVAkaA{w?w_11`l-<;eI|(~}HQ zAVFM>llsT*YRe!kzWVX#)XKhd<5@O@I~a$MIs3`=EbX7vb9=w-p;4a9jP!@8;zSSX zzX%JT3)X%UP7EbFi6a89%7DeIYULmzzB~(D43V+kH59YvkiLx6M)?ZE-x)RI8O29j zuO>I^ln(ox-WkX^EqKJZQYjWJvF3_ib4_CA>7z_pfs+=E<{*Vr zS`PWre==_-l7dRFm_9XEHhoIivUA!jNL#-DK1TdBTUJMScR2gAH(McZ*B(s+wZTM- zbBBHI)(=TggO>su0l0*FVMAEv9nY*AviDRQKM^wH02-qgoj=W92@{4|^0vtwQ&Cf! z5f|d4UVg+84yq;bi%L2rDelsLPYsR!41cHveW^UP`L$>$>@e-PDK42B5e+i4>6hax zGO0(96>`O6s(N0%z8&ZF&cAm8!`SJqJma*ZD5wWrVKiSKq@`}eGPu$bU6Uh*C_PP2 zgG1MR?6v^$tUzuJyZ_2Q-}S+)rLF$maes+1k3XbJjJ_9w8nR`eHD@05u53_es>1RN zHm~jXHxRLVi24(B`Wz$7P!q>PT+S^#93`}*1>DV9|>H)EG(X9P-J-0%%bgHa4Y5=mV}yf z1d5q=U?;O=4MD#DlsWs_9g1H4{V=5XavGQ8tiygltuYh&IG3|-Klc#oq_e<_%QuA) zISUGo)$lGkacWG%1;&6n^JF9u?phA0t6TxeuXLPi+O>^x~3Hk8cm93ZAmLD zQM8%UEp4lRuXT)_JBj$On-v*j-C1)D-)BtJg@vJU-xjvqLtzmB!k;n0Of$1}%!)?;(m zGAW~G`bzkYn`rb`0Z(VZlC@jO>RJ|g%hXGy8S^<(__w* zs3Z=(bNh!m+&9iIB>IX)B^^Wq1nlyj+>WEb;yK5>c*PHS$r>dHMHB`)rpsg#zhC& z5oU1{m04+<23dn?q1N`lsJ+W)2~Ccz$vdTZZOQYhiZT2GHj5w$xY}Jb{9q`bOb%qg z%Y&H<+eIxeVvCNrI;i9ukE3?Q*QOhUz}wP`DcSME73w#!xnoU9#M5uVrsS<)6Y^S^ z_vrwu7(BTxNRTWPxFBm$5JpkJ-F0SmAFFfM?XibGe-RI{8MAA(K>90IG_A!wqpH@M ziZXx++l?H=t=pSL!NJ(rJ8p?15BUaIjb*Ue+e!ZK4fHH|yPSE<+ zME7pSeaAP&i!Ws_L5J+#HE&jJ(kJ&4%88WW*Rs4(NgF+2mY(YZRR)ALF6$rCD9i6OvHd;t|XES#71e;o?P!S6iDd z?}N0IA0$`3L2PwG7Nj(p*Yx<$=#|B!Ilqxm=3$`YNiVfOCq`x97SiTbtyu}(d+ zNeJ)-LL4(GRvO678#zM5zKFaHeWQw}D{g+`GR<)y|DHUWVk(#NZ`DiK2uEwVPHcYy^C&ZITEYl~3^P%&)^P203CDN;g!}3rBC}`k{MVnl! z?hFN`HJ$wo7lmYbs-l$bG|i`SbR$>m$+xyGn-jz>bh(y?|2~IF8sc4Rd=YJuX}<6_ zjWAaGki$|SbF#Mh@mkvAMHUx%%SGp;EgA3KJrM#c%R_>~@3m0s`uZWgn7W~G!7EA5 zLUUfJd(6F&upIRQ8&sXN9rD1Tdyc+|X10&jm#4|X9#*u3Q>5e;0>eqeC&EcrM_nGk zjo5=5v6Ml!@I)m6ezh7Q;k+0Si#wYQ>>9yxYV=(DPpuWJ0F3&Y_;Ky=At`Cs=+}wO z#8RZFYC0S`)!8&cyWUwILZ!A4i&nTxa{qO8gU~rnZ=V{XE`D`C>oS&n)O-gL6xAGA z=8@@EpjP7EksWH`oPoS4N3uS>7&rOLTpam@-sUm4Ze{vjjW8xRr1Pd+Qb@8jTWLm* z{qE--`j+aYiet@39CBhLxhiFR!DI&x+(&!5e^t zHAM4|v^+fqbJw5L0T!vm4}ETbykiA2Ar(?7-UF(Qz#J8nW@jS95U%LbYk)>S;0aG2 zQ`E9vbh;SH!}z7|?w+?uc|7VIk4egB{PG*sZlgN7J73WapbAl47%j3riF$~(1NgYA_#1`O6g(K^5j6wL+2 z>s~}9UbuHBc8xgsvJ-PV_z|%BZUou`avi}|7Hx7q18y|0l2?zNb4!OSqh(TAMHv`y z8D>s>O@Ef^eNqORx;(@wo9f#;;N4a20#eDqD`%75C(qBF6o+MH4?PM;{M2E`C)dmH zsuSA3wHdAYeS*;oj7S9s0HlKz2&-xFw{!1Z7|?p2rZ6PvK(3W!w&Q=^B~U1%o4n-u zeHhKR6mco3BTpVsHg&re-35NML9os}j9G2#_tfdIj7|<$s z87>O~zz}AvQXBGDTXv&-em1~N&$z5w(*D;Gh!4{IT2+&L zSJk990yW7QSa2(t3R`Y!k_+~>vz}W=KR@E%m?!_M`y^0MmT0@a7BB0BI=);ny%^6Q zFTyP{#&xXfvQ1FWRANE;0K4>h=rzFryVma|mr1_h#QgtHJz`_N@yQJGhrNrEYZCH| zkG2w0Q?oa6%hfT9KlV1XRCemH-7@LB30m@RU7L51;jsV?J9E;(VKI6$;WF3d0a;W-RK7FMaPsILQ}e*J-c?E5P4{ z|4@j9kU0Nr*2bLwtsXtUJ8Aw$J;KX}CXXam>?>o*bh9R)CUfB2ftT{CdX(j{_)}kB zV_dFh(%^_Q>j9%cIW5m_nmFDr!``E2OJn@Xic#;I&a?Hi z=w{~E44MwH$M?C2?B?uR)ieR9A>YQ7&G(g=J++VrEu+Jv37 zWf)9<+M z+B9EN*o{xzPmVd;8{+DI-hXl#d0rJ*>adf5dq{6iR45{+F}SV!^iBVQcKPDsq4xGm zLB!7PA0vld+|L)Djy`*SaIH)6ref2;F^x+8N;Uz{V-B*o!F0|2!C^CF3-t*8{z5dP zRR89wilxItA7T&oRQ&GVV2l*kyNTWq@Tw`Nuc#c|1JNSRIDVf9x%0VXlL@nc$u5l` z1*Tb3W6z%7&@&X-S~3#ZDrHj%>AZC%cMLNo6ep>lO1rUneWXc6pV=>O z`X=Qvw+Z8Vq<@}au9ZB3jS%9uC;JPsPGm_Et$4R}&di#P*!_&rIn?=Agv|BWqpeWK zhPKSzlPF%1%AQ!R5`^yXFZ(Sh)ckY9?yFjK!Mig@$=ff;Vt^+%QP_#Iz( zi@g`&oH<2BquE)j?-PrVa|4S7j4dWrU_wUbRX6u=AZE;aVr<_- zA%2_wBqbBVDP8FtTkOAZ;O@KlvbIos(9Xwa=tD=ua>8oTzkb%LADBt?w!XPSJ2h0! zyWrfzJ>}lJ*bx4CWw{Hy>d?V^zxprO^>7&{Fb6k0e;!?ic)s9qM$4~H`D+Ak>eLQa zAMG$aCiAH^?7$NtvLWfxcQH=g>>-C8FRNbPw*7UIobY(9;>HI?1G!2rQFiXSB zA~=hY#ToMu(KW9~)}c_0L?sewv6qJ(qa_0;6+u*q-rq9U^HrHkE8!}Tx$^%dbIJXY zxikbamqW|{Epz#=%3RH>GS@lXa3H2=AW|skL zfcrpNiX}Vk`7~{5nA-4r6HaS**ZnKogClyM?m9dj;u5ok^jD|fY;A}(;=`n&;^Y>X zp{Eo#MCh1k;EhWDor1K-7t zsV7lcRv<_%-DUBc*CFJ--qBiQvNY;WB2E0xp%n=nK?Gn?2S0FPRmKjNxt@&pdzR;z7yCU1E_gYsv+>)~obucWX?^x%%&7$px@#QXf0nl; zX$t%5QZz46a8~5D(@5L}xfSm5Y$_GGUwkB^4Yu2EW%Tpr7!{82OguzqB2yGtJ@yUq ziGr5<;`lN6Wo9_tYb{ind#zAik6of9uqCUodhnK&&szG&m8>i-VYYV%IeWQJQJl)z z`HA7>hKjmJhI|WF=`9y8wTs4`ab39s(1GH$IJbNkIlw(32v4iL8`O*%=yJF9AMn_> zmh`CEbn>=UGys1hl&U7v`nGc>kd)`KSiP{K)I?wUzJQx=aqADbpo;My-FoxL$K|pCcd=ci0iFBvv z%lNaMLsxAOs+{g)W6pWaGp_=IN>-v~+h+clGsAQNL{zQB8jRb#+!R!%C(BLxh<@+g zCu3T1rodv1*<|F`w1lBcELf~(0D$|67B8p|PVXQYFUEdu&NZo2_y4aF| z&{ZmT5qKL9(6%UbieP#ZBUG2bOt|t^fg2Nj%Yr%Opoh>@%IRuzM48?pbn2mgB`AKw z9L&3-%CT(#HfE8Qml?)is-VSF*Dnb?dTUJ`McL!SGrQ{GWvhn0mHdxFh<#&Uci=9q zC8KeDdqQFRM(+;@^F?nmAoo8mrJ@I||LIXqQKr#*$=4kg{pvK(+r^>iIna*lu=wgS zkTi}imbWx+u2V#x*BqX9H)Ei-LhyH~VTpI>rML?Q>$@{AQ5IBTgD+lEl5bn~UAHs{ zjo--K5K8Y~2)}evh128Yx1JSg1WeEBWx3N`m=*q(UTPVpkzE`=r{#bdoD*AW6)&u(IxHJ7`&$ixpERbNT*OPo^ zUS5QbV{Mq{si0PKo`X7D2ArJV{z`a2QoOVX^_km|3EY1Ro!V_tgXQ1GxAkC3IIp`b>m&D%$IIya4NqJPlHv8**g&TP);?^n2GihH-s7OL(cO2! zv}jucR=C=;c9e7*=1F4mQr2aNz9j>+{@~tu8pvJ1x<7n`!Cgo%jhpUFc)=G@qWU#h zN02(kA~v=dsQ-bMe4O%KwwSOoA6Gc8;k?gjg+;mZjH5D=&0vSg6Ag`HUCqs%Cz6JUW4UGr1M-sN-T-tajyAHa2EUu&RlCF>*dqnPmDn~=PnmCqX z9>|zp&uiNBtSb2dZ<86zt~FZmB(rhpYGr&zCLD+JoRI~aGy&N2lOu%dzJg><9W$*_si9w50NWb1# zN_k5{lEwU0-6T)P^f_$S8IU6JhdshgzUv%PRep#M>U4W0%=gDCOh5kThCy%@?qoOo&$D{g29GSuE4-?T+0rUbC0=WAx9u1j_HA%)cyr>5 z03vcbd=QpO@y=O=h-Oyed_pac%#~Fw%hNIs!yckPe|vx?916#6FkT!k*ot|!W;gV| zA);tmt|861C^)yH8kVoXg*u1wkELT3ZSh*Fva7zuZ%aftvSZL7(+ZOLfFUP{*hUY8 zrp{l4zn)rZO5OH@6(Pxb^Bev6z<)-@Sz#WxtlKqqt?A;9OfpGd4=9|(OGT=B=yMs6 zF9r=Mrc;?qRy&wpV$`pX&X~`4tRa*E?{_MEu8g&;c#e6J9N_|DN6KXM2W?TPEk7b6 z35C|I`?J?=$nwQgZ^MwCL+!pcc&vGtfzRA!8V#?mIIfIAEz-3wHL3mmOu{Q8RFOvc z11E{WKN5G6E-EVUoI`Qh#nexs2HUu^+y22!nta#dDMe$GDdnx}NwH(VbWM*PgmZlElRiU}1FekqTPGhlWPevxN-9F#0B|pR9Nx zD29CMq&RdfP*CTWMHw!aDv>>;5OK0aZx#RfKfc`~Dva93o|1OKQ%u>e$_Nm#L}otnNu;X7ckXjgetPdqddU+m2&F zg-954$QHIIyzi2ZmNIs}&%kS%sp_0GTNt&_}s-#3o24#zw@s?nP-FL|+ z+_lkX4Rj;ctOrd44mL2YwYb2XODtR<>a;4*HzUWW*Y*`%{-}wpH)TPFMlaidD>lK{VSB0(>!*y-MnJ^_U<~{iDR1t&t z%ZvmKbV1}8owGLz$DN#^l494f{4?A35eV)xq)VJnD}vibR;1xWda$1<^;yu~k?+F% zJ0kJ@A^n|(N;aO|OyL(Ji%{J}NlO{K-Cd*#;TF9Jd8qsPcE zC9@6l{Nr}xZtH6uyMDUxP536i>)g+up~nfjtp%;bg5>r9zI!vUuDkIyUB8tuEz=b( zronz|6ZJne{pWwz^xyoc>CX%-_5G>oLkd(&rD-j(|Es3o|CElhrxpox31BUO@PY`3p5^&c<7(`bDT9*YQ$rOR^QcB+fOc-on>_k{*$S69F zpXKIW=TT{g8m+xLTWci33j*8go64Kr>dOdG-J-pwItnoQ1+xCj*fhL6Jh=a#a~geU zk&r<>nLz*o)>(B>QL3won zT1eAMc>5~DR~;kWcXOsX{#IN0#@Uif=*BY-f%SH7w`&H{r2L57T+c)H?Z%tE+Pzyp z2EgzKJ~Fy0wFEhT^8JJbnvXald5m)F5zjn)3Sex{+-6dQp(#q-9y@`+2Q)V8PQ6IGr5b7OI$u??vm%IGLM>X>`F1GAwTFfj7@OY*C^fwDedWO&DC z_bvt~sVc${;Aofc^+Jyl`k3V4IkTdxO4AC6%2g@_jCHcRp2Y=mzMl{PVXn4?5gK&0 zt3k-%AqZ&zZObAl&KE{N_a9=P1cC>~Z|{dPKP__G=W^uzAz7yLH16wbamR0&8N4?h zoE7cUwrJd13sLziQKQ5oUUs zq8#pk{(k%s$!V7(Jl>|Ds|V8jYW_JixD4;HK*xX~1v&~R44qzMtCMk31w zz5lIaq;dQZJz>PH)R8iw{oyl{_bhl>-R!QK?6P%+gpr}zjr%m=^<$Go!tYw?+Q5e+ z-Pn-5SJM_e?Lfqu9Qj5HB}>0jc0!7KJbom~i&iiF647=Ok-E%O4Z8ZPSOW-Of|xl` zhKVQfy$#;odn&6<(}S*Si@&zhR)cMC%bLonufB7K=*%oROuc9c(Qzt%?uVl*CuH;L z@_fHFYpwJJsjB7Hw=W-0e9tY!Q`X{sEqO3cCrC09zAR0=WZ>=yff48<4#UsTs4pK> zJQv|)vtA00ZuN$ue*!LA+~t17g;I4I^1^(CQwght(wBko8Y|9n5wH(n-V&z~lixv- zl1zP=g?9k!08m2f5ulNV!Ln$k_L&p6ucfhLMHm^+^A)`msTa2(xaxMK1nRU}r8l!! zFPZ-es36XN7#hMVLsReZH$wxHYBdI4r{F6+PWja;kP?bs*A)hIhXWt>2=T73#%=Vnvws-(m;N~sa5ofwU))b-R0N(lSvNa zD>CuFqi;RL?fcd)-2d6WD^mw0NeA7h*&x}GcXySgfx+-@bhCphFn`s zM<*N*d=d<|yGNPHw+=;Gf;<1vqW2zhJ?4A{WgA-O|HNkWadZJTGxUZWWbKwtzqZo% zLtP5itb_i(M1n7fxE~V+>GuBL)OrJ+#);{%3P9Twh$h^{lwc$$snNUz!Q_Ya1dN%l zr4T+tz@-QTQhO1MI;U)O8|c(L+I1`_u6}6zX~o-hcM2C7k%bm8mFP(m)W!3!kZS^F zPQ;OhgXYl4NZi*EdF=Uj&|5mr1V8D|b>-RD_@6gHQVmhSiTPeKv;38PE8O-qK*R)y zjBiMgVH)7y_AbVR!*HcI>asrnlQ3KWoyXNFa56 zcre*ivFU1wQ};$f-xc%3B#<3shzupS|5g+xcj+D>mK<_V zc>H@I|5JI$X9G(t!+4VKAK-(HJMPv8bKBHOFse1%1-{JHfu1j|*B;lpsW)cQm>pW`Zx7C%|KB3giZ@Dh=A*8Ig?@U9o!+xMu1(25ZjEPtuWU;B zTDePHTKT1`&p=4ovJFPjhxkp*T~6oPawf*#lYW9^a_fX5RjufEYDO5 z_KhdAZ~aJ2N>05l8~9DjNP^M0BEIK=H19IDKbwvzAYI{FJKp~pukMuuQ;SMO1#I2J znA?k*d=E|8^Xi*jF~&(wm`v$NcGbH%DK!`QvhM?7$;5D|Oc7jsJtX%G0jg2Bn1s3X zfa+F4iOj>?&=$dE>ACk=T<>@%#DQ*P7~2W6+GEA*$Pg7y`Ax>A{`sfnz~*=0YC;Bq z@v92#C~h1$YQ#R%^CO17flwBgqZZc&R^IvPr{xc?PmbT-#y-&*boIE}6W072iv{~< z^tXpAy-&*FtOVid@MnVXbpL0*pcmA6ZiHgJ_(mL9`LwLt*SwK?*IXFHp^I=bkJ_uq z=ecjNVA!=kK0=F^+EZsf+*&#w3h6y%AAGze z44)v3=UA|gv_j=*nc{fgP#9>L*u#FcO_1a)4T5<=um+7bI=@_7u9lFTX11#}e(Y(* zsXb4Q>}cPvFz-R4j5*pbom}ijS*15_W97bYAw^RSgfJQS}F`ZJXRp9 z%ZUITntDDHxL$+)txVvkSFgi39 zV;Ipg%86yQpKoxQWoEOSl%++N?RAdA{gu5a_lJ-Odsv}s2Et{RbJaLyfbVZpq~X5*oC$JZn4w%$JkL;5y~m8?jkW?QSEj7OPFI z2|d9B{@sD9-Iv7qF;uiQ)B5*Nu?dV?Wc_6^w@Ct9_1z+lF12@X<)VJes0m6XgSZYK zN7AdOai3E(Q=lYk=LXBbLU!aI|_(R%D*$ld%eFH}PVLLHPo-PrMd>&`SCr-SP@sP~K6N=n*)2@O6#Z@CJ6 zNpDI1Jn4$w;v3`l=$kz{wNSe@QnlOB*o?RAI_cUg!ht|>8C6^#QPNJ<=FO6<&E$2o z?X{iKE`2>e1|e4=GE)jdW@JevNt7ZcdzJ`AS!St(lqF<0W6M}FgTWYM z=Kc77fB*CT{_lG}$2pGmS?=e4?$3SS*L9)9xzM4%2S@@_aHxLZT50KpKG+T8O?6Tt zHHG`0XIOhudJg^7>hK9ofceYrw~hr`JoKqrZF8ube#U@~D^_nxS{w?$q zzak*IbZ-Fj!1!c*lijCee}|Tq0N2s;lA5!H=_WxNIujGvHsb$`5Ut2^#8YnIUAb`9 z@wmcxqAu_$!qgOum~nNWW-F*LZkPF-);oYL2N=8{(vV$P7OBW~^6&|m7}DYQR6X!d z-VJqq^seU-heko?BK5uMve$ZHJ=W(@*mg+$6{Xai9jL z^hOpJ-V9@Z_#!o>gctqeedh^&>{|l+@5=#_^+QynICEy{OK@OaOzVg8Yz24%#b>V| zH|MpaK~x?>I|J*^&H&@&mT@D6ap+Gr=PXjzS?A*oO2qc<)NP)Ok<0z5=g{j-QsGgH zn%2Iwxr;Mm{^};O?p&DpU~{I;g-d|F3J_#?rQOuPiBt84OVA;<7Y%$y5khv5?-p;T zbtqZiLoUGa3-8nY&YH}%23}>Xwk4mK^qQ;S5oeZ=sNwa$Kk{}{uS0{Q3|`Dcz7)=; zxz06%aCfJ!K6uad#ggDcJo2fMT(sR>Q5>fr~rtq$x0!y2oLLN>zjg`ooc!@S_k@vAn5vB?T2~`~z zxcf+Ra_I5>n|`>C+a6w;H)}kb^}=6#mu}fPQn8wEAFT*|(?Y_Cu1LJ^j&e>MGFu3H z4AoctBNG)o^q@_55}hnjz9q(#ZThoSP;2G)>FQabmM-yV{qKy*wX5;yH1L(w$9-S_ z*K*|-B69Nbw_$ z&e>l@T~ZUiNaZZX9R1BqyfUKLJQ--3Cq zx$6%val>?(g{OuB!w&K8LIk5%hcqlC>=&*Rx8U3ZvHUDyT;O2hhf^fz-))w7h83O2 z1{TCm=Nwv4PnIXiO`v)4n`1QO1lx|XrZDkpK z^J^Ww*$0rq7FSm36m`1s(x(g5iDS6bd~~}RbTjrNCrAap}}W4l)iWhaFVSYv+<3z=`9Fg$VP zGMF^;TDL+X4qfvB%E{d1cHDJ0oBX1eU%tMikP80r?&DA694kL^C5uNW_gP26sdOJZCyu9Dw4^9hzeO7Ma3+39C^sR*88=2V=zsx^d2%&-wL!{Z#cpf4o{SX(F1Mt`| zG>BC%LM=mtyArg52k$r7oL{{8$@Wa>AoFIyzUK*s2&bXO3ydb`!PNbG8{|x-a3WJR zpqrYNx*(ROY4-uy{64OkpAoa_n@0}LtcvXxtu4fc>Ssi5T0~jv93JN#78j{ zhuI<7YV_YL0$gvv!MWR>ADHQXm4>l1(G6TPS2ll}&~qQ2G#z=7Fdxck(63a}8!V2L zoPIeSeJ%82zyHhMN0|9lgn9oqcfdYI)SH%~-FWO6)oOg;wKNV87-`@kEXydipUf

(9BV# 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

hK-&Ks@@ zlo6!Dk$Z7qrGZ<*vxT?z;bJGJ~QI)1%rV1pAesQ8cp-(Z;{Zrk&zE^{ruxIN+# z!|L{Tvxo;wU{qf*sxuDvEc!G1vcELUSKBaKLn~-7?=mYOt}ch;c`OFAk?Nt8z;lrX z2N@8#_hYlfhm^5J@(?M;>G>lGa0}IMBy9h~dMpK2PV94&iJ>7MaD;h|)f3nrS<*xh z&(@Q!t0P~;iK}~3y4lJ92Or)@C%Iqs&OI7t2ST!mE1=n@rI`Wh%yR%AuIG#b@}KKj zR{Xwssx$t9a|)i*8E!*2#H)5CYfPcHMh z<;4MB79p*Y%QL6#`h4Ylv+ybuytn^e5_+ncrIq^YM)m0G*wfdcu-l@~0)`DpUBDe^ zCwE^83xPt@a{q@X&Xl`gN0ILL`W4I2fj^E_Mkn&e{RF5WR%jok)$7W12hSMK96gGS zQtV)iy|>xm)n=}MwZ{CjSPy~PmYc=&mJ#0~taeLylJu9NGQ(91{)RQ`iAa57UD>)ry01R~#4xv_D1igD0SC~(l3nYS)F1DRsBFdZdU0%? ziW1(aKgXKm{W+oiu;+T)&NJ(uAt+nJIrH^D3HGhs{#nt!`Pjpcc|jK7lcz)VaEV)2 zpF8Sw6=x=J_QumU=3A-~ zlCM%~bQtcf0~b7sJ*GfVuJpTO}pnQ@GHsx(p;sYxQh?*n@UBazI=rNNOm z#JMJMrTI6MmW58uSYgwb84KsWrYR(CJ#ij<8-#{2kIi-JRp$n-_~=r0+Eaux9>d>A z*&4i{2|>s8)9ZA#m;cM_{vU#R;p$)Xu3S#NO*P1r`@ z#qUKvR&P3q_VvY~w)q^1_?k!vW#ZTk*A@Kc*ku=e<88-&QeElDa76;d=@5}nIHM=14@J;7&*RFB3rU!0aIg9?39@na^-&=aCx2pK0 z4~`u-n#fNmnV)glMMndASt%>C?^qA|GBp(g^ zLEL4&cMz#U!jbvKh*1`gromDjB(K`LAPYVGQ^8-6){>q@d9Qt=A`<@COSl^*zs<

E?0Vxs3;>)=$6^1`OT`qsGY9?zfFd)d=DJzBf$ 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 b?UR^0=dtB#X{N`dB+n`9vnNYV_{9DP&hNr8 literal 0 HcmV?d00001 diff --git a/docs/getting_started/azure-container-instance.rst b/docs/getting_started/azure-container-instance.rst new file mode 100644 index 0000000000..8f979f514c --- /dev/null +++ b/docs/getting_started/azure-container-instance.rst @@ -0,0 +1,135 @@ +.. _azure-container-instance + +Azure Container Instance Execution Mode +======================================= +.. versionadded:: 1.4 +This tutorial will guide you through the steps required to use Azure Container Instance as the Execution Mode for your dbt code with Astronomer Cosmos. Schematically, the guide will walk you through the steps required to build the following architecture: + +.. figure:: https://github.com/astronomer/astronomer-cosmos/raw/main/docs/_static/cosmos_aci_schematic.png + :width: 800 + +Prerequisites ++++++++++++++ +1. Docker with docker daemon (Docker Desktop on MacOS). Follow the `Docker installation guide `_. +2. Airflow +3. Azure CLI (install guide here: `Azure CLI `_) +4. Astronomer-cosmos package containing the dbt Azure Container Instance operators +5. Azure account with: + 1. A resource group + 2. A service principal with `Contributor` permissions on the resource group + 3. A Container Registry + 4. A Postgres instance accessible from Azure. (we use an Azure Postgres instance in the example) +6. Docker image built with required dbt project and dbt DAG +7. dbt DAG with dbt Azure Container Instance operators in the Airflow DAGs directory to run in Airflow + +More information on how to achieve 2-6 is detailed below. + +Note that the steps below will walk you through an example, for which the code can be found HERE + +Step-by-step guide +++++++++++++++++++ + +**Install Airflow and Cosmos** + +Create a python virtualenv, activate it, upgrade pip to the latest version and install apache airflow & astronomer-postgres + +.. code-block:: bash + + python -m venv venv + source venv/bin/activate + pip install --upgrade pip + pip install apache-airflow + pip install "astronomer-cosmos[dbt-postgres,azure-container-instance]" + +**Setup Postgres database** + +You will need a postgres database running to be used as the database for the dbt project. In order to have it accessible from Azure Container Instance, the easiest way is to create an Azure Postgres instance. For this, run the following (assuming you are logged into your Azure account) + +.. code-block:: bash + + az postgres server create -l westeurope -g <<>> -n <<>> -u dbadmin -p <<>> --sku-name B_Gen5_1 --ssl-enforcement Enabled + + +**Setup Azure Container Registry** +In order to run a container in Azure Container Instance, it needs access to the container image. In our setup, we will use Azure Container Registry for this. To set an Azure Container Registry up, you can use the following bash command: + +.. code-block:: bash + az acr create --name <<>> --resource-group <<>> --sku Basic --admin-enabled + +**Build the dbt Docker image** + +For the Docker operators to work, you need to create a docker image that will be supplied as image parameter to the dbt docker operators used in the DAG. + +Clone the `cosmos-example `_ repo + +.. code-block:: bash + + git clone https://github.com/astronomer/cosmos-example.git + cd cosmos-example + +Create a docker image containing the dbt project files and dbt profile by using the `Dockerfile `_, which will be supplied to the Docker operators. + +.. code-block:: bash + + docker build -t <<>:1.0.0 -f Dockerfile.azure_container_instance . + +After this, the image needs to be pushed to the registry of your choice. Note that your image name should contain the name of your registry: +.. code-block:: bash + + docker push <<>>:1.0.0 + +.. note:: + + You may need to ensure docker knows to use the right credentials. If using Azure Container Registry, this can be done by running the following command: + .. code-block:: bash + az acr login + +.. note:: + + If running on M1, you may need to set the following envvars for running the docker build command in case it fails + + .. code-block:: bash + + export DOCKER_BUILDKIT=0 + export COMPOSE_DOCKER_CLI_BUILD=0 + export DOCKER_DEFAULT_PLATFORM=linux/amd64 + +Take a read of the Dockerfile to understand what it does so that you could use it as a reference in your project. + + - The `dbt profile `_ file is added to the image + - The dags directory containing the `dbt project jaffle_shop `_ is added to the image + - The dbt_project.yml is replaced with `postgres_profile_dbt_project.yml `_ which contains the profile key pointing to postgres_profile as profile creation is not handled at the moment for K8s operators like in local mode. + +**Setup Airflow Connections** +Now you have the required Azure infrastructure, you still need to add configuration to Airflow to ensure the infrastructure can be used. You'll need 3 connections: + +1. ``aci_db``: a Postgres connection to your Azure Postgres instance. +2. ``aci``: an Azure Container Instance connection configured with a Service Principal with sufficient permissions (i.e. ``Contributor`` on the resource group in which you will use Azure Container Instances). +3. ``acr``: an Azure Container Registry connection configured for your Azure Container Registry. + +Check out the ``airflow-settings.yml`` file `here `_ for an example. If you are using Astro CLI, filling in the right values here will be enough for this to work. + +**Setup and Trigger the DAG with Airflow** + +Copy the dags directory from cosmos-example repo to your Airflow home + +.. code-block:: bash + + cp -r dags $AIRFLOW_HOME/ + +Run Airflow + +.. code-block:: bash + + airflow standalone + +.. note:: + + You might need to run airflow standalone with ``sudo`` if your Airflow user is not able to access the docker socket URL or pull the images in the Kind cluster. + +Log in to Airflow through a web browser ``http://localhost:8080/``, using the user ``airflow`` and the password described in the ``standalone_admin_password.txt`` file. + +Enable and trigger a run of the `jaffle_shop_azure_container_instance `_ DAG. You will be able to see the following successful DAG run. + +.. figure:: https://github.com/astronomer/astronomer-cosmos/raw/main/docs/_static/jaffle_shop_azure_container_instance.png + :width: 800 diff --git a/docs/getting_started/execution-modes.rst b/docs/getting_started/execution-modes.rst index 7211382387..924e4ba129 100644 --- a/docs/getting_started/execution-modes.rst +++ b/docs/getting_started/execution-modes.rst @@ -3,18 +3,19 @@ Execution Modes =============== -Cosmos can run ``dbt`` commands using four different approaches, called ``execution modes``: +Cosmos can run ``dbt`` commands using five different approaches, called ``execution modes``: 1. **local**: Run ``dbt`` commands using a local ``dbt`` installation (default) 2. **virtualenv**: Run ``dbt`` commands from Python virtual environments managed by Cosmos 3. **docker**: Run ``dbt`` commands from Docker containers managed by Cosmos (requires a pre-existing Docker image) 4. **kubernetes**: Run ``dbt`` commands from Kubernetes Pods managed by Cosmos (requires a pre-existing Docker image) +5. **azure_container_instance**: Run ``dbt`` commands from Azure Container Instances managed by Cosmos (requires a pre-existing Docker image) The choice of the ``execution mode`` can vary based on each user's needs and concerns. For more details, check each execution mode described below. .. list-table:: Execution Modes Comparison - :widths: 25 25 25 25 + :widths: 20 20 20 20 20 :header-rows: 1 * - Execution Mode @@ -37,6 +38,10 @@ The choice of the ``execution mode`` can vary based on each user's needs and con - Slow - High - No + * - Azure Container Instance + - Slow + - High + - No Local ----- @@ -117,7 +122,7 @@ Example DAG: Kubernetes ---------- -Lastly, the ``kubernetes`` approach is the most isolated way of running ``dbt`` since the ``dbt`` run commands from within a Kubernetes Pod, usually in a separate host. +The ``kubernetes`` approach is a very isolated way of running ``dbt`` since the ``dbt`` run commands from within a Kubernetes Pod, usually in a separate host. It assumes the user has a Kubernetes cluster. It also expects the user to ensure the Docker container has up-to-date ``dbt`` pipelines and profiles, potentially leading the user to declare secrets in two places (Airflow and Docker container). @@ -148,3 +153,30 @@ Example DAG: "secrets": [postgres_password_secret], }, ) + +Azure Container Instance +------------------------ +.. versionadded:: 1.4 +Similar to the ``kubernetes`` approach, using ``Azure Container Instances`` as the execution mode gives a very isolated way of running ``dbt``, since the ``dbt`` run itself is run within a container running in an Azure Container Instance. + +This execution mode requires the user has an Azure environment that can be used to run Azure Container Groups in (see :ref:`azure-container-instance` for more details on the exact requirements). Similarly to the ``Docker`` and ``Kubernetes`` execution modes, a Docker container should be available, containing the up-to-date ``dbt`` pipelines and profiles. + +Each task will create a new container on Azure, giving full isolation. This, however, comes at the cost of speed, as this separation of tasks introduces some overhead. Please checkout the step-by-step guide for using Azure Container Instance as the execution mode + + +.. code-block:: python + + docker_cosmos_dag = DbtDag( + # ... + execution_config=ExecutionConfig( + execution_mode=ExecutionMode.AZURE_CONTAINER_INSTANCE + ), + operator_args={ + "ci_conn_id": "aci", + "registry_conn_id": "acr", + "resource_group": "my-rg", + "name": "my-aci-{{ ti.task_id.replace('.','-').replace('_','-') }}", + "region": "West Europe", + "image": "dbt-jaffle-shop:1.0.0", + }, + ) diff --git a/pyproject.toml b/pyproject.toml index e2ade9b7c6..7536e703ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -121,6 +121,9 @@ kubernetes = [ pydantic = [ "pydantic>=1.10.0", ] +azure-container-instance = [ + "apache-airflow-providers-microsoft-azure>=8.4.0", +] [project.entry-points.cosmos] provider_info = "cosmos:get_provider_info" @@ -148,27 +151,26 @@ packages = ["cosmos"] [tool.hatch.envs.tests] dependencies = [ "astronomer-cosmos[tests]", + "apache-airflow-providers-cncf-kubernetes>=5.1.1", + "apache-airflow-providers-docker>=3.5.0", + "apache-airflow-providers-microsoft-azure", "types-PyYAML", "types-attrs", "types-requests", "types-python-dateutil", "Werkzeug<3.0.0", - "apache-airflow-providers-cncf-kubernetes>=5.1.1", - "apache-airflow-providers-docker>=3.5.0", -] -# Airflow install with constraint file, Airflow versions < 2.7 require a workaround for PyYAML -pre-install-commands = [""" - if [[ "2.3 2.4 2.5 2.6" =~ "{matrix:airflow}" ]]; then - echo "Cython < 3" >> /tmp/constraint.txt - pip wheel "PyYAML==6.0.0" -c /tmp/constraint.txt - fi - pip install 'apache-airflow=={matrix:airflow}' --constraint 'https://raw.githubusercontent.com/apache/airflow/constraints-{matrix:airflow}.0/constraints-{matrix:python}.txt' - """ + "apache-airflow=={matrix:airflow}.0", ] +pre-install-commands = ["sh scripts/test/pre-install-airflow.sh {matrix:airflow} {matrix:python}"] + [[tool.hatch.envs.tests.matrix]] python = ["3.8", "3.9", "3.10", "3.11"] airflow = ["2.3", "2.4", "2.5", "2.6", "2.7", "2.8"] +[tool.hatch.envs.tests.overrides] +matrix.airflow.dependencies = [ + { value = "typing_extensions<4.6", if = ["2.6"] } +] [tool.hatch.envs.tests.scripts] freeze = "pip freeze" diff --git a/scripts/test/pre-install-airflow.sh b/scripts/test/pre-install-airflow.sh new file mode 100644 index 0000000000..de29703df2 --- /dev/null +++ b/scripts/test/pre-install-airflow.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +AIRFLOW_VERSION="$1" +PYTHON_VERSION="$2" + +CONSTRAINT_URL="https://raw.githubusercontent.com/apache/airflow/constraints-$AIRFLOW_VERSION.0/constraints-$PYTHON_VERSION.txt" +curl -sSL $CONSTRAINT_URL -o /tmp/constraint.txt +# Workaround to remove PyYAML constraint that will work on both Linux and MacOS +sed '/PyYAML==/d' /tmp/constraint.txt > /tmp/constraint.txt.tmp +mv /tmp/constraint.txt.tmp /tmp/constraint.txt +# Install Airflow with constraints +pip install apache-airflow==$AIRFLOW_VERSION --constraint /tmp/constraint.txt +pip install pydantic --constraint /tmp/constraint.txt +rm /tmp/constraint.txt diff --git a/tests/airflow/test_graph.py b/tests/airflow/test_graph.py index 255f8afc34..16a2c7e077 100644 --- a/tests/airflow/test_graph.py +++ b/tests/airflow/test_graph.py @@ -15,6 +15,7 @@ create_task_metadata, create_test_task_metadata, generate_task_or_group, + _snake_case_to_camelcase, ) from cosmos.config import ProfileConfig, RenderConfig from cosmos.constants import ( @@ -423,3 +424,10 @@ def test_create_test_task_metadata(node_type, node_unique_id, test_indirect_sele }, **additional_arguments, } + + +@pytest.mark.parametrize( + "input,expected", [("snake_case", "SnakeCase"), ("snake_case_with_underscores", "SnakeCaseWithUnderscores")] +) +def test_snake_case_to_camelcase(input, expected): + assert _snake_case_to_camelcase(input) == expected diff --git a/tests/operators/test_azure_container_instance.py b/tests/operators/test_azure_container_instance.py new file mode 100644 index 0000000000..da7720958a --- /dev/null +++ b/tests/operators/test_azure_container_instance.py @@ -0,0 +1,145 @@ +from pathlib import Path +from unittest.mock import MagicMock, patch + +from airflow.utils.context import Context +from pendulum import datetime + +from cosmos.operators.azure_container_instance import ( + DbtAzureContainerInstanceBaseOperator, + DbtLSAzureContainerInstanceOperator, + DbtRunAzureContainerInstanceOperator, + DbtTestAzureContainerInstanceOperator, + DbtSeedAzureContainerInstanceOperator, +) + + +class ConcreteDbtAzureContainerInstanceOperator(DbtAzureContainerInstanceBaseOperator): + base_cmd = ["cmd"] + + +def test_dbt_azure_container_instance_operator_add_global_flags() -> None: + dbt_base_operator = ConcreteDbtAzureContainerInstanceOperator( + ci_conn_id="my_airflow_connection", + task_id="my-task", + image="my_image", + region="Mordor", + name="my-aci", + resource_group="my-rg", + project_dir="my/dir", + vars={ + "start_time": "{{ data_interval_start.strftime('%Y%m%d%H%M%S') }}", + "end_time": "{{ data_interval_end.strftime('%Y%m%d%H%M%S') }}", + }, + no_version_check=True, + ) + assert dbt_base_operator.add_global_flags() == [ + "--vars", + "end_time: '{{ data_interval_end.strftime(''%Y%m%d%H%M%S'') }}'\n" + "start_time: '{{ data_interval_start.strftime(''%Y%m%d%H%M%S'') }}'\n", + "--no-version-check", + ] + + +@patch("cosmos.operators.base.context_to_airflow_vars") +def test_dbt_azure_container_instance_operator_get_env(p_context_to_airflow_vars: MagicMock) -> None: + """ + If an end user passes in a variable via the context that is also a global flag, validate that the both are kept + """ + dbt_base_operator = ConcreteDbtAzureContainerInstanceOperator( + ci_conn_id="my_airflow_connection", + task_id="my-task", + image="my_image", + region="Mordor", + name="my-aci", + resource_group="my-rg", + project_dir="my/dir", + ) + dbt_base_operator.env = { + "start_date": "20220101", + "end_date": "20220102", + "some_path": Path(__file__), + "retries": 3, + ("tuple", "key"): "some_value", + } + p_context_to_airflow_vars.return_value = {"START_DATE": "2023-02-15 12:30:00"} + env = dbt_base_operator.get_env( + Context(execution_date=datetime(2023, 2, 15, 12, 30)), + ) + expected_env = { + "start_date": "20220101", + "end_date": "20220102", + "some_path": Path(__file__), + "START_DATE": "2023-02-15 12:30:00", + } + assert env == expected_env + + +@patch("cosmos.operators.base.context_to_airflow_vars") +def test_dbt_azure_container_instance_operator_check_environment_variables( + p_context_to_airflow_vars: MagicMock, +) -> None: + """ + If an end user passes in a variable via the context that is also a global flag, validate that the both are kept + """ + dbt_base_operator = ConcreteDbtAzureContainerInstanceOperator( + ci_conn_id="my_airflow_connection", + task_id="my-task", + image="my_image", + region="Mordor", + name="my-aci", + resource_group="my-rg", + project_dir="my/dir", + environment_variables={"FOO": "BAR"}, + ) + dbt_base_operator.env = { + "start_date": "20220101", + "end_date": "20220102", + "some_path": Path(__file__), + "retries": 3, + "FOO": "foo", + ("tuple", "key"): "some_value", + } + expected_env = {"start_date": "20220101", "end_date": "20220102", "some_path": Path(__file__), "FOO": "BAR"} + dbt_base_operator.build_command(context=MagicMock()) + + assert dbt_base_operator.environment_variables == expected_env + + +base_kwargs = { + "ci_conn_id": "my_airflow_connection", + "name": "my-aci", + "region": "Mordor", + "resource_group": "my-rg", + "task_id": "my-task", + "image": "my_image", + "project_dir": "my/dir", + "vars": { + "start_time": "{{ data_interval_start.strftime('%Y%m%d%H%M%S') }}", + "end_time": "{{ data_interval_end.strftime('%Y%m%d%H%M%S') }}", + }, + "no_version_check": True, +} + +result_map = { + "ls": DbtLSAzureContainerInstanceOperator(**base_kwargs), + "run": DbtRunAzureContainerInstanceOperator(**base_kwargs), + "test": DbtTestAzureContainerInstanceOperator(**base_kwargs), + "seed": DbtSeedAzureContainerInstanceOperator(**base_kwargs), +} + + +def test_dbt_azure_container_instance_build_command(): + """ + Since we know that the AzureContainerInstanceOperator is tested, we can just test that the + command is built correctly. + """ + for command_name, command_operator in result_map.items(): + command_operator.build_command(context=MagicMock(), cmd_flags=MagicMock()) + assert command_operator.command == [ + "dbt", + command_name, + "--vars", + "end_time: '{{ data_interval_end.strftime(''%Y%m%d%H%M%S'') }}'\n" + "start_time: '{{ data_interval_start.strftime(''%Y%m%d%H%M%S'') }}'\n", + "--no-version-check", + ] From d95b8789fc02f0cd4027a425a472d17c6f56696e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 5 Feb 2024 10:56:10 -0800 Subject: [PATCH 094/223] =?UTF-8?q?=E2=AC=86=20[pre-commit.ci]=20pre-commi?= =?UTF-8?q?t=20autoupdate=20(#834)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.1.14 → v0.2.0](https://github.com/astral-sh/ruff-pre-commit/compare/v0.1.14...v0.2.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2017424d79..fc8a97fff9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -54,7 +54,7 @@ repos: - --py37-plus - --keep-runtime-typing - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.14 + rev: v0.2.0 hooks: - id: ruff args: From 0f59402e1b6de9b88da462918edb6ceb6b91c235 Mon Sep 17 00:00:00 2001 From: Justin Bandoro <79104794+jbandoro@users.noreply.github.com> Date: Tue, 13 Feb 2024 16:29:26 -0800 Subject: [PATCH 095/223] Fix broken integration test uncovered from pytest v8.0 update (#845) ## Description The issue is documented in https://github.com/astronomer/astronomer-cosmos/issues/844 where an integration test is failing because of an update to test collection/ordering introduced in pytest v8.0. ## Related Issue(s) closes #844 --- tests/operators/test_local.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/operators/test_local.py b/tests/operators/test_local.py index babb425ef0..e57f3287bd 100644 --- a/tests/operators/test_local.py +++ b/tests/operators/test_local.py @@ -210,6 +210,14 @@ def test_run_operator_dataset_inlets_and_outlets(): from airflow.datasets import Dataset with DAG("test-id-1", start_date=datetime(2022, 1, 1)) as dag: + seed_operator = DbtSeedLocalOperator( + profile_config=real_profile_config, + project_dir=DBT_PROJ_DIR, + task_id="seed", + dbt_cmd_flags=["--select", "raw_customers"], + install_deps=True, + append_env=True, + ) run_operator = DbtRunLocalOperator( profile_config=real_profile_config, project_dir=DBT_PROJ_DIR, @@ -226,7 +234,7 @@ def test_run_operator_dataset_inlets_and_outlets(): install_deps=True, append_env=True, ) - run_operator + seed_operator >> run_operator >> test_operator run_test_dag(dag) assert run_operator.inlets == [] assert run_operator.outlets == [Dataset(uri="postgres://0.0.0.0:5432/postgres.public.stg_customers", extra=None)] From 413bf5255a0c45233fc7e48a711d60ebaab5da9c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 13 Feb 2024 16:58:17 -0800 Subject: [PATCH 096/223] =?UTF-8?q?=E2=AC=86=20[pre-commit.ci]=20pre-commi?= =?UTF-8?q?t=20autoupdate=20(#843)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.2.0 → v0.2.1](https://github.com/astral-sh/ruff-pre-commit/compare/v0.2.0...v0.2.1) - [github.com/psf/black: 24.1.1 → 24.2.0](https://github.com/psf/black/compare/24.1.1...24.2.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Justin Bandoro <79104794+jbandoro@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fc8a97fff9..997575d466 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -54,13 +54,13 @@ repos: - --py37-plus - --keep-runtime-typing - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.2.0 + rev: v0.2.1 hooks: - id: ruff args: - --fix - repo: https://github.com/psf/black - rev: 24.1.1 + rev: 24.2.0 hooks: - id: black args: ["--config", "./pyproject.toml"] From d1cb79b1bcb93705e5b544702a2a0debcc061983 Mon Sep 17 00:00:00 2001 From: Julian LaNeve Date: Wed, 14 Feb 2024 12:42:20 -0500 Subject: [PATCH 097/223] Fix typo in MWAA getting started guide (#846) ## Description This fixes a typo in the MWAA getting started guide. We instruct the user to create a virtualenv using MWAA's startup script, but then the execution config uses the virtual env mode which will re-create the venv every task. Instead, the user should let Cosmos use the default (local) execution mode. ## Related Issue(s) ## Breaking Change? ## Checklist - [ ] I have made corresponding changes to the documentation (if required) - [ ] I have added tests that prove my fix is effective or that my feature works --- docs/getting_started/mwaa.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/getting_started/mwaa.rst b/docs/getting_started/mwaa.rst index 7c42b344dd..0def60f496 100644 --- a/docs/getting_started/mwaa.rst +++ b/docs/getting_started/mwaa.rst @@ -104,7 +104,6 @@ In your ``my_cosmos_dag.py`` file, import the ``DbtDag`` class from Cosmos and c execution_config = ExecutionConfig( dbt_executable_path=f"{os.environ['AIRFLOW_HOME']}/dbt_venv/bin/dbt", - execution_mode=ExecutionMode.VIRTUALENV, ) my_cosmos_dag = DbtDag( From 3c98fffe91a37ba28839539a5aa8c30c4abaefbc Mon Sep 17 00:00:00 2001 From: Justin Bandoro <79104794+jbandoro@users.noreply.github.com> Date: Thu, 15 Feb 2024 04:08:07 -0800 Subject: [PATCH 098/223] Fix docs:`ExecutionConfig.dbt_project_path` (#847) ## Description I think this was originally copied over from RenderConfig, and clears up confusion for users using docker/k8s execution mode where they should specify the project path. --- docs/configuration/execution-config.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration/execution-config.rst b/docs/configuration/execution-config.rst index c118590d85..23b511e372 100644 --- a/docs/configuration/execution-config.rst +++ b/docs/configuration/execution-config.rst @@ -9,4 +9,4 @@ The ``ExecutionConfig`` class takes the following arguments: - ``execution_mode``: The way dbt is run when executing within airflow. For more information, see the `execution modes <../getting_started/execution-modes.html>`_ page. - ``test_indirect_selection``: The mode to configure the test behavior when performing indirect selection. - ``dbt_executable_path``: The path to the dbt executable for dag generation. Defaults to dbt if available on the path. -- ``dbt_project_path``: Configures the DBT project location accessible on their airflow controller for DAG rendering - Required when using ``load_method=LoadMode.DBT_LS`` or ``load_method=LoadMode.CUSTOM`` +- ``dbt_project_path``: Configures the dbt project location accessible at runtime for dag execution. This is the project path in a docker container for ``ExecutionMode.DOCKER`` or ``ExecutionMode.KUBERNETES``. Mutually exclusive with ``ProjectConfig.dbt_project_path``. From 9fe2f1d9f20791ed61819f44d27e4bb971fac6b3 Mon Sep 17 00:00:00 2001 From: Julian LaNeve Date: Thu, 15 Feb 2024 09:06:19 -0500 Subject: [PATCH 099/223] Add performance integration tests (#827) ## Description This PR adds a step to our CI to measure how quickly Cosmos can run models. This is part of a larger initiative to make the project more performant now that it's reached a certain level of maturity. How it works: - We now have [a test that generates a dbt project with a certain number of sequential models](https://github.com/astronomer/astronomer-cosmos/blob/performance-int-tests/tests/perf/test_performance.py) (based on a parameter that gets passed in), runs a simple DAG, and measures task throughput (measured in terms of models run per second - I've extended our CI to run this test for 1, 10, 50, and 100 models to start - This CI reports out a GitHub Actions output that gets shown in the actions summary, [at the bottom](https://github.com/astronomer/astronomer-cosmos/actions/runs/7894490582) While this isn't perfect, it's a step in the right direction - we now have some general visibility! Note that these numbers may not be indicative of a production Airflow environment running something like the Kubernetes Executor, because this runs a local executor on GH Actions runners. Still, it's meant as a benchmark to demonstrate whether we're moving in the right direction or not. As part of this, I've also refactored our tests to call a script from the pyproject file instead of embedding the scripts directly in the file. This should make it easier to maintain and track changes. ## Related Issue(s) https://github.com/astronomer/astronomer-cosmos/pull/800 ## Breaking Change? ## Checklist - [ ] I have made corresponding changes to the documentation (if required) - [ ] I have added tests that prove my fix is effective or that my feature works --- .github/workflows/test.yml | 59 +++++++++-- dev/dags/dbt/perf/.gitignore | 4 + dev/dags/dbt/perf/README.md | 3 + dev/dags/dbt/perf/analyses/.gitkeep | 0 dev/dags/dbt/perf/dbt_project.yml | 17 +++ dev/dags/dbt/perf/macros/.gitkeep | 0 dev/dags/dbt/perf/profiles.yml | 11 ++ dev/dags/dbt/perf/seeds/.gitkeep | 0 dev/dags/dbt/perf/snapshots/.gitkeep | 0 dev/dags/dbt/perf/tests/.gitkeep | 0 dev/dags/performance_dag.py | 36 +++++++ pyproject.toml | 122 +++++----------------- scripts/test/integration-expensive.sh | 8 ++ scripts/test/integration-setup.sh | 6 ++ scripts/test/integration-sqlite-setup.sh | 4 + scripts/test/integration-sqlite.sh | 8 ++ scripts/test/integration.sh | 9 ++ scripts/test/performance-setup.sh | 4 + scripts/test/performance.sh | 5 + scripts/test/unit-cov.sh | 10 ++ scripts/test/unit.sh | 7 ++ tests/perf/test_performance.py | 122 ++++++++++++++++++++++ tests/test_example_dags.py | 10 +- tests/test_example_dags_no_connections.py | 10 +- 24 files changed, 351 insertions(+), 104 deletions(-) create mode 100644 dev/dags/dbt/perf/.gitignore create mode 100644 dev/dags/dbt/perf/README.md create mode 100644 dev/dags/dbt/perf/analyses/.gitkeep create mode 100644 dev/dags/dbt/perf/dbt_project.yml create mode 100644 dev/dags/dbt/perf/macros/.gitkeep create mode 100644 dev/dags/dbt/perf/profiles.yml create mode 100644 dev/dags/dbt/perf/seeds/.gitkeep create mode 100644 dev/dags/dbt/perf/snapshots/.gitkeep create mode 100644 dev/dags/dbt/perf/tests/.gitkeep create mode 100644 dev/dags/performance_dag.py create mode 100644 scripts/test/integration-expensive.sh create mode 100644 scripts/test/integration-setup.sh create mode 100644 scripts/test/integration-sqlite-setup.sh create mode 100644 scripts/test/integration-sqlite.sh create mode 100644 scripts/test/integration.sh create mode 100644 scripts/test/performance-setup.sh create mode 100644 scripts/test/performance.sh create mode 100644 scripts/test/unit-cov.sh create mode 100644 scripts/test/unit.sh create mode 100644 tests/perf/test_performance.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5adcc3ac5e..4b214ce119 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,10 +11,8 @@ concurrency: cancel-in-progress: true jobs: - Authorize: - environment: - ${{ github.event_name == 'pull_request_target' && + environment: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.full_name != github.repository && 'external' || 'internal' }} runs-on: ubuntu-latest @@ -30,8 +28,8 @@ jobs: - uses: actions/setup-python@v3 with: - python-version: '3.9' - architecture: 'x64' + python-version: "3.9" + architecture: "x64" - run: pip3 install hatch - run: hatch run tests.py3.9-2.7:type-check @@ -294,6 +292,55 @@ jobs: AIRFLOW_CONN_AIRFLOW_DB: postgres://postgres:postgres@0.0.0.0:5432/postgres PYTHONPATH: /home/runner/work/astronomer-cosmos/astronomer-cosmos/:$PYTHONPATH + Run-Performance-Tests: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11"] + airflow-version: ["2.7"] + num-models: [1, 10, 50, 100] + + steps: + - uses: actions/checkout@v3 + with: + ref: ${{ github.event.pull_request.head.sha || github.ref }} + - uses: actions/cache@v3 + with: + path: | + ~/.cache/pip + .nox + key: perf-test-${{ runner.os }}-${{ matrix.python-version }}-${{ matrix.airflow-version }}-${{ hashFiles('pyproject.toml') }}-${{ hashFiles('cosmos/__init__.py') }} + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install packages and dependencies + run: | + python -m pip install hatch + hatch -e tests.py${{ matrix.python-version }}-${{ matrix.airflow-version }} run pip freeze + + - name: Run performance tests against against Airflow ${{ matrix.airflow-version }} and Python ${{ matrix.python-version }} + id: run-performance-tests + run: | + hatch run tests.py${{ matrix.python-version }}-${{ matrix.airflow-version }}:test-performance-setup + hatch run tests.py${{ matrix.python-version }}-${{ matrix.airflow-version }}:test-performance + + # read the performance results and set them as an env var for the next step + # format: NUM_MODELS={num_models}\nTIME={end - start}\n + cat /tmp/performance_results.txt > $GITHUB_STEP_SUMMARY + env: + AIRFLOW_HOME: /home/runner/work/astronomer-cosmos/astronomer-cosmos/ + AIRFLOW_CONN_AIRFLOW_DB: postgres://postgres:postgres@0.0.0.0:5432/postgres + AIRFLOW__CORE__DAGBAG_IMPORT_TIMEOUT: 90.0 + PYTHONPATH: /home/runner/work/astronomer-cosmos/astronomer-cosmos/:$PYTHONPATH + MODEL_COUNT: ${{ matrix.num-models }} + + env: + AIRFLOW_HOME: /home/runner/work/astronomer-cosmos/astronomer-cosmos/ + AIRFLOW_CONN_AIRFLOW_DB: postgres://postgres:postgres@0.0.0.0:5432/postgres + PYTHONPATH: /home/runner/work/astronomer-cosmos/astronomer-cosmos/:$PYTHONPATH Code-Coverage: if: github.event.action != 'labeled' @@ -309,7 +356,7 @@ jobs: - name: Set up Python 3.11 uses: actions/setup-python@v3 with: - python-version: '3.11' + python-version: "3.11" - name: Install coverage run: | pip3 install coverage diff --git a/dev/dags/dbt/perf/.gitignore b/dev/dags/dbt/perf/.gitignore new file mode 100644 index 0000000000..49f147cb98 --- /dev/null +++ b/dev/dags/dbt/perf/.gitignore @@ -0,0 +1,4 @@ + +target/ +dbt_packages/ +logs/ diff --git a/dev/dags/dbt/perf/README.md b/dev/dags/dbt/perf/README.md new file mode 100644 index 0000000000..31e67f483b --- /dev/null +++ b/dev/dags/dbt/perf/README.md @@ -0,0 +1,3 @@ +dbt project for running performance tests. + +The `models` directory gets populated by an integration test defined in `tests/perf`. diff --git a/dev/dags/dbt/perf/analyses/.gitkeep b/dev/dags/dbt/perf/analyses/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/dev/dags/dbt/perf/dbt_project.yml b/dev/dags/dbt/perf/dbt_project.yml new file mode 100644 index 0000000000..ebb0275743 --- /dev/null +++ b/dev/dags/dbt/perf/dbt_project.yml @@ -0,0 +1,17 @@ +# Name your project! Project names should contain only lowercase characters +# and underscores. A good package name should reflect your organization's +# name or the intended use of these models +name: "perf" +version: "1.0.0" +config-version: 2 + +model-paths: ["models"] +analysis-paths: ["analyses"] +test-paths: ["tests"] +seed-paths: ["seeds"] +macro-paths: ["macros"] +snapshot-paths: ["snapshots"] + +clean-targets: # directories to be removed by `dbt clean` + - "target" + - "dbt_packages" diff --git a/dev/dags/dbt/perf/macros/.gitkeep b/dev/dags/dbt/perf/macros/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/dev/dags/dbt/perf/profiles.yml b/dev/dags/dbt/perf/profiles.yml new file mode 100644 index 0000000000..5b3cf175d5 --- /dev/null +++ b/dev/dags/dbt/perf/profiles.yml @@ -0,0 +1,11 @@ +simple: + target: dev + outputs: + dev: + type: sqlite + threads: 1 + database: "database" + schema: "main" + schemas_and_paths: + main: "{{ env_var('DBT_SQLITE_PATH') }}/imdb.db" + schema_directory: "{{ env_var('DBT_SQLITE_PATH') }}" diff --git a/dev/dags/dbt/perf/seeds/.gitkeep b/dev/dags/dbt/perf/seeds/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/dev/dags/dbt/perf/snapshots/.gitkeep b/dev/dags/dbt/perf/snapshots/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/dev/dags/dbt/perf/tests/.gitkeep b/dev/dags/dbt/perf/tests/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/dev/dags/performance_dag.py b/dev/dags/performance_dag.py new file mode 100644 index 0000000000..caf977817d --- /dev/null +++ b/dev/dags/performance_dag.py @@ -0,0 +1,36 @@ +""" +A DAG that uses Cosmos to render a dbt project for performance testing. +""" + +import airflow +from datetime import datetime +import os +from pathlib import Path + +from cosmos import DbtDag, ProjectConfig, ProfileConfig, RenderConfig + +DEFAULT_DBT_ROOT_PATH = Path(__file__).parent / "dbt" +DBT_ROOT_PATH = Path(os.getenv("DBT_ROOT_PATH", DEFAULT_DBT_ROOT_PATH)) +DBT_SQLITE_PATH = str(DEFAULT_DBT_ROOT_PATH / "data") + +profile_config = ProfileConfig( + profile_name="simple", + target_name="dev", + profiles_yml_filepath=(DBT_ROOT_PATH / "simple/profiles.yml"), +) + +cosmos_perf_dag = DbtDag( + project_config=ProjectConfig( + DBT_ROOT_PATH / "perf", + env_vars={"DBT_SQLITE_PATH": DBT_SQLITE_PATH}, + ), + profile_config=profile_config, + render_config=RenderConfig( + dbt_deps=False, + ), + # normal dag parameters + schedule_interval=None, + start_date=datetime(2024, 1, 1), + catchup=False, + dag_id="performance_dag", +) diff --git a/pyproject.toml b/pyproject.toml index 7536e703ac..522431da78 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,16 +9,8 @@ description = "Orchestrate your dbt projects in Airflow" readme = "README.rst" license = "Apache-2.0" requires-python = ">=3.8" -authors = [ - { name = "Astronomer", email = "humans@astronomer.io" }, -] -keywords = [ - "airflow", - "apache-airflow", - "astronomer", - "dags", - "dbt", -] +authors = [{ name = "Astronomer", email = "humans@astronomer.io" }] +keywords = ["airflow", "apache-airflow", "astronomer", "dags", "dbt"] classifiers = [ "Development Status :: 3 - Alpha", "Environment :: Web Environment", @@ -56,48 +48,23 @@ dbt-all = [ "dbt-spark", "dbt-vertica", ] -dbt-athena = [ - "dbt-athena-community", - "apache-airflow-providers-amazon>=8.0.0", -] -dbt-bigquery = [ - "dbt-bigquery", -] -dbt-databricks = [ - "dbt-databricks", -] -dbt-exasol = [ - "dbt-exasol", -] -dbt-postgres = [ - "dbt-postgres", -] -dbt-redshift = [ - "dbt-redshift", -] -dbt-snowflake = [ - "dbt-snowflake", -] -dbt-spark = [ - "dbt-spark", -] -dbt-vertica = [ - "dbt-vertica<=1.5.4", -] -openlineage = [ - "openlineage-integration-common", - "openlineage-airflow", -] -all = [ - "astronomer-cosmos[dbt-all]", - "astronomer-cosmos[openlineage]" -] -docs =[ +dbt-athena = ["dbt-athena-community", "apache-airflow-providers-amazon>=8.0.0"] +dbt-bigquery = ["dbt-bigquery"] +dbt-databricks = ["dbt-databricks"] +dbt-exasol = ["dbt-exasol"] +dbt-postgres = ["dbt-postgres"] +dbt-redshift = ["dbt-redshift"] +dbt-snowflake = ["dbt-snowflake"] +dbt-spark = ["dbt-spark"] +dbt-vertica = ["dbt-vertica<=1.5.4"] +openlineage = ["openlineage-integration-common", "openlineage-airflow"] +all = ["astronomer-cosmos[dbt-all]", "astronomer-cosmos[openlineage]"] +docs = [ "sphinx", "pydata-sphinx-theme", "sphinx-autobuild", "sphinx-autoapi", - "apache-airflow-providers-cncf-kubernetes>=5.1.1" + "apache-airflow-providers-cncf-kubernetes>=5.1.1", ] tests = [ "packaging", @@ -137,9 +104,7 @@ Documentation = "https://astronomer.github.io/astronomer-cosmos" path = "cosmos/__init__.py" [tool.hatch.build.targets.sdist] -include = [ - "/cosmos", -] +include = ["/cosmos"] [tool.hatch.build.targets.wheel] packages = ["cosmos"] @@ -175,51 +140,20 @@ matrix.airflow.dependencies = [ [tool.hatch.envs.tests.scripts] freeze = "pip freeze" type-check = "mypy cosmos" -test = 'pytest -vv --durations=0 . -m "not integration" --ignore=tests/test_example_dags.py --ignore=tests/test_example_dags_no_connections.py' -test-cov = """pytest -vv --cov=cosmos --cov-report=term-missing --cov-report=xml --durations=0 -m "not integration" --ignore=tests/test_example_dags.py --ignore=tests/test_example_dags_no_connections.py""" -# we install using the following workaround to overcome installation conflicts, such as: -# apache-airflow 2.3.0 and dbt-core [0.13.0 - 1.5.2] and jinja2>=3.0.0 because these package versions have conflicting dependencies -test-integration-setup = """pip uninstall -y dbt-postgres dbt-databricks dbt-vertica; \ -rm -rf airflow.*; \ -airflow db init; \ -pip install 'dbt-core' 'dbt-databricks' 'dbt-postgres' 'dbt-vertica' 'openlineage-airflow'""" -test-integration = """rm -rf dbt/jaffle_shop/dbt_packages; -pytest -vv \ ---cov=cosmos \ ---cov-report=term-missing \ ---cov-report=xml \ ---durations=0 \ --m integration \ --k 'not (sqlite or example_cosmos_sources or example_cosmos_python_models or example_virtualenv)'""" -test-integration-expensive = """pytest -vv \ ---cov=cosmos \ ---cov-report=term-missing \ ---cov-report=xml \ ---durations=0 \ --m integration \ --k 'example_cosmos_python_models or example_virtualenv'""" -test-integration-sqlite-setup = """pip uninstall -y dbt-core dbt-sqlite openlineage-airflow openlineage-integration-common; \ -rm -rf airflow.*; \ -airflow db init; \ -pip install 'dbt-core==1.4' 'dbt-sqlite<=1.4' 'dbt-databricks<=1.4' 'dbt-postgres<=1.4' """ -test-integration-sqlite = """ -pytest -vv \ ---cov=cosmos \ ---cov-report=term-missing \ ---cov-report=xml \ ---durations=0 \ --m integration \ --k 'example_cosmos_sources or sqlite'""" +test = 'sh scripts/test/unit.sh' +test-cov = 'sh scripts/test/unit-cov.sh' +test-integration-setup = 'sh scripts/test/integration-setup.sh' +test-integration = 'sh scripts/test/integration.sh' +test-integration-expensive = 'sh scripts/test/integration-expensive.sh' +test-integration-sqlite-setup = 'sh scripts/test/integration-sqlite-setup.sh' +test-integration-sqlite = 'sh scripts/test/integration-sqlite.sh' +test-performance-setup = 'sh scripts/test/performance-setup.sh' +test-performance = 'sh scripts/test/performance.sh' [tool.pytest.ini_options] -filterwarnings = [ - "ignore::DeprecationWarning", -] +filterwarnings = ["ignore::DeprecationWarning"] minversion = "6.0" -markers = [ - "integration", - "sqlite" -] +markers = ["integration", "sqlite", "perf"] ###################################### # DOCS @@ -233,7 +167,7 @@ dependencies = [ "sphinx-autobuild", "sphinx-autoapi", "openlineage-airflow", - "apache-airflow-providers-cncf-kubernetes>=5.1.1" + "apache-airflow-providers-cncf-kubernetes>=5.1.1", ] [tool.hatch.envs.docs.scripts] diff --git a/scripts/test/integration-expensive.sh b/scripts/test/integration-expensive.sh new file mode 100644 index 0000000000..24bace86d4 --- /dev/null +++ b/scripts/test/integration-expensive.sh @@ -0,0 +1,8 @@ +pytest -vv \ + --cov=cosmos \ + --cov-report=term-missing \ + --cov-report=xml \ + --durations=0 \ + -m integration \ + --ignore=tests/perf \ + -k 'example_cosmos_python_models or example_virtualenv' diff --git a/scripts/test/integration-setup.sh b/scripts/test/integration-setup.sh new file mode 100644 index 0000000000..eba4f15137 --- /dev/null +++ b/scripts/test/integration-setup.sh @@ -0,0 +1,6 @@ +# we install using the following workaround to overcome installation conflicts, such as: +# apache-airflow 2.3.0 and dbt-core [0.13.0 - 1.5.2] and jinja2>=3.0.0 because these package versions have conflicting dependencies +pip uninstall -y dbt-postgres dbt-databricks dbt-vertica; \ +rm -rf airflow.*; \ +airflow db init; \ +pip install 'dbt-core' 'dbt-databricks' 'dbt-postgres' 'dbt-vertica' 'openlineage-airflow' diff --git a/scripts/test/integration-sqlite-setup.sh b/scripts/test/integration-sqlite-setup.sh new file mode 100644 index 0000000000..b8bce035c0 --- /dev/null +++ b/scripts/test/integration-sqlite-setup.sh @@ -0,0 +1,4 @@ +pip uninstall -y dbt-core dbt-sqlite openlineage-airflow openlineage-integration-common; \ +rm -rf airflow.*; \ +airflow db init; \ +pip install 'dbt-core==1.4' 'dbt-sqlite<=1.4' 'dbt-databricks<=1.4' 'dbt-postgres<=1.4' diff --git a/scripts/test/integration-sqlite.sh b/scripts/test/integration-sqlite.sh new file mode 100644 index 0000000000..dc32324d47 --- /dev/null +++ b/scripts/test/integration-sqlite.sh @@ -0,0 +1,8 @@ +pytest -vv \ + --cov=cosmos \ + --cov-report=term-missing \ + --cov-report=xml \ + --durations=0 \ + -m integration \ + --ignore=tests/perf \ + -k 'example_cosmos_sources or sqlite' diff --git a/scripts/test/integration.sh b/scripts/test/integration.sh new file mode 100644 index 0000000000..823f70a7e2 --- /dev/null +++ b/scripts/test/integration.sh @@ -0,0 +1,9 @@ +rm -rf dbt/jaffle_shop/dbt_packages; +pytest -vv \ + --cov=cosmos \ + --cov-report=term-missing \ + --cov-report=xml \ + --durations=0 \ + -m integration \ + --ignore=tests/perf \ + -k 'not (sqlite or example_cosmos_sources or example_cosmos_python_models or example_virtualenv)' diff --git a/scripts/test/performance-setup.sh b/scripts/test/performance-setup.sh new file mode 100644 index 0000000000..b8bce035c0 --- /dev/null +++ b/scripts/test/performance-setup.sh @@ -0,0 +1,4 @@ +pip uninstall -y dbt-core dbt-sqlite openlineage-airflow openlineage-integration-common; \ +rm -rf airflow.*; \ +airflow db init; \ +pip install 'dbt-core==1.4' 'dbt-sqlite<=1.4' 'dbt-databricks<=1.4' 'dbt-postgres<=1.4' diff --git a/scripts/test/performance.sh b/scripts/test/performance.sh new file mode 100644 index 0000000000..ea58c19600 --- /dev/null +++ b/scripts/test/performance.sh @@ -0,0 +1,5 @@ +pytest -vv \ + -s \ + -m 'perf' \ + --ignore=tests/test_example_dags.py \ + --ignore=tests/test_example_dags_no_connections.py diff --git a/scripts/test/unit-cov.sh b/scripts/test/unit-cov.sh new file mode 100644 index 0000000000..89a6244ba5 --- /dev/null +++ b/scripts/test/unit-cov.sh @@ -0,0 +1,10 @@ +pytest \ + -vv \ + --cov=cosmos \ + --cov-report=term-missing \ + --cov-report=xml \ + --durations=0 \ + -m "not (integration or perf)" \ + --ignore=tests/perf \ + --ignore=tests/test_example_dags.py \ + --ignore=tests/test_example_dags_no_connections.py diff --git a/scripts/test/unit.sh b/scripts/test/unit.sh new file mode 100644 index 0000000000..ecc1a049a2 --- /dev/null +++ b/scripts/test/unit.sh @@ -0,0 +1,7 @@ +pytest \ + -vv \ + --durations=0 \ + -m "not (integration or perf)" \ + --ignore=tests/perf \ + --ignore=tests/test_example_dags.py \ + --ignore=tests/test_example_dags_no_connections.py diff --git a/tests/perf/test_performance.py b/tests/perf/test_performance.py new file mode 100644 index 0000000000..acf5d35448 --- /dev/null +++ b/tests/perf/test_performance.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import time +import os +from pathlib import Path +from contextlib import contextmanager +from typing import Generator + +try: + from functools import cache +except ImportError: + from functools import lru_cache as cache + +import pytest +from airflow.models.dagbag import DagBag +from dbt.version import get_installed_version as get_dbt_version +from packaging.version import Version + +EXAMPLE_DAGS_DIR = Path(__file__).parent.parent.parent / "dev/dags" +AIRFLOW_IGNORE_FILE = EXAMPLE_DAGS_DIR / ".airflowignore" +DBT_VERSION = Version(get_dbt_version().to_version_string()[1:]) + + +@cache +def get_dag_bag() -> DagBag: + """Create a DagBag by adding the files that are not supported to .airflowignore""" + # add everything to airflow ignore that isn't performance_dag.py + with open(AIRFLOW_IGNORE_FILE, "w+") as f: + for file in EXAMPLE_DAGS_DIR.iterdir(): + if file.is_file() and file.suffix == ".py": + if file.name != "performance_dag.py": + print(f"Adding {file.name} to .airflowignore") + f.write(f"{file.name}\n") + + print(AIRFLOW_IGNORE_FILE.read_text()) + + db = DagBag(EXAMPLE_DAGS_DIR, include_examples=False) + + assert db.dags + assert not db.import_errors + + return db + + +def generate_model_code(model_number: int) -> str: + """ + Generates code for a dbt model with a dependency on the previous model. Runs + a simple select statement on the previous model. + """ + if model_number == 0: + return f""" + {{{{ config(materialized='table') }}}} + + select 1 as id + """ + + return f""" + {{{{ config(materialized='table') }}}} + + select * from {{{{ ref('model_{model_number - 1}') }}}} + """ + + +@contextmanager +def generate_project( + project_path: Path, + num_models: int, +) -> Generator[Path, None, None]: + """ + Generate dbt models in the project directory. + """ + models_dir = project_path / "models" + + try: + # create the models directory + models_dir.mkdir(exist_ok=True) + + # create the models + for i in range(num_models): + model = models_dir / f"model_{i}.sql" + model.write_text(generate_model_code(i)) + + yield project_path + finally: + # clean up the models in the project_path / models directory + for model in models_dir.iterdir(): + model.unlink() + + +@pytest.mark.perf +def test_perf_dag(): + num_models = os.environ.get("MODEL_COUNT", 10) + + if type(num_models) is str: + num_models = int(num_models) + + print(f"Generating dbt project with {num_models} models") + + dbt_project_dir = EXAMPLE_DAGS_DIR / "dbt" / "perf" + + with generate_project(dbt_project_dir, num_models): + dag_bag = get_dag_bag() + + dag = dag_bag.get_dag("performance_dag") + + # verify the integrity of the dag + assert dag.task_count == num_models + + # measure the time before and after the dag is run + + start = time.time() + dag.test() + end = time.time() + + print(f"Ran {num_models} models in {end - start} seconds") + print(f"NUM_MODELS={num_models}\nTIME={end - start}") + + # write the results to a file + with open("/tmp/performance_results.txt", "w") as f: + f.write( + f"NUM_MODELS={num_models}\nTIME={end - start}\nMODELS_PER_SECOND={num_models / (end - start)}\nDBT_VERSION={DBT_VERSION}" + ) diff --git a/tests/test_example_dags.py b/tests/test_example_dags.py index 11655a31dc..91fd1d6c20 100644 --- a/tests/test_example_dags.py +++ b/tests/test_example_dags.py @@ -26,6 +26,8 @@ "2.4": ["cosmos_seed_dag.py"], } +IGNORED_DAG_FILES = ["performance_dag.py"] + # Sort descending based on Versions and convert string to an actual version MIN_VER_DAG_FILE_VER: dict[Version, list[str]] = { Version(version): MIN_VER_DAG_FILE[version] for version in sorted(MIN_VER_DAG_FILE, key=Version, reverse=True) @@ -51,11 +53,17 @@ def get_dag_bag() -> DagBag: if Version(airflow.__version__) < min_version: print(f"Adding {files} to .airflowignore") file.writelines([f"{file}\n" for file in files]) - # The dbt sqlite adapter is only available until dbt 1.4 + + for dagfile in IGNORED_DAG_FILES: + print(f"Adding {dagfile} to .airflowignore") + file.writelines([f"{dagfile}\n"]) + + # The dbt sqlite adapter is only available until dbt 1.4 if DBT_VERSION >= Version("1.5.0"): file.writelines(["example_cosmos_sources.py\n"]) if DBT_VERSION < Version("1.6.0"): file.writelines(["example_model_version.py\n"]) + print(".airflowignore contents: ") print(AIRFLOW_IGNORE_FILE.read_text()) db = DagBag(EXAMPLE_DAGS_DIR, include_examples=False) diff --git a/tests/test_example_dags_no_connections.py b/tests/test_example_dags_no_connections.py index 5356c4ea6e..ae7c354a1d 100644 --- a/tests/test_example_dags_no_connections.py +++ b/tests/test_example_dags_no_connections.py @@ -22,6 +22,8 @@ "2.4": ["cosmos_seed_dag.py"], } +IGNORED_DAG_FILES = ["performance_dag.py"] + # Sort descending based on Versions and convert string to an actual version MIN_VER_DAG_FILE_VER: dict[Version, list[str]] = { Version(version): MIN_VER_DAG_FILE[version] for version in sorted(MIN_VER_DAG_FILE, key=Version, reverse=True) @@ -36,9 +38,11 @@ def get_dag_bag() -> DagBag: if Version(airflow.__version__) < min_version: print(f"Adding {files} to .airflowignore") file.writelines([f"{file_name}\n" for file_name in files]) - a = 1 + 2 - b = 3 + 4 - a + b + + for dagfile in IGNORED_DAG_FILES: + print(f"Adding {dagfile} to .airflowignore") + file.writelines([f"{dagfile}\n"]) + if DBT_VERSION >= Version("1.5.0"): file.writelines(["example_cosmos_sources.py\n"]) if DBT_VERSION < Version("1.6.0"): From 23c3e05c1ee63da733b52cc793b217b45f786d15 Mon Sep 17 00:00:00 2001 From: Justin Bandoro <79104794+jbandoro@users.noreply.github.com> Date: Fri, 16 Feb 2024 11:05:28 -0800 Subject: [PATCH 100/223] Fix: `operator_args` modified in place in Airflow converter (#835) ## Description This is a fix for #833 where a user in Slack reported a bug where if the same `operator_args` are used in multiple `DbtTaskGroup`'s the `vars` were only used in the first task group. I added a test which would have caught the bug, so that it isn't reintroduced later. ## Related Issue(s) closes #833 ## Breaking Change? None ## Checklist - [ ] I have made corresponding changes to the documentation (if required) - [x] I have added tests that prove my fix is effective or that my feature works --- cosmos/converter.py | 4 ++-- tests/test_converter.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/cosmos/converter.py b/cosmos/converter.py index 97e8190dd1..a6b4441f7a 100644 --- a/cosmos/converter.py +++ b/cosmos/converter.py @@ -221,8 +221,8 @@ def __init__( validate_adapted_user_config(execution_config, project_config, render_config) - env_vars = project_config.env_vars or operator_args.pop("env", None) - dbt_vars = project_config.dbt_vars or operator_args.pop("vars", None) + env_vars = project_config.env_vars or operator_args.get("env") + dbt_vars = project_config.dbt_vars or operator_args.get("vars") # Previously, we were creating a cosmos.dbt.project.DbtProject # DbtProject has now been replaced with ProjectConfig directly diff --git a/tests/test_converter.py b/tests/test_converter.py index d84249aaee..b0913acae3 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -405,3 +405,36 @@ def test_converter_project_config_dbt_vars_with_custom_load_mode( ) _, kwargs = mock_legacy_dbt_project.call_args assert kwargs["dbt_vars"] == {"key": "value"} + + +@patch("cosmos.config.ProjectConfig.validate_project") +@patch("cosmos.converter.build_airflow_graph") +@patch("cosmos.converter.DbtGraph.load") +def test_converter_multiple_calls_same_operator_args( + mock_dbt_graph_load, mock_validate_project, mock_build_airflow_graph +): + """Tests if the DbttoAirflowConverter is called more than once with the same operator_args, the + operator_args are not modified. + """ + project_config = ProjectConfig(project_name="fake-project", dbt_project_path="/some/project/path") + execution_config = ExecutionConfig() + render_config = RenderConfig() + profile_config = MagicMock() + operator_args = { + "install_deps": True, + "vars": {"key": "value"}, + "env": {"key": "value"}, + } + original_operator_args = operator_args.copy() + for _ in range(2): + with DAG("test-id", start_date=datetime(2022, 1, 1)) as dag: + DbtToAirflowConverter( + dag=dag, + nodes=nodes, + project_config=project_config, + profile_config=profile_config, + execution_config=execution_config, + render_config=render_config, + operator_args=operator_args, + ) + assert operator_args == original_operator_args From 916eac7345847125578c82767d45538979ae5cde Mon Sep 17 00:00:00 2001 From: Justin Bandoro <79104794+jbandoro@users.noreply.github.com> Date: Fri, 16 Feb 2024 11:39:17 -0800 Subject: [PATCH 101/223] Fix: Docker and Kubernetes operators execute method resolution (#849) ## Description This was a bug reported by users using the kuberentes execution method where the pods wouldn't run any commands. With how the base operators inherited from their parent classes, the `execute` method for the operator was either the `DockerOperator.execute` or `KubernetesPodOperator.execute` skipping the `build_and_run_cmd` required to setup before containers were run. As part of this fix, I added tests to check that when the `execute` method is invoked that the docker/kube args are first set before the executor pod is executed. ## Related Issue(s) closes #848 --- cosmos/operators/docker.py | 4 +-- cosmos/operators/kubernetes.py | 4 +-- tests/operators/test_docker.py | 43 +++++++++++++++++++++++++----- tests/operators/test_kubernetes.py | 42 ++++++++++++++++++++++++----- 4 files changed, 75 insertions(+), 18 deletions(-) diff --git a/cosmos/operators/docker.py b/cosmos/operators/docker.py index 848aa37096..571bff046d 100644 --- a/cosmos/operators/docker.py +++ b/cosmos/operators/docker.py @@ -28,7 +28,7 @@ ) -class DbtDockerBaseOperator(DockerOperator, AbstractDbtBaseOperator): # type: ignore +class DbtDockerBaseOperator(AbstractDbtBaseOperator, DockerOperator): # type: ignore """ Executes a dbt core cli command in a Docker container. @@ -50,7 +50,7 @@ def __init__( def build_and_run_cmd(self, context: Context, cmd_flags: list[str] | None = None) -> Any: self.build_command(context, cmd_flags) self.log.info(f"Running command: {self.command}") - result = super().execute(context) + result = DockerOperator.execute(self, context) logger.info(result) def build_command(self, context: Context, cmd_flags: list[str] | None = None) -> None: diff --git a/cosmos/operators/kubernetes.py b/cosmos/operators/kubernetes.py index b45f1d741b..fc0e28c051 100644 --- a/cosmos/operators/kubernetes.py +++ b/cosmos/operators/kubernetes.py @@ -44,7 +44,7 @@ ) -class DbtKubernetesBaseOperator(KubernetesPodOperator, AbstractDbtBaseOperator): # type: ignore +class DbtKubernetesBaseOperator(AbstractDbtBaseOperator, KubernetesPodOperator): # type: ignore """ Executes a dbt core cli command in a Kubernetes Pod. @@ -73,7 +73,7 @@ def build_env_args(self, env: dict[str, str | bytes | PathLike[Any]]) -> None: def build_and_run_cmd(self, context: Context, cmd_flags: list[str] | None = None) -> Any: self.build_kube_args(context, cmd_flags) self.log.info(f"Running command: {self.arguments}") - result = super().execute(context) + result = KubernetesPodOperator.execute(self, context) logger.info(result) def build_kube_args(self, context: Context, cmd_flags: list[str] | None = None) -> None: diff --git a/tests/operators/test_docker.py b/tests/operators/test_docker.py index 7d989f1a00..ef26fbaff0 100644 --- a/tests/operators/test_docker.py +++ b/tests/operators/test_docker.py @@ -1,12 +1,12 @@ from pathlib import Path from unittest.mock import MagicMock, patch +import pytest from airflow.utils.context import Context from pendulum import datetime from cosmos.operators.docker import ( DbtBuildDockerOperator, - DbtDockerBaseOperator, DbtLSDockerOperator, DbtRunDockerOperator, DbtSeedDockerOperator, @@ -14,12 +14,24 @@ ) -class ConcreteDbtDockerBaseOperator(DbtDockerBaseOperator): - base_cmd = ["cmd"] +@pytest.fixture() +def mock_docker_execute(): + with patch("cosmos.operators.docker.DockerOperator.execute") as mock_execute: + yield mock_execute -def test_dbt_docker_operator_add_global_flags() -> None: - dbt_base_operator = ConcreteDbtDockerBaseOperator( +@pytest.fixture() +def base_operator(mock_docker_execute): + from cosmos.operators.docker import DbtDockerBaseOperator + + class ConcreteDbtDockerBaseOperator(DbtDockerBaseOperator): + base_cmd = ["cmd"] + + return ConcreteDbtDockerBaseOperator + + +def test_dbt_docker_operator_add_global_flags(base_operator) -> None: + dbt_base_operator = base_operator( conn_id="my_airflow_connection", task_id="my-task", image="my_image", @@ -38,12 +50,29 @@ def test_dbt_docker_operator_add_global_flags() -> None: ] +@patch("cosmos.operators.docker.DbtDockerBaseOperator.build_command") +def test_dbt_docker_operator_execute(mock_build_command, base_operator, mock_docker_execute): + """Tests that the execute method call results in both the build_command method and the docker execute method being called.""" + operator = base_operator( + conn_id="my_airflow_connection", + task_id="my-task", + image="my_image", + project_dir="my/dir", + ) + operator.execute(context={}) + # Assert that the build_command method was called in the execution + mock_build_command.assert_called_once() + # Assert that the docker execute method was called in the execution + mock_docker_execute.assert_called_once() + assert mock_docker_execute.call_args.args[-1] == {} + + @patch("cosmos.operators.base.context_to_airflow_vars") -def test_dbt_docker_operator_get_env(p_context_to_airflow_vars: MagicMock) -> None: +def test_dbt_docker_operator_get_env(p_context_to_airflow_vars: MagicMock, base_operator) -> None: """ If an end user passes in a """ - dbt_base_operator = ConcreteDbtDockerBaseOperator( + dbt_base_operator = base_operator( conn_id="my_airflow_connection", task_id="my-task", image="my_image", diff --git a/tests/operators/test_kubernetes.py b/tests/operators/test_kubernetes.py index be8624942a..e267b9b05f 100644 --- a/tests/operators/test_kubernetes.py +++ b/tests/operators/test_kubernetes.py @@ -6,7 +6,6 @@ from cosmos.operators.kubernetes import ( DbtBuildKubernetesOperator, - DbtKubernetesBaseOperator, DbtLSKubernetesOperator, DbtRunKubernetesOperator, DbtSeedKubernetesOperator, @@ -24,12 +23,24 @@ module_available = False -class ConcreteDbtKubernetesBaseOperator(DbtKubernetesBaseOperator): - base_cmd = ["cmd"] +@pytest.fixture() +def mock_kubernetes_execute(): + with patch("cosmos.operators.kubernetes.KubernetesPodOperator.execute") as mock_execute: + yield mock_execute -def test_dbt_kubernetes_operator_add_global_flags() -> None: - dbt_kube_operator = ConcreteDbtKubernetesBaseOperator( +@pytest.fixture() +def base_operator(mock_kubernetes_execute): + from cosmos.operators.kubernetes import DbtKubernetesBaseOperator + + class ConcreteDbtKubernetesBaseOperator(DbtKubernetesBaseOperator): + base_cmd = ["cmd"] + + return ConcreteDbtKubernetesBaseOperator + + +def test_dbt_kubernetes_operator_add_global_flags(base_operator) -> None: + dbt_kube_operator = base_operator( conn_id="my_airflow_connection", task_id="my-task", image="my_image", @@ -48,12 +59,29 @@ def test_dbt_kubernetes_operator_add_global_flags() -> None: ] +@patch("cosmos.operators.kubernetes.DbtKubernetesBaseOperator.build_kube_args") +def test_dbt_kubernetes_operator_execute(mock_build_kube_args, base_operator, mock_kubernetes_execute): + """Tests that the execute method call results in both the build_kube_args method and the kubernetes execute method being called.""" + operator = base_operator( + conn_id="my_airflow_connection", + task_id="my-task", + image="my_image", + project_dir="my/dir", + ) + operator.execute(context={}) + # Assert that the build_kube_args method was called in the execution + mock_build_kube_args.assert_called_once() + # Assert that the kubernetes execute method was called in the execution + mock_kubernetes_execute.assert_called_once() + assert mock_kubernetes_execute.call_args.args[-1] == {} + + @patch("cosmos.operators.base.context_to_airflow_vars") -def test_dbt_kubernetes_operator_get_env(p_context_to_airflow_vars: MagicMock) -> None: +def test_dbt_kubernetes_operator_get_env(p_context_to_airflow_vars: MagicMock, base_operator) -> None: """ If an end user passes in a """ - dbt_kube_operator = ConcreteDbtKubernetesBaseOperator( + dbt_kube_operator = base_operator( conn_id="my_airflow_connection", task_id="my-task", image="my_image", From 3809de259d506836d142931d9c15192e95f81442 Mon Sep 17 00:00:00 2001 From: Daniel Reeves <31971762+dwreeves@users.noreply.github.com> Date: Mon, 19 Feb 2024 15:17:10 -0500 Subject: [PATCH 102/223] Support partial parsing (#800) ## Description dbt uses `partial_parse.msgpack` to make rendering things a lot faster. This PR adds support for `partial_parse.msgpack` in the following places: - `ExecutionMode.LOCAL` - `ExecutionMode.VIRTUALENV` - `LoadMode.DBT_LS` This PR also allows users to explicitly _turn off_ partial parsing. If this is done, then `--no-partial-parse` will be passed as an explicit flag in all dbt command invocations (i.e. all `ExecutionMode`s and `LoadMode.DBT_LS`, albeit not the `dbt deps` invocation.) This should address some performance complaints that users have, e.g. this message from Slack: https://apache-airflow.slack.com/archives/C059CC42E9W/p1704483361206829 Albeit, this user will also need to provide a `partial_parse.msgpack`. My experimentation and looking at dbt-core source code confirms that dbt does not use `manifest.json` when partial parsing. It appears that these are just output artifacts, but not input artifacts. Only `partial_parse.msgpack` is used. (There are a couple ways to confirm this other than just checking source code Also, I added a minor refactor of a feature I added a year ago: I added `send_sigint()` to the custom subprocess hook, since this custom subprocess hook did not exist back when I added it (if you want me to split this refactor into a different PR then let me know). ### API change I decided the best way to go about this would be to just add a `partial_parse: bool` to both the execution config and render config. For example: ```python dbt_group = DbtTaskGroup( ..., execution_config=ExecutionConfig( ..., partial_parse=True ), render_config=RenderConfig( ..., partial_parse=False ) ) ``` That said, in all honesty users will not need to set this at all, except unless they want to suppress the little warning message about not being able to find the `partial_parse.msgpack`. This is because by default this addition searches for a msgpack if it exists, which is already the existing behavior in a sense, except right now the msgpack file never exists (dbt does look for it though). When inserting into the `AbstractDbtBaseOperator`, I did not use `global_boolean_flags`. See the subsection below for why. ### Other execution performance improvements The main reason I am adding this feature is that it should dramatically improve performance for users. However, it is not the only way to improve It's possible that we should add a way to add the flag `--no-write-json` as an explicit kwarg to the dbt base operator. Right now users can support this via `dbt_cmd_global_flags=["--no-write-json"]`. Some users (e.g. those using Elementary, or those using the dbt local operator `callback` kwarg) will want to write the JSON, but I suspect the majority of users will not. Similarly, `--log-level-file` is not used at all, and at minimum dbt should work best the vast majority of time with `--no-cache-selected-only` set. It's also possible there should be some sort of "performant" mode that automatically sets all these defaults for optimal performance: - `--no-write-json` - `--log-level-file=none` - `--no-cache-selected-only` Perhaps a "performant" config would be too cumbersome to implement (I would agree with that). In which case the docs could also have a section on performance tips. ### A note on `global_boolean_flags` I did not add the partial parse support to `global_boolean_flags` because it doesn't quite fit into the existing paradigm for this. Right now the default for each of these `global_boolean_flags` is False, whereas the default for partial parse is actually True. This makes fitting it in awkward. I think it's possible that just having a `tuple[str]` is insufficient here, as some flags you may want to add (not just `--no-partial-parse` but also `--no-write-json` are by default _True_ and must be explicitly turned off. Meaning that the parsing Cosmos does with flags of `'--{flag.replace("_", "-")}'` is ineffective for flags like this. Right now, we have an example of putting _no_ in front with `no_version_check`. Meaning that the default behavior of version checking is True, but the flag itself starts as negated so the default is actually `False`. My proposal is, instead of `global_boolean_flags: tuple[str]`, this should instead be `global_boolean_flags: tuple[str | tuple[str, str | None, str | None]]`. In the case that a global flag is a `tuple[str, str | None, str | None]`, then the first arg should be the flag, the second should be "if true," and the third should be "if false." `None` indicates, when true/false (respectively), then do nothing. For example: ```python class AbstractDbtBaseOperator(BaseOperator, metaclass=ABCMeta): ... global_boolean_flags = ( ("no_version_check", "--no-version-check", None), ("cache_selected_only", "-cache-selected-only", None), ("partial_parse", None, "--no-partial-parse"), ) ``` And Cosmos want to support `str` parsing for backwards compatibility. It's pretty straightforward to convert the data type: ```python if isinstance(flag, str): flag = (flag, '--{flag.replace("_", "-")}', None) ``` ## Related Issue(s) - Resolves #791 - Partially resolves #785 - #785 should probably be split up into two different stages: (1) support for partial parsing (2) (a) dbt project dir / manifest / `partial_parse.msgpack` is allowed to come from cloud storage. (b) `dbt compile` is able to dump into cloud storage. ## Breaking Change? Should not break anything. This doesn't do anything when `partial_parse.msgpack` is missing, and the default behavior (`partial_parse=True`) does not alter the dbt cmd flags. ## Checklist - [x] I have made corresponding changes to the documentation (if required) - [x] I have added tests that prove my fix is effective or that my feature works --------- Co-authored-by: Tatiana Al-Chueyr Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Julian LaNeve Co-authored-by: Justin Bandoro <79104794+jbandoro@users.noreply.github.com> --- cosmos/config.py | 7 ++- cosmos/constants.py | 1 + cosmos/converter.py | 1 + cosmos/dbt/graph.py | 8 ++- cosmos/dbt/project.py | 16 ++++-- cosmos/hooks/subprocess.py | 6 ++ cosmos/operators/base.py | 16 +++--- cosmos/operators/local.py | 16 ++++-- docs/configuration/project-config.rst | 3 + docs/getting_started/execution-modes.rst | 4 ++ tests/dbt/test_graph.py | 28 ++++++++++ tests/dbt/test_project.py | 31 ++++++++++- tests/hooks/__init__.py | 0 tests/hooks/test_subprocess.py | 70 ++++++++++++++++++++++++ tests/operators/test_local.py | 40 ++++++++++++++ 15 files changed, 227 insertions(+), 20 deletions(-) create mode 100644 tests/hooks/__init__.py create mode 100644 tests/hooks/test_subprocess.py diff --git a/cosmos/config.py b/cosmos/config.py index dc33c0eba5..52763536f3 100644 --- a/cosmos/config.py +++ b/cosmos/config.py @@ -122,6 +122,9 @@ class ProjectConfig: :param dbt_vars: Dictionary of dbt variables for the project. This argument overrides variables defined in your dbt_project.yml file. The dictionary is dumped to a yaml string and passed to dbt commands as the --vars argument. Variables are only supported for rendering when using ``RenderConfig.LoadMode.DBT_LS`` and ``RenderConfig.LoadMode.CUSTOM`` load mode. + :param partial_parse: If True, then attempt to use the ``partial_parse.msgpack`` if it exists. This is only used + for the ``LoadMode.DBT_LS`` load mode, and for the ``ExecutionMode.LOCAL`` and ``ExecutionMode.VIRTUALENV`` + execution modes. """ dbt_project_path: Path | None = None @@ -141,6 +144,7 @@ def __init__( project_name: str | None = None, env_vars: dict[str, str] | None = None, dbt_vars: dict[str, str] | None = None, + partial_parse: bool = True, ): # Since we allow dbt_project_path to be defined in ExecutionConfig and RenderConfig # dbt_project_path may not always be defined here. @@ -166,6 +170,7 @@ def __init__( self.env_vars = env_vars self.dbt_vars = dbt_vars + self.partial_parse = partial_parse def validate_project(self) -> None: """ @@ -292,7 +297,7 @@ class ExecutionConfig: :param execution_mode: The execution mode for dbt. Defaults to local :param test_indirect_selection: The mode to configure the test behavior when performing indirect selection. :param dbt_executable_path: The path to the dbt executable for runtime execution. Defaults to dbt if available on the path. - :param dbt_project_path Configures the DBT project location accessible at runtime for dag execution. This is the project path in a docker container for ExecutionMode.DOCKER or ExecutionMode.KUBERNETES. Mutually Exclusive with ProjectConfig.dbt_project_path + :param dbt_project_path: Configures the DBT project location accessible at runtime for dag execution. This is the project path in a docker container for ExecutionMode.DOCKER or ExecutionMode.KUBERNETES. Mutually Exclusive with ProjectConfig.dbt_project_path """ execution_mode: ExecutionMode = ExecutionMode.LOCAL diff --git a/cosmos/constants.py b/cosmos/constants.py index 4741d621d6..b5a1f3daa1 100644 --- a/cosmos/constants.py +++ b/cosmos/constants.py @@ -12,6 +12,7 @@ DBT_LOG_DIR_NAME = "logs" DBT_TARGET_PATH_ENVVAR = "DBT_TARGET_PATH" DBT_TARGET_DIR_NAME = "target" +DBT_PARTIAL_PARSE_FILE_NAME = "partial_parse.msgpack" DBT_LOG_FILENAME = "dbt.log" DBT_BINARY_NAME = "dbt" diff --git a/cosmos/converter.py b/cosmos/converter.py index a6b4441f7a..1bd227a42f 100644 --- a/cosmos/converter.py +++ b/cosmos/converter.py @@ -246,6 +246,7 @@ def __init__( task_args = { **operator_args, "project_dir": execution_config.project_path, + "partial_parse": project_config.partial_parse, "profile_config": profile_config, "emit_datasets": render_config.emit_datasets, "env": env_vars, diff --git a/cosmos/dbt/graph.py b/cosmos/dbt/graph.py index 6d941e2786..6ebfddcc32 100644 --- a/cosmos/dbt/graph.py +++ b/cosmos/dbt/graph.py @@ -22,7 +22,7 @@ LoadMode, ) from cosmos.dbt.parser.project import LegacyDbtProject -from cosmos.dbt.project import create_symlinks, environ +from cosmos.dbt.project import create_symlinks, copy_msgpack_for_partial_parse, environ from cosmos.dbt.selector import select_nodes from cosmos.log import get_logger @@ -241,6 +241,9 @@ def run_dbt_ls( if self.render_config.selector: ls_command.extend(["--selector", self.render_config.selector]) + if not self.project.partial_parse: + ls_command.append("--no-partial-parse") + ls_command.extend(self.local_flags) stdout = run_command(ls_command, tmp_dir, env_vars) @@ -285,6 +288,9 @@ def load_via_dbt_ls(self) -> None: tmpdir_path = Path(tmpdir) create_symlinks(self.render_config.project_path, tmpdir_path, self.render_config.dbt_deps) + if self.project.partial_parse: + copy_msgpack_for_partial_parse(self.render_config.project_path, tmpdir_path) + with self.profile_config.ensure_profile(use_mock_values=True) as profile_values, environ( self.project.env_vars or self.render_config.env_vars or {} ): diff --git a/cosmos/dbt/project.py b/cosmos/dbt/project.py index aff6ed03ec..889987b6de 100644 --- a/cosmos/dbt/project.py +++ b/cosmos/dbt/project.py @@ -1,11 +1,9 @@ from __future__ import annotations from pathlib import Path +import shutil import os -from cosmos.constants import ( - DBT_LOG_DIR_NAME, - DBT_TARGET_DIR_NAME, -) +from cosmos.constants import DBT_LOG_DIR_NAME, DBT_TARGET_DIR_NAME, DBT_PARTIAL_PARSE_FILE_NAME from contextlib import contextmanager from typing import Generator @@ -21,6 +19,16 @@ def create_symlinks(project_path: Path, tmp_dir: Path, ignore_dbt_packages: bool os.symlink(project_path / child_name, tmp_dir / child_name) +def copy_msgpack_for_partial_parse(project_path: Path, tmp_dir: Path) -> None: + partial_parse_file = Path(project_path) / DBT_TARGET_DIR_NAME / DBT_PARTIAL_PARSE_FILE_NAME + + if partial_parse_file.exists(): + tmp_target_dir = tmp_dir / DBT_TARGET_DIR_NAME + tmp_target_dir.mkdir(exist_ok=True) + + shutil.copy(str(partial_parse_file), str(tmp_target_dir / DBT_PARTIAL_PARSE_FILE_NAME)) + + @contextmanager def environ(env_vars: dict[str, str]) -> Generator[None, None, None]: """Temporarily set environment variables inside the context manager and restore diff --git a/cosmos/hooks/subprocess.py b/cosmos/hooks/subprocess.py index cf6b489e8b..2522420b72 100644 --- a/cosmos/hooks/subprocess.py +++ b/cosmos/hooks/subprocess.py @@ -105,3 +105,9 @@ def send_sigterm(self) -> None: logger.info("Sending SIGTERM signal to process group") if self.sub_process and hasattr(self.sub_process, "pid"): os.killpg(os.getpgid(self.sub_process.pid), signal.SIGTERM) + + def send_sigint(self) -> None: + """Sends SIGINT signal to ``self.sub_process`` if one exists.""" + logger.info("Sending SIGINT signal to process group") + if self.sub_process and hasattr(self.sub_process, "pid"): + os.killpg(os.getpgid(self.sub_process.pid), signal.SIGINT) diff --git a/cosmos/operators/base.py b/cosmos/operators/base.py index 25aef77642..d8fefc5239 100644 --- a/cosmos/operators/base.py +++ b/cosmos/operators/base.py @@ -51,6 +51,9 @@ class AbstractDbtBaseOperator(BaseOperator, metaclass=ABCMeta): :param skip_exit_code: If task exits with this exit code, leave the task in ``skipped`` state (default: 99). If set to ``None``, any non-zero exit code will be treated as a failure. + :param partial_parse: If True (default), then the operator will use the + ``partial_parse.msgpack`` during execution if it exists. If False, then + a flag will be explicitly set to turn off partial parsing. :param cancel_query_on_kill: If true, then cancel any running queries when the task's on_kill() is executed. Otherwise, the query will keep running when the task is killed. :param dbt_executable_path: Path to dbt executable can be used with venv @@ -68,13 +71,7 @@ class AbstractDbtBaseOperator(BaseOperator, metaclass=ABCMeta): "vars", "models", ) - global_boolean_flags = ( - "no_version_check", - "cache_selected_only", - "fail_fast", - "quiet", - "warn_error", - ) + global_boolean_flags = ("no_version_check", "cache_selected_only", "fail_fast", "quiet", "warn_error") intercept_flag = True @@ -105,6 +102,7 @@ def __init__( append_env: bool = False, output_encoding: str = "utf-8", skip_exit_code: int = 99, + partial_parse: bool = True, cancel_query_on_kill: bool = True, dbt_executable_path: str = get_system_dbt(), dbt_cmd_flags: list[str] | None = None, @@ -131,6 +129,7 @@ def __init__( self.append_env = append_env self.output_encoding = output_encoding self.skip_exit_code = skip_exit_code + self.partial_parse = partial_parse self.cancel_query_on_kill = cancel_query_on_kill self.dbt_executable_path = dbt_executable_path self.dbt_cmd_flags = dbt_cmd_flags @@ -219,6 +218,9 @@ def build_cmd( dbt_cmd.extend(self.dbt_cmd_global_flags) + if not self.partial_parse: + dbt_cmd.append("--no-partial-parse") + dbt_cmd.extend(self.base_cmd) if self.indirect_selection: diff --git a/cosmos/operators/local.py b/cosmos/operators/local.py index 123f4f5fde..f805b2882f 100644 --- a/cosmos/operators/local.py +++ b/cosmos/operators/local.py @@ -34,7 +34,12 @@ from sqlalchemy.orm import Session -from cosmos.constants import DEFAULT_OPENLINEAGE_NAMESPACE, OPENLINEAGE_PRODUCER +from cosmos.constants import ( + DEFAULT_OPENLINEAGE_NAMESPACE, + OPENLINEAGE_PRODUCER, + DBT_TARGET_DIR_NAME, + DBT_PARTIAL_PARSE_FILE_NAME, +) from cosmos.config import ProfileConfig from cosmos.log import get_logger from cosmos.operators.base import ( @@ -52,7 +57,7 @@ FullOutputSubprocessResult, ) from cosmos.dbt.parser.output import extract_log_issues, parse_output -from cosmos.dbt.project import create_symlinks +from cosmos.dbt.project import create_symlinks, copy_msgpack_for_partial_parse DBT_NO_TESTS_MSG = "Nothing to do" DBT_WARN_MSG = "WARN" @@ -208,6 +213,9 @@ def run_command( create_symlinks(Path(self.project_dir), Path(tmp_project_dir), self.install_deps) + if self.partial_parse: + copy_msgpack_for_partial_parse(Path(self.project_dir), Path(tmp_project_dir)) + with self.profile_config.ensure_profile() as profile_values: (profile_path, env_vars) = profile_values env.update(env_vars) @@ -374,9 +382,7 @@ def build_and_run_cmd(self, context: Context, cmd_flags: list[str] | None = None def on_kill(self) -> None: if self.cancel_query_on_kill: - self.subprocess_hook.log.info("Sending SIGINT signal to process group") - if self.subprocess_hook.sub_process and hasattr(self.subprocess_hook.sub_process, "pid"): - os.killpg(os.getpgid(self.subprocess_hook.sub_process.pid), signal.SIGINT) + self.subprocess_hook.send_sigint() else: self.subprocess_hook.send_sigterm() diff --git a/docs/configuration/project-config.rst b/docs/configuration/project-config.rst index c062a1de52..3bf524ac82 100644 --- a/docs/configuration/project-config.rst +++ b/docs/configuration/project-config.rst @@ -23,6 +23,9 @@ variables that should be used for rendering and execution. It takes the followin will only be rendered at execution time, not at render time. - ``env_vars``: (new in v1.3) A dictionary of environment variables used for rendering and execution. Rendering with env vars is only supported when using ``RenderConfig.LoadMode.DBT_LS`` load mode. +- ``partial_parse``: (new in v1.4) If True, then attempt to use the ``partial_parse.msgpack`` if it exists. This is only used + for the ``LoadMode.DBT_LS`` load mode, and for the ``ExecutionMode.LOCAL`` and ``ExecutionMode.VIRTUALENV`` + execution modes. Project Config Example ---------------------- diff --git a/docs/getting_started/execution-modes.rst b/docs/getting_started/execution-modes.rst index 924e4ba129..7c7417cc7a 100644 --- a/docs/getting_started/execution-modes.rst +++ b/docs/getting_started/execution-modes.rst @@ -56,6 +56,9 @@ The ``local`` execution mode assumes a ``dbt`` binary is reachable within the Ai If ``dbt`` was not installed as part of the Cosmos packages, users can define a custom path to ``dbt`` by declaring the argument ``dbt_executable_path``. +By default, if Cosmos sees a ``partial_parse.msgpack`` in the target directory of the dbt project directory when using ``local`` execution, it will use this for partial parsing to speed up task execution. +This can be turned off by setting ``partial_parse=False`` in the ``ProjectConfig``. + When using the ``local`` execution mode, Cosmos converts Airflow Connections into a native ``dbt`` profiles file (``profiles.yml``). Example of how to use, for instance, when ``dbt`` was installed together with Cosmos: @@ -76,6 +79,7 @@ The ``virtualenv`` mode isolates the Airflow worker dependencies from ``dbt`` by In this case, users are responsible for declaring which version of ``dbt`` they want to use by giving the argument ``py_requirements``. This argument can be set directly in operator instances or when instantiating ``DbtDag`` and ``DbtTaskGroup`` as part of ``operator_args``. Similar to the ``local`` execution mode, Cosmos converts Airflow Connections into a way ``dbt`` understands them by creating a ``dbt`` profile file (``profiles.yml``). +Also similar to the ``local`` execution mode, Cosmos will by default attempt to use a ``partial_parse.msgpack`` if one exists to speed up parsing. Some drawbacks of this approach: diff --git a/tests/dbt/test_graph.py b/tests/dbt/test_graph.py index cdca57b7eb..c27b208410 100644 --- a/tests/dbt/test_graph.py +++ b/tests/dbt/test_graph.py @@ -901,6 +901,34 @@ def test_load_via_dbt_ls_render_config_selector_arg_is_used( assert ls_command[ls_command.index("--selector") + 1] == selector +@patch("cosmos.dbt.graph.Popen") +@patch("cosmos.dbt.graph.DbtGraph.update_node_dependency") +@patch("cosmos.config.RenderConfig.validate_dbt_command") +def test_load_via_dbt_ls_render_config_no_partial_parse( + mock_validate, mock_update_nodes, mock_popen, tmp_dbt_project_dir +): + """Tests that --no-partial-parse appears when partial_parse=False.""" + mock_popen().communicate.return_value = ("", "") + mock_popen().returncode = 0 + project_config = ProjectConfig(partial_parse=False) + render_config = RenderConfig(dbt_project_path=tmp_dbt_project_dir / DBT_PROJECT_NAME, load_method=LoadMode.DBT_LS) + profile_config = ProfileConfig( + profile_name="test", + target_name="test", + profiles_yml_filepath=DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME / "profiles.yml", + ) + execution_config = MagicMock() + dbt_graph = DbtGraph( + project=project_config, + render_config=render_config, + execution_config=execution_config, + profile_config=profile_config, + ) + dbt_graph.load_via_dbt_ls() + ls_command = mock_popen.call_args.args[0] + assert "--no-partial-parse" in ls_command + + @pytest.mark.parametrize("load_method", [LoadMode.DBT_MANIFEST, LoadMode.CUSTOM]) def test_load_method_with_unsupported_render_config_selector_arg(load_method): """Tests that error is raised when RenderConfig.selector is used with LoadMode.DBT_MANIFEST or LoadMode.CUSTOM.""" diff --git a/tests/dbt/test_project.py b/tests/dbt/test_project.py index 000ad06bdc..85314b8e5a 100644 --- a/tests/dbt/test_project.py +++ b/tests/dbt/test_project.py @@ -1,8 +1,11 @@ -from pathlib import Path -from cosmos.dbt.project import create_symlinks, environ import os +from pathlib import Path from unittest.mock import patch +import pytest + +from cosmos.dbt.project import create_symlinks, copy_msgpack_for_partial_parse, environ + DBT_PROJECTS_ROOT_DIR = Path(__file__).parent.parent.parent / "dev/dags/dbt" @@ -17,6 +20,30 @@ def test_create_symlinks(tmp_path): assert child.name not in ("logs", "target", "profiles.yml", "dbt_packages") +@pytest.mark.parametrize("exists", [True, False]) +def test_copy_manifest_for_partial_parse(tmp_path, exists): + project_path = tmp_path / "project" + target_path = project_path / "target" + partial_parse_file = target_path / "partial_parse.msgpack" + + target_path.mkdir(parents=True) + + if exists: + partial_parse_file.write_bytes(b"") + + tmp_dir = tmp_path / "tmp_dir" + tmp_dir.mkdir() + + copy_msgpack_for_partial_parse(project_path, tmp_dir) + + tmp_partial_parse_file = tmp_dir / "target" / "partial_parse.msgpack" + + if exists: + assert tmp_partial_parse_file.exists() + else: + assert not tmp_partial_parse_file.exists() + + @patch.dict(os.environ, {"VAR1": "value1", "VAR2": "value2"}) def test_environ_context_manager(): # Define the expected environment variables diff --git a/tests/hooks/__init__.py b/tests/hooks/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/hooks/test_subprocess.py b/tests/hooks/test_subprocess.py new file mode 100644 index 0000000000..601d37b004 --- /dev/null +++ b/tests/hooks/test_subprocess.py @@ -0,0 +1,70 @@ +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import MagicMock, patch +import signal + +import pytest + +from cosmos.hooks.subprocess import FullOutputSubprocessHook + +OS_ENV_KEY = "SUBPROCESS_ENV_TEST" +OS_ENV_VAL = "this-is-from-os-environ" + + +@pytest.mark.parametrize( + "env,expected", + [ + ({"ABC": "123", "AAA": "456"}, {"ABC": "123", "AAA": "456", OS_ENV_KEY: ""}), + ({}, {OS_ENV_KEY: ""}), + (None, {OS_ENV_KEY: OS_ENV_VAL}), + ], + ids=["with env", "empty env", "no env"], +) +def test_env(env, expected): + """ + Test that env variables are exported correctly to the command environment. + When ``env`` is ``None``, ``os.environ`` should be passed to ``Popen``. + Otherwise, the variables in ``env`` should be available, and ``os.environ`` should not. + """ + hook = FullOutputSubprocessHook() + + def build_cmd(keys, filename): + """ + Produce bash command to echo env vars into filename. + Will always echo the special test var named ``OS_ENV_KEY`` into the file to test whether + ``os.environ`` is passed or not. + """ + return "\n".join(f"echo {k}=${k}>> {filename}" for k in [*keys, OS_ENV_KEY]) + + with TemporaryDirectory() as tmp_dir, patch.dict("os.environ", {OS_ENV_KEY: OS_ENV_VAL}): + tmp_file = Path(tmp_dir, "test.txt") + command = build_cmd(env and env.keys() or [], tmp_file.as_posix()) + hook.run_command(command=["bash", "-c", command], env=env) + actual = dict([x.split("=") for x in tmp_file.read_text().splitlines()]) + assert actual == expected + + +def test_subprocess_hook(): + hook = FullOutputSubprocessHook() + result = hook.run_command(command=["bash", "-c", f'echo "foo"']) + assert result.exit_code == 0 + assert result.output == "foo" + assert result.full_output == ["foo"] + + +@patch("os.getpgid", return_value=123) +@patch("os.killpg") +def test_send_sigint(mock_killpg, mock_getpgid): + hook = FullOutputSubprocessHook() + hook.sub_process = MagicMock() + hook.send_sigint() + mock_killpg.assert_called_with(123, signal.SIGINT) + + +@patch("os.getpgid", return_value=123) +@patch("os.killpg") +def test_send_sigterm(mock_killpg, mock_getpgid): + hook = FullOutputSubprocessHook() + hook.sub_process = MagicMock() + hook.send_sigterm() + mock_killpg.assert_called_with(123, signal.SIGTERM) diff --git a/tests/operators/test_local.py b/tests/operators/test_local.py index e57f3287bd..90585cc95a 100644 --- a/tests/operators/test_local.py +++ b/tests/operators/test_local.py @@ -242,6 +242,22 @@ def test_run_operator_dataset_inlets_and_outlets(): assert test_operator.outlets == [] +def test_dbt_base_operator_no_partial_parse() -> None: + + dbt_base_operator = ConcreteDbtLocalBaseOperator( + profile_config=profile_config, + task_id="my-task", + project_dir="my/dir", + partial_parse=False, + ) + + cmd, _ = dbt_base_operator.build_cmd( + Context(execution_date=datetime(2023, 2, 15, 12, 30)), + ) + + assert "--no-partial-parse" in cmd + + @pytest.mark.integration def test_run_test_operator_with_callback(failing_test_dbt_project): on_warning_callback = MagicMock() @@ -519,3 +535,27 @@ def test_dbt_docs_local_operator_with_static_flag(): dbt_cmd_flags=["--static"], ) assert operator.required_files == ["static_index.html"] + + +@patch("cosmos.hooks.subprocess.FullOutputSubprocessHook.send_sigint") +def test_dbt_local_operator_on_kill_sigint(mock_send_sigint) -> None: + + dbt_base_operator = ConcreteDbtLocalBaseOperator( + profile_config=profile_config, task_id="my-task", project_dir="my/dir", cancel_query_on_kill=True + ) + + dbt_base_operator.on_kill() + + mock_send_sigint.assert_called_once() + + +@patch("cosmos.hooks.subprocess.FullOutputSubprocessHook.send_sigterm") +def test_dbt_local_operator_on_kill_sigterm(mock_send_sigterm) -> None: + + dbt_base_operator = ConcreteDbtLocalBaseOperator( + profile_config=profile_config, task_id="my-task", project_dir="my/dir", cancel_query_on_kill=False + ) + + dbt_base_operator.on_kill() + + mock_send_sigterm.assert_called_once() From 67cf3d4dfd76995baf0c3458bcc85b20c8581739 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 19 Feb 2024 15:50:03 -0800 Subject: [PATCH 103/223] =?UTF-8?q?=E2=AC=86=20[pre-commit.ci]=20pre-commi?= =?UTF-8?q?t=20autoupdate=20(#852)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 997575d466..be9cb26429 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -41,20 +41,20 @@ repos: - id: rst-backticks - id: python-check-mock-methods - repo: https://github.com/Lucas-C/pre-commit-hooks - rev: v1.5.4 + rev: v1.5.5 hooks: - id: remove-crlf - id: remove-tabs exclude: ^docs/make.bat$|^docs/Makefile$|^dev/dags/dbt/jaffle_shop/seeds/raw_orders.csv$ - repo: https://github.com/asottile/pyupgrade - rev: v3.15.0 + rev: v3.15.1 hooks: - id: pyupgrade args: - --py37-plus - --keep-runtime-typing - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.2.1 + rev: v0.2.2 hooks: - id: ruff args: From 9338c7b5f93b6af493acc4c555f9da50f359d3da Mon Sep 17 00:00:00 2001 From: Daniel Reeves <31971762+dwreeves@users.noreply.github.com> Date: Tue, 20 Feb 2024 13:50:43 -0500 Subject: [PATCH 104/223] Add dbt docs natively in Airflow via plugin (#737) ## Description This PR adds a plugin (via the Airflow plugins entrypoint) that adds a menu item inside of `Browse` that renders the dbt docs: ![image](https://github.com/astronomer/astronomer-cosmos/assets/31971762/77b5e8d6-ada5-484c-b463-a01352ab61f6) And this is what it looks like. (This example is inside the dev docker compose): image The docs are rendered via an iframe with some additional hacks to make the page render in a user friendly way. I chose an iframe over vendoring the `index.html` in the templates for a few reasons, but mostly to support custom `{% block __overview__ %}` text. However, extracting the text from `index.html` and rendering it in a custom page is certainly an option too. The dbt docs are specified in the Airflow config with the following parameters: ```ini [cosmos] dbt_docs_dir = path/to/docs/here dbt_docs_conn_id = my_conn_id ``` Note that the path can be a link to any of the following: - S3 - Azure Blob Storage - Google Cloud Storage - HTTP/HTTPS - Local storage This is designed to work with the operators that dump the dbt docs, and the documentation changes I added make that clear. Lastly, if docs are not hooked up, a message comes up telling the user that they should set their dbt docs up: image ### Current limitations - Most importantly, **I need help testing the S3 / Azure / GCS integrations.** I _think_ I got them right but I'll need someone to actually try them. - **I also wouldn't mind some help testing the UI on more browsers.** I've tested both Firefox and Chrome. - **The iframe hack is less than ideal; I would preferably want the dbt docs to have a fixed height.** So instead of using the scroll bar of the Airflow UI, use the scroll bar of the dbt docs UI. The issue is basically that I am not an HTML/CSS/JavaScript person. I don't think there is any reason this shouldn't be possible, so I can continue to look into this as the PR is reviewed, or someone else can just do it for me. - I cannot run tests locally (lots of issues, mostly the databricks DAG in `dev/dags/` fails locally), so I actually have no idea whether the test suite works. I was just planning on letting Github Actions take a stab at it. ### API Decisions The core maintainers of the repo should provide some feedback on a few high level API decisions: - **Config variable names:** Let me know if `dbt_docs_dir` and `dbt_docs_conn_id` are appropriate names. Other names could be like, `dbt_docs_path`, or `dbt_docs_dir_conn_id`, or `dbt_docs_path_conn_id`, etc. - **Location in UI:** I entertained two ideas: (a) Adding a menu button called Cosmos with dbt docs underneath. (b) Adding it under browse. Ultimately I decided on option 2. ## Related Issue(s) Closes #571. ## Breaking Change? This PR should not cause any breaking changes. ## Checklist - [x] I have made corresponding changes to the documentation (if required) - [x] I have added tests that prove my fix is effective or that my feature works --------- Co-authored-by: Tatiana Al-Chueyr Co-authored-by: Justin Bandoro <79104794+jbandoro@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- cosmos/plugin/__init__.py | 202 ++++++++++++++++ .../static/iframeResizer.contentWindow.min.js | 9 + cosmos/plugin/static/iframeResizer.min.js | 8 + cosmos/plugin/templates/dbt_docs.html | 15 ++ .../plugin/templates/dbt_docs_not_set_up.html | 9 + dev/dags/dbt/jaffle_shop/.gitignore | 1 + dev/docker-compose.yaml | 1 + .../location_of_dbt_docs_in_airflow.png | Bin 0 -> 48804 bytes docs/configuration/generating-docs.rst | 4 +- docs/configuration/hosting-docs.rst | 127 ++++++++++ docs/configuration/index.rst | 1 + pyproject.toml | 3 + tests/plugin/__init__.py | 0 tests/plugin/test_plugin.py | 223 ++++++++++++++++++ 15 files changed, 603 insertions(+), 2 deletions(-) create mode 100644 cosmos/plugin/__init__.py create mode 100644 cosmos/plugin/static/iframeResizer.contentWindow.min.js create mode 100644 cosmos/plugin/static/iframeResizer.min.js create mode 100644 cosmos/plugin/templates/dbt_docs.html create mode 100644 cosmos/plugin/templates/dbt_docs_not_set_up.html create mode 100644 docs/_static/location_of_dbt_docs_in_airflow.png create mode 100644 docs/configuration/hosting-docs.rst create mode 100644 tests/plugin/__init__.py create mode 100644 tests/plugin/test_plugin.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index be9cb26429..146e07743d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -33,7 +33,7 @@ repos: types: [text] args: - --exclude-file=tests/sample/manifest_model_version.json - - --skip=**/manifest.json + - --skip=**/manifest.json,**.min.js - -L connexion,aci - repo: https://github.com/pre-commit/pygrep-hooks rev: v1.10.0 diff --git a/cosmos/plugin/__init__.py b/cosmos/plugin/__init__.py new file mode 100644 index 0000000000..48061b254b --- /dev/null +++ b/cosmos/plugin/__init__.py @@ -0,0 +1,202 @@ +import os.path as op +from typing import Any, Dict, Optional, Tuple +from urllib.parse import urlsplit + +from airflow.configuration import conf +from airflow.plugins_manager import AirflowPlugin +from airflow.security import permissions +from airflow.www.auth import has_access +from airflow.www.views import AirflowBaseView +from flask import abort, url_for +from flask_appbuilder import AppBuilder, expose + + +def bucket_and_key(path: str) -> Tuple[str, str]: + parsed_url = urlsplit(path) + bucket = parsed_url.netloc + key = parsed_url.path.lstrip("/") + return bucket, key + + +def open_s3_file(conn_id: Optional[str], path: str) -> str: + from airflow.providers.amazon.aws.hooks.s3 import S3Hook + + if conn_id is None: + conn_id = S3Hook.default_conn_name + + hook = S3Hook(aws_conn_id=conn_id) + bucket, key = bucket_and_key(path) + content = hook.read_key(key=key, bucket_name=bucket) + return content # type: ignore[no-any-return] + + +def open_gcs_file(conn_id: Optional[str], path: str) -> str: + from airflow.providers.google.cloud.hooks.gcs import GCSHook + + if conn_id is None: + conn_id = GCSHook.default_conn_name + + hook = GCSHook(gcp_conn_id=conn_id) + bucket, blob = bucket_and_key(path) + content = hook.download(bucket_name=bucket, object_name=blob) + return content.decode("utf-8") # type: ignore[no-any-return] + + +def open_azure_file(conn_id: Optional[str], path: str) -> str: + from airflow.providers.microsoft.azure.hooks.wasb import WasbHook + + if conn_id is None: + conn_id = WasbHook.default_conn_name + + hook = WasbHook(wasb_conn_id=conn_id) + + container, blob = bucket_and_key(path) + content = hook.read_file(container_name=container, blob_name=blob) + return content # type: ignore[no-any-return] + + +def open_http_file(conn_id: Optional[str], path: str) -> str: + from airflow.providers.http.hooks.http import HttpHook + + if conn_id is None: + conn_id = "" + + hook = HttpHook(method="GET", http_conn_id=conn_id) + res = hook.run(endpoint=path) + hook.check_response(res) + return res.text # type: ignore[no-any-return] + + +def open_file(path: str) -> str: + """Retrieve a file from http, https, gs, s3, or wasb.""" + conn_id: Optional[str] = conf.get("cosmos", "dbt_docs_conn_id", fallback=None) + + if path.strip().startswith("s3://"): + return open_s3_file(conn_id=conn_id, path=path) + elif path.strip().startswith("gs://"): + return open_gcs_file(conn_id=conn_id, path=path) + elif path.strip().startswith("wasb://"): + return open_azure_file(conn_id=conn_id, path=path) + elif path.strip().startswith("http://") or path.strip().startswith("https://"): + return open_http_file(conn_id=conn_id, path=path) + else: + with open(path) as f: + content = f.read() + return content # type: ignore[no-any-return] + + +iframe_script = """ + +""" + + +class DbtDocsView(AirflowBaseView): + default_view = "dbt_docs" + route_base = "/cosmos" + template_folder = op.join(op.dirname(__file__), "templates") + static_folder = op.join(op.dirname(__file__), "static") + + def create_blueprint( + self, appbuilder: AppBuilder, endpoint: Optional[str] = None, static_folder: Optional[str] = None + ) -> None: + # Make sure the static folder is not overwritten, as we want to use it. + return super().create_blueprint(appbuilder, endpoint=endpoint, static_folder=self.static_folder) # type: ignore[no-any-return] + + @expose("/dbt_docs") # type: ignore[misc] + @has_access([(permissions.ACTION_CAN_READ, permissions.RESOURCE_WEBSITE)]) + def dbt_docs(self) -> str: + if conf.get("cosmos", "dbt_docs_dir", fallback=None) is None: + return self.render_template("dbt_docs_not_set_up.html") # type: ignore[no-any-return,no-untyped-call] + return self.render_template("dbt_docs.html") # type: ignore[no-any-return,no-untyped-call] + + @expose("/dbt_docs_index.html") # type: ignore[misc] + @has_access([(permissions.ACTION_CAN_READ, permissions.RESOURCE_WEBSITE)]) + def dbt_docs_index(self) -> str: + docs_dir = conf.get("cosmos", "dbt_docs_dir", fallback=None) + if docs_dir is None: + abort(404) + html = open_file(op.join(docs_dir, "index.html")) + # Hack the dbt docs to render properly in an iframe + iframe_resizer_url = url_for(".static", filename="iframeResizer.contentWindow.min.js") + html = html.replace("", f'{iframe_script}', 1) + return html + + @expose("/catalog.json") # type: ignore[misc] + @has_access([(permissions.ACTION_CAN_READ, permissions.RESOURCE_WEBSITE)]) + def catalog(self) -> Tuple[str, int, Dict[str, Any]]: + docs_dir = conf.get("cosmos", "dbt_docs_dir", fallback=None) + if docs_dir is None: + abort(404) + data = open_file(op.join(docs_dir, "catalog.json")) + return data, 200, {"Content-Type": "application/json"} + + @expose("/manifest.json") # type: ignore[misc] + @has_access([(permissions.ACTION_CAN_READ, permissions.RESOURCE_WEBSITE)]) + def manifest(self) -> Tuple[str, int, Dict[str, Any]]: + docs_dir = conf.get("cosmos", "dbt_docs_dir", fallback=None) + if docs_dir is None: + abort(404) + data = open_file(op.join(docs_dir, "manifest.json")) + return data, 200, {"Content-Type": "application/json"} + + +dbt_docs_view = DbtDocsView() + + +class CosmosPlugin(AirflowPlugin): + name = "cosmos" + appbuilder_views = [{"name": "dbt Docs", "category": "Browse", "view": dbt_docs_view}] diff --git a/cosmos/plugin/static/iframeResizer.contentWindow.min.js b/cosmos/plugin/static/iframeResizer.contentWindow.min.js new file mode 100644 index 0000000000..914161c09f --- /dev/null +++ b/cosmos/plugin/static/iframeResizer.contentWindow.min.js @@ -0,0 +1,9 @@ +/*! iFrame Resizer (iframeSizer.contentWindow.min.js) - v4.3.5 - 2023-03-08 + * Desc: Include this file in any page being loaded into an iframe + * to force the iframe to resize to the content size. + * Requires: iframeResizer.min.js on host page. + * Copyright: (c) 2023 David J. Bradshaw - dave@bradshaw.net + * License: MIT + */ +!function(a){if("undefined"!=typeof window){var r=!0,P="",u=0,c="",s=null,D="",d=!1,j={resize:1,click:1},l=128,q=!0,f=1,n="bodyOffset",m=n,H=!0,W="",h={},g=32,B=null,p=!1,v=!1,y="[iFrameSizer]",J=y.length,w="",U={max:1,min:1,bodyScroll:1,documentElementScroll:1},b="child",V=!0,X=window.parent,T="*",E=0,i=!1,Y=null,O=16,S=1,K="scroll",M=K,Q=window,G=function(){x("onMessage function not defined")},Z=function(){},$=function(){},_={height:function(){return x("Custom height calculation function not defined"),document.documentElement.offsetHeight},width:function(){return x("Custom width calculation function not defined"),document.body.scrollWidth}},ee={},te=!1;try{var ne=Object.create({},{passive:{get:function(){te=!0}}});window.addEventListener("test",ae,ne),window.removeEventListener("test",ae,ne)}catch(e){}var oe,o,I,ie,N,A,C={bodyOffset:function(){return document.body.offsetHeight+ye("marginTop")+ye("marginBottom")},offset:function(){return C.bodyOffset()},bodyScroll:function(){return document.body.scrollHeight},custom:function(){return _.height()},documentElementOffset:function(){return document.documentElement.offsetHeight},documentElementScroll:function(){return document.documentElement.scrollHeight},max:function(){return Math.max.apply(null,e(C))},min:function(){return Math.min.apply(null,e(C))},grow:function(){return C.max()},lowestElement:function(){return Math.max(C.bodyOffset()||C.documentElementOffset(),we("bottom",Te()))},taggedElement:function(){return be("bottom","data-iframe-height")}},z={bodyScroll:function(){return document.body.scrollWidth},bodyOffset:function(){return document.body.offsetWidth},custom:function(){return _.width()},documentElementScroll:function(){return document.documentElement.scrollWidth},documentElementOffset:function(){return document.documentElement.offsetWidth},scroll:function(){return Math.max(z.bodyScroll(),z.documentElementScroll())},max:function(){return Math.max.apply(null,e(z))},min:function(){return Math.min.apply(null,e(z))},rightMostElement:function(){return we("right",Te())},taggedElement:function(){return be("right","data-iframe-width")}},re=(oe=Ee,N=null,A=0,function(){var e=Date.now(),t=O-(e-(A=A||e));return o=this,I=arguments,t<=0||Ok[r]["max"+e])throw new Error("Value for min"+e+" can not be greater than max"+e)}}function h(e,n){null===i&&(i=setTimeout(function(){i=null,e()},n))}function e(){"hidden"!==document.visibilityState&&(O("document","Trigger event: Visibility change"),h(function(){b("Tab Visible","resize")},16))}function b(i,t){Object.keys(k).forEach(function(e){var n;k[n=e]&&"parent"===k[n].resizeFrom&&k[n].autoResize&&!k[n].firstRun&&A(i,t,k[e].iframe,e)})}function y(){F(window,"message",w),F(window,"resize",function(){var e;O("window","Trigger event: "+(e="resize")),h(function(){b("Window "+e,"resize")},16)}),F(document,"visibilitychange",e),F(document,"-webkit-visibilitychange",e)}function n(){function t(e,n){if(n){if(!n.tagName)throw new TypeError("Object is not a valid DOM element");if("IFRAME"!==n.tagName.toUpperCase())throw new TypeError("Expected + +{% endblock %} diff --git a/cosmos/plugin/templates/dbt_docs_not_set_up.html b/cosmos/plugin/templates/dbt_docs_not_set_up.html new file mode 100644 index 0000000000..1fcc6ef7f3 --- /dev/null +++ b/cosmos/plugin/templates/dbt_docs_not_set_up.html @@ -0,0 +1,9 @@ +{% extends base_template %} +{% block content %} +

⚠️ Your dbt docs are not set up yet! ⚠️

+ +

+ Read the Astronomer Cosmos docs for information on how to set up dbt docs. +

+ +{% endblock %} diff --git a/dev/dags/dbt/jaffle_shop/.gitignore b/dev/dags/dbt/jaffle_shop/.gitignore index 49f147cb98..45d294b9af 100644 --- a/dev/dags/dbt/jaffle_shop/.gitignore +++ b/dev/dags/dbt/jaffle_shop/.gitignore @@ -2,3 +2,4 @@ target/ dbt_packages/ logs/ +!target/manifest.json diff --git a/dev/docker-compose.yaml b/dev/docker-compose.yaml index 23b012d153..5345f4b134 100644 --- a/dev/docker-compose.yaml +++ b/dev/docker-compose.yaml @@ -10,6 +10,7 @@ x-airflow-common: environment: &airflow-common-env DB_BACKEND: postgres + AIRFLOW__COSMOS__DBT_DOCS_DIR: http://cosmos-docs.s3-website-us-east-1.amazonaws.com/ AIRFLOW__CORE__EXECUTOR: LocalExecutor AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://airflow:pg_password@postgres:5432/airflow AIRFLOW__CORE__FERNET_KEY: '' diff --git a/docs/_static/location_of_dbt_docs_in_airflow.png b/docs/_static/location_of_dbt_docs_in_airflow.png new file mode 100644 index 0000000000000000000000000000000000000000..348a53c8ea2b9f894a8f8070acc6e1a6ff354cb6 GIT binary patch literal 48804 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 literal 0 HcmV?d00001 diff --git a/docs/configuration/generating-docs.rst b/docs/configuration/generating-docs.rst index 6112ebcee6..54ec80fc9e 100644 --- a/docs/configuration/generating-docs.rst +++ b/docs/configuration/generating-docs.rst @@ -5,7 +5,9 @@ Generating Docs dbt allows you to generate static documentation on your models, tables, and more. You can read more about it in the `official dbt documentation `_. For an example of what the docs look like with the ``jaffle_shop`` project, check out `this site `_. -Many users choose to generate and serve these docs on a static website. This is a great way to share your data models with your team and other stakeholders. +After generating the dbt docs, you can host them natively within Airflow via the Cosmos Airflow plugin; see `Hosting Docs `__ for more information. + +Alternatively, many users choose to serve these docs on a separate static website. This is a great way to share your data models with a broad array of stakeholders. Cosmos offers two pre-built ways of generating and uploading dbt docs and a fallback option to run custom code after the docs are generated: diff --git a/docs/configuration/hosting-docs.rst b/docs/configuration/hosting-docs.rst new file mode 100644 index 0000000000..5143a9f67f --- /dev/null +++ b/docs/configuration/hosting-docs.rst @@ -0,0 +1,127 @@ +.. hosting-docs: + +Hosting Docs +============ + +dbt docs can be served directly from the Apache Airflow webserver with the Cosmos Airflow plugin, without requiring the user to set up anything outside of Airflow. This page describes how to host docs in the Airflow webserver directly, although some users may opt to host docs externally. + +Overview +~~~~~~~~ + +The dbt docs are available in the Airflow menu under ``Browse > dbt docs``: + +.. image:: /_static/location_of_dbt_docs_in_airflow.png + :alt: Airflow UI - Location of dbt docs in menu + :align: center + +In order to access the dbt docs, you must specify the following config variables: + +- ``cosmos.dbt_docs_dir``: A path to where the docs are being hosted. +- (Optional) ``cosmos.dbt_docs_conn_id``: A conn ID to use for a cloud storage deployment. If not specified _and_ the URI points to a cloud storage platform, then the default conn ID for the AWS/Azure/GCP hook will be used. + +.. code-block:: cfg + + [cosmos] + dbt_docs_dir = path/to/docs/here + dbt_docs_conn_id = my_conn_id + +or as an environment variable: + +.. code-block:: shell + + AIRFLOW__COSMOS__DBT_DOCS_DIR="path/to/docs/here" + AIRFLOW__COSMOS__DBT_DOCS_CONN_ID="my_conn_id" + +The path can be either a folder in the local file system the webserver is running on, or a URI to a cloud storage platform (S3, GCS, Azure). + +Host from Cloud Storage +~~~~~~~~~~~~~~~~~~~~~~~ + +For typical users, the recommended setup for hosting dbt docs would look like this: + +1. Generate the docs via one of Cosmos' pre-built operators for generating dbt docs (see `Generating Docs `__ for more information) +2. Wherever you dumped the docs, set your ``cosmos.dbt_docs_dir`` to that location. +3. If you want to use a conn ID other than the default connection, set your ``cosmos.dbt_docs_conn_id``. Otherwise, leave this blank. + +AWS S3 Example +^^^^^^^^^^^^^^ + +.. code-block:: cfg + + [cosmos] + dbt_docs_dir = s3://my-bucket/path/to/docs + dbt_docs_conn_id = aws_default + +.. code-block:: shell + + AIRFLOW__COSMOS__DBT_DOCS_DIR="s3://my-bucket/path/to/docs" + AIRFLOW__COSMOS__DBT_DOCS_CONN_ID="aws_default" + +Google Cloud Storage Example +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: cfg + + [cosmos] + dbt_docs_dir = gs://my-bucket/path/to/docs + dbt_docs_conn_id = google_cloud_default + +.. code-block:: shell + + AIRFLOW__COSMOS__DBT_DOCS_DIR="gs://my-bucket/path/to/docs" + AIRFLOW__COSMOS__DBT_DOCS_CONN_ID="google_cloud_default" + +Azure Blob Storage Example +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: cfg + + [cosmos] + dbt_docs_dir = wasb://my-container/path/to/docs + dbt_docs_conn_id = wasb_default + +.. code-block:: shell + + AIRFLOW__COSMOS__DBT_DOCS_DIR="wasb://my-container/path/to/docs" + AIRFLOW__COSMOS__DBT_DOCS_CONN_ID="wasb_default" + +Host from Local Storage +~~~~~~~~~~~~~~~~~~~~~~~ + +By default, Cosmos will not generate docs on the fly. Local storage only works if you are pre-compiling your dbt project before deployment. + +If your Airflow deployment process involves running ``dbt compile``, you will also want to add ``dbt docs generate`` to your deployment process as well to generate all the artifacts necessary to run the dbt docs from local storage. + +By default, dbt docs are generated in the ``target`` folder; so that will also be your docs folder by default. + +For example, if your dbt project directory is ``/usr/local/airflow/dags/my_dbt_project``, then by default your dbt docs directory will be ``/usr/local/airflow/dags/my_dbt_project/target``: + +.. code-block:: cfg + + [cosmos] + dbt_docs_dir = /usr/local/airflow/dags/my_dbt_project/target + +.. code-block:: shell + + AIRFLOW__COSMOS__DBT_DOCS_DIR="/usr/local/airflow/dags/my_dbt_project/target" + +Using docs out of local storage has the downside that some values in the dbt docs can become stale unless the docs are periodically refreshed and redeployed: + +- Counts of the numbers of rows. +- The compiled SQL for incremental models before and after the first run. + +Host from HTTP/HTTPS +~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: cfg + + [cosmos] + dbt_docs_dir = https://my-site.com/path/to/docs + +.. code-block:: shell + + AIRFLOW__COSMOS__DBT_DOCS_DIR="https://my-site.com/path/to/docs" + + +You do not need to set a ``dbt_docs_conn_id`` when using HTTP/HTTPS. +If you do set the ``dbt_docs_conn_id``, then the ``HttpHook`` will be used. diff --git a/docs/configuration/index.rst b/docs/configuration/index.rst index 8c282be030..919ed9b1e5 100644 --- a/docs/configuration/index.rst +++ b/docs/configuration/index.rst @@ -16,6 +16,7 @@ Cosmos offers a number of configuration options to customize its behavior. For m Parsing Methods Configuring Lineage Generating Docs + Hosting Docs Scheduling Testing Behavior Selecting & Excluding diff --git a/pyproject.toml b/pyproject.toml index 522431da78..7758f9669e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,6 +95,9 @@ azure-container-instance = [ [project.entry-points.cosmos] provider_info = "cosmos:get_provider_info" +[project.entry-points."airflow.plugins"] +cosmos = "cosmos.plugin:CosmosPlugin" + [project.urls] Homepage = "https://github.com/astronomer/astronomer-cosmos" Documentation = "https://astronomer.github.io/astronomer-cosmos" diff --git a/tests/plugin/__init__.py b/tests/plugin/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/plugin/test_plugin.py b/tests/plugin/test_plugin.py new file mode 100644 index 0000000000..df33ae13ac --- /dev/null +++ b/tests/plugin/test_plugin.py @@ -0,0 +1,223 @@ +# dbt-core relies on Jinja2>3, whereas Flask<2 relies on an incompatible version of Jinja2. +# +# This discrepancy causes the automated integration tests to fail, as dbt-core is installed in the same +# environment as apache-airflow. +# +# We can get around this by patching the jinja2 namespace to include the deprecated objects: +try: + import flask # noqa: F401 +except ImportError: + import markupsafe + import jinja2 + + jinja2.Markup = markupsafe.Markup + jinja2.escape = markupsafe.escape + +from unittest.mock import mock_open, patch, MagicMock, PropertyMock + +import sys +import pytest +from airflow.configuration import conf +from airflow.exceptions import AirflowConfigException +from airflow.utils.db import initdb, resetdb +from airflow.www.app import cached_app +from airflow.www.extensions.init_appbuilder import AirflowAppBuilder +from flask.testing import FlaskClient + +import cosmos.plugin + +from cosmos.plugin import ( + dbt_docs_view, + iframe_script, + open_gcs_file, + open_azure_file, + open_http_file, + open_s3_file, + open_file, +) + + +original_conf_get = conf.get + + +def _get_text_from_response(response) -> str: + # Airflow < 2.4 uses an old version of Werkzeug that does not have Response.text. + if not hasattr(response, "text"): + return response.get_data(as_text=True) + else: + return response.text + + +@pytest.fixture(scope="module") +def app() -> FlaskClient: + initdb() + + app = cached_app(testing=True) + appbuilder: AirflowAppBuilder = app.extensions["appbuilder"] + + appbuilder.sm.check_authorization = lambda *args, **kwargs: True + + if dbt_docs_view not in appbuilder.baseviews: + appbuilder._check_and_init(dbt_docs_view) + appbuilder.register_blueprint(dbt_docs_view) + + yield app.test_client() + + resetdb(skip_init=True) + + +def test_dbt_docs(monkeypatch, app): + def conf_get(section, key, *args, **kwargs): + if section == "cosmos" and key == "dbt_docs_dir": + return "path/to/docs/dir" + else: + return original_conf_get(section, key, *args, **kwargs) + + monkeypatch.setattr(conf, "get", conf_get) + + response = app.get("/cosmos/dbt_docs") + + assert response.status_code == 200 + assert " Date: Wed, 21 Feb 2024 14:18:05 -0800 Subject: [PATCH 105/223] Fix failing test_created_pod for `apache-airflow-providers-cncf-kubernetes` after v8.0.0 update (#854) Instead of comparing the full k8s pod as a dictionary, which isn't necessary and was resulting in the failing unit test described in #853, the test was updated to compare the metadata and container fields that are relevant to cosmos. Closes #853 --- tests/operators/test_kubernetes.py | 134 ++++++----------------------- 1 file changed, 27 insertions(+), 107 deletions(-) diff --git a/tests/operators/test_kubernetes.py b/tests/operators/test_kubernetes.py index e267b9b05f..19a6c7aeb5 100644 --- a/tests/operators/test_kubernetes.py +++ b/tests/operators/test_kubernetes.py @@ -255,117 +255,37 @@ def cleanup(pod: str, remote_pod: str): def test_created_pod(): - ls_kwargs = {"env_vars": {"FOO": "BAR"}} + ls_kwargs = {"env_vars": {"FOO": "BAR"}, "namespace": "foo"} ls_kwargs.update(base_kwargs) ls_operator = DbtLSKubernetesOperator(**ls_kwargs) ls_operator.hook = MagicMock() ls_operator.hook.is_in_cluster = False - ls_operator.hook._get_namespace.return_value.to_dict.return_value = "foo" ls_operator.build_kube_args(context={}, cmd_flags=MagicMock()) pod_obj = ls_operator.build_pod_request_obj() - expected_result = { - "api_version": "v1", - "kind": "Pod", - "metadata": { - "annotations": {}, - "cluster_name": None, - "creation_timestamp": None, - "deletion_grace_period_seconds": None, - "deletion_timestamp": None, - "finalizers": None, - "generate_name": None, - "generation": None, - "labels": { - "airflow_kpo_in_cluster": "False", - "airflow_version": pod_obj.metadata.labels["airflow_version"], - }, - "managed_fields": None, - "name": pod_obj.metadata.name, - "owner_references": None, - "resource_version": None, - "self_link": None, - "uid": None, - }, - "spec": { - "active_deadline_seconds": None, - "affinity": {}, - "automount_service_account_token": None, - "containers": [ - { - "args": [ - "dbt", - "ls", - "--vars", - "end_time: '{{ " - "data_interval_end.strftime(''%Y%m%d%H%M%S'') " - "}}'\n" - "start_time: '{{ " - "data_interval_start.strftime(''%Y%m%d%H%M%S'') " - "}}'\n", - "--no-version-check", - "--project-dir", - "my/dir", - ], - "command": [], - "env": [{"name": "FOO", "value": "BAR", "value_from": None}], - "env_from": [], - "image": "my_image", - "image_pull_policy": None, - "lifecycle": None, - "liveness_probe": None, - "name": "base", - "ports": [], - "readiness_probe": None, - "resources": None, - "security_context": None, - "startup_probe": None, - "stdin": None, - "stdin_once": None, - "termination_message_path": None, - # "termination_message_policy": None, - "tty": None, - "volume_devices": None, - "volume_mounts": [], - "working_dir": None, - } - ], - "dns_config": None, - "dns_policy": None, - "enable_service_links": None, - "ephemeral_containers": None, - "host_aliases": None, - "host_ipc": None, - "host_network": False, - "host_pid": None, - "hostname": None, - "image_pull_secrets": [], - "init_containers": [], - "node_name": None, - "node_selector": {}, - "os": None, - "overhead": None, - "preemption_policy": None, - "priority": None, - "priority_class_name": None, - "readiness_gates": None, - "restart_policy": "Never", - "runtime_class_name": None, - "scheduler_name": None, - "security_context": {}, - "service_account": None, - "service_account_name": None, - "set_hostname_as_fqdn": None, - "share_process_namespace": None, - "subdomain": None, - "termination_grace_period_seconds": None, - "tolerations": [], - "topology_spread_constraints": None, - "volumes": [], - }, - "status": None, - } - computed_result = pod_obj.to_dict() - computed_result["spec"]["containers"][0].pop("termination_message_policy") - computed_result["metadata"].pop("namespace") - assert computed_result == expected_result + metadata = pod_obj.metadata + assert metadata.labels["airflow_kpo_in_cluster"] == "False" + assert metadata.namespace == "foo" + + container = pod_obj.spec.containers[0] + assert container.env[0].name == "FOO" + assert container.env[0].value == "BAR" + assert container.env[0].value_from is None + assert container.image == "my_image" + + expected_container_args = [ + "dbt", + "ls", + "--vars", + "end_time: '{{ " + "data_interval_end.strftime(''%Y%m%d%H%M%S'') " + "}}'\n" + "start_time: '{{ " + "data_interval_start.strftime(''%Y%m%d%H%M%S'') " + "}}'\n", + "--no-version-check", + "--project-dir", + "my/dir", + ] + assert container.args == expected_container_args + assert container.command == [] From c0f8aa7f480ce5a0e68f006bf93f00af47312b09 Mon Sep 17 00:00:00 2001 From: Diego de Oliveira Date: Fri, 23 Feb 2024 18:46:14 -0300 Subject: [PATCH 106/223] fix `folder_dir` not showing on logs for `DbtDocsS3LocalOperator` (#856) ## Description DAG logs is not printing `folder_dir` ```python generate_dbt_docs_aws = DbtDocsS3Operator( task_id="generate_dbt_docs_aws", project_dir=f"{AIRFLOW_HOME}/dags/dbt/dbt-project", profile_config=profile_config, env=env_vars, append_env=True, # docs-specific arguments connection_id="aws_default", bucket_name="airflow-data-xxxxxxxx-us-east-2", folder_dir="dags/dbt/dbt-project/target", dag=dbt_cosmos_dag, ) ``` ![image](https://github.com/astronomer/astronomer-cosmos/assets/6994647/f57cba9a-4b87-4c36-b580-1b2ddde1eb78) ## Related Issue(s) None ## Breaking Change? No ## Checklist - [ ] I have made corresponding changes to the documentation (if required) - [ ] I have added tests that prove my fix is effective or that my feature works --- cosmos/operators/local.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cosmos/operators/local.py b/cosmos/operators/local.py index f805b2882f..5b09b553fd 100644 --- a/cosmos/operators/local.py +++ b/cosmos/operators/local.py @@ -574,9 +574,9 @@ def upload_to_cloud_storage(self, project_dir: str) -> None: ) for filename in self.required_files: - logger.info("Uploading %s to %s", filename, f"s3://{self.bucket_name}/{filename}") - key = f"{self.folder_dir}/{filename}" if self.folder_dir else filename + s3_path = f"s3://{self.bucket_name}/{key}" + logger.info("Uploading %s to %s", filename, s3_path) hook.load_file( filename=f"{target_dir}/{filename}", From c0e06afef60be026f97fc822b208b2a82569905d Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Mon, 26 Feb 2024 15:27:03 +0000 Subject: [PATCH 107/223] Improve `dbt ls` parsing resilience to missing tags/config (#859) An Astronomer customer reported an issue when using `LoadMode.DBT_LS` and Cosmos raising the error: ``` E KeyError: 'tags' cosmos/dbt/graph.py:113: KeyError ``` While parsing the DAG in Airflow. It is unclear which of the thousand nodes in their project had this behaviour, but it feels like a good idea to make Cosmos more resilient to this use case regardless. --- cosmos/dbt/graph.py | 8 +++----- tests/dbt/test_graph.py | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/cosmos/dbt/graph.py b/cosmos/dbt/graph.py index 6ebfddcc32..435fbccc7a 100644 --- a/cosmos/dbt/graph.py +++ b/cosmos/dbt/graph.py @@ -85,8 +85,6 @@ def is_freshness_effective(freshness: dict[str, Any]) -> bool: "filter": null } should be considered as null, this function ensures that.""" - if freshness is None: - return False for key, value in freshness.items(): if isinstance(value, dict): if any(subvalue is not None for subvalue in value.values()): @@ -137,9 +135,9 @@ def parse_dbt_ls_output(project_path: Path, ls_stdout: str) -> dict[str, DbtNode resource_type=DbtResourceType(node_dict["resource_type"]), depends_on=node_dict.get("depends_on", {}).get("nodes", []), file_path=project_path / node_dict["original_file_path"], - tags=node_dict["tags"], - config=node_dict["config"], - has_freshness=is_freshness_effective(node_dict.get("freshness")) + tags=node_dict.get("tags", []), + config=node_dict.get("config", {}), + has_freshness=is_freshness_effective(node_dict.get("freshness"), False) if node_dict["resource_type"] == "source" else False, ) diff --git a/tests/dbt/test_graph.py b/tests/dbt/test_graph.py index c27b208410..94951d7bdd 100644 --- a/tests/dbt/test_graph.py +++ b/tests/dbt/test_graph.py @@ -811,6 +811,24 @@ def test_parse_dbt_ls_output(): assert expected_nodes == nodes +def test_parse_dbt_ls_output_with_json_without_tags_or_config(): + some_ls_stdout = '{"resource_type": "model", "name": "some-name", "original_file_path": "some-file-path.sql", "unique_id": "some-unique-id", "config": {}}' + + expected_nodes = { + "some-unique-id": DbtNode( + unique_id="some-unique-id", + resource_type=DbtResourceType.MODEL, + file_path=Path("some-project/some-file-path.sql"), + tags=[], + config={}, + depends_on=[], + ), + } + nodes = parse_dbt_ls_output(Path("some-project"), some_ls_stdout) + + assert expected_nodes == nodes + + @patch("cosmos.dbt.graph.Popen") @patch("cosmos.dbt.graph.DbtGraph.update_node_dependency") @patch("cosmos.config.RenderConfig.validate_dbt_command") From 49b759536fa6921fecae9fc86a6a24cbdb5f96bd Mon Sep 17 00:00:00 2001 From: Julian LaNeve Date: Mon, 26 Feb 2024 12:37:21 -0500 Subject: [PATCH 108/223] Fix docs homepage link (#860) ## Description This PR fixes a broken link on the docs homepage. --- docs/index.rst | 2 +- docs/requirements.txt | 1 + pyproject.toml | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/index.rst b/docs/index.rst index 0c7ab506cf..c22de1a7a2 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -58,7 +58,7 @@ This will generate an Airflow DAG that looks like this: Getting Started _______________ -Check out the Quickstart guide on our `docs `_. See more examples at `/dev/dags `_ and at the `cosmos-demo repo `_. +Check out the Quickstart guide on our `docs `_. See more examples at `/dev/dags `_ and at the `cosmos-demo repo `_. Changelog diff --git a/docs/requirements.txt b/docs/requirements.txt index 420d62a599..f681c20093 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -6,3 +6,4 @@ sphinx-autoapi apache-airflow apache-airflow-providers-cncf-kubernetes>=5.1.1 openlineage-airflow +pydantic diff --git a/pyproject.toml b/pyproject.toml index 7758f9669e..8b7d57aa52 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -171,6 +171,7 @@ dependencies = [ "sphinx-autoapi", "openlineage-airflow", "apache-airflow-providers-cncf-kubernetes>=5.1.1", + "pydantic>=1.10.0", ] [tool.hatch.envs.docs.scripts] From de22c926696d959dcbb606725b8cb9780c080177 Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Tue, 27 Feb 2024 13:17:52 +0000 Subject: [PATCH 109/223] Extend `DatabricksTokenProfileMapping` test to include session properties (#858) An Astronomer customer reported an issue setting session properties in the `DatabricksTokenProfileMapping` class. We are extending the tests to include an example of the current behaviour. --- tests/profiles/databricks/test_dbr_token.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/profiles/databricks/test_dbr_token.py b/tests/profiles/databricks/test_dbr_token.py index 3fd36a784c..0a1701af25 100644 --- a/tests/profiles/databricks/test_dbr_token.py +++ b/tests/profiles/databricks/test_dbr_token.py @@ -95,11 +95,15 @@ def test_profile_args( profile_args={ "schema": "my_schema", "catalog": "my_catalog", + "session_properties": {"legacy_time_parser_policy": "corrected"}, + "threads": 4, }, ) assert profile_mapping.profile_args == { "schema": "my_schema", "catalog": "my_catalog", + "session_properties": {"legacy_time_parser_policy": "corrected"}, + "threads": 4, } assert profile_mapping.profile == { @@ -109,7 +113,23 @@ def test_profile_args( "http_path": mock_databricks_conn.extra_dejson.get("http_path"), "schema": "my_schema", "catalog": "my_catalog", + "threads": 4, + "session_properties": {"legacy_time_parser_policy": "corrected"}, } + expected_profile_yml = """example: + outputs: + cosmos_target: + catalog: my_catalog + host: my_host + http_path: my_http_path + schema: my_schema + session_properties: + legacy_time_parser_policy: corrected + threads: 4 + token: '{{ env_var(''COSMOS_CONN_DATABRICKS_TOKEN'') }}' + type: databricks + target: cosmos_target\n""" + assert profile_mapping.get_profile_file_contents("example") == expected_profile_yml def test_profile_args_overrides( From a8246ac46623b3c4ac210bfc2802e9836431d6d8 Mon Sep 17 00:00:00 2001 From: Justin Bandoro <79104794+jbandoro@users.noreply.github.com> Date: Tue, 27 Feb 2024 10:01:59 -0800 Subject: [PATCH 110/223] Add support for `InvocationMode.DBT_RUNNER` for local execution mode (#850) ## Description This PR adds `dbtRunner` programmatic invocation for `ExecutionMode.LOCAL`. I decided to not make a new execution mode for each (e.g. `ExecutionMode.LOCAL_DBT_RUNNER`) and all of the child operators but instead added an additional config `ExecutionConfig.invocation_mode` where `InvocationMode.DBT_RUNNER` could be specified. This is so that users who are already using local execution mode could use dbt runner and see performance improvements. With the `dbtRunnerResult` it makes it easy to know whether the dbt run was successful and logs do not need to be parsed but are still logged in the operator: ![image](https://github.com/astronomer/astronomer-cosmos/assets/79104794/76a4cf82-f0f2-4133-8d68-a0a6a145b1d8) ## Performance Testing After #827 was added, I modified it slightly to use postgres adapter instead of sqlite because the latest dbt-core support for sqlite is 1.4 when programmatic invocation requires >=1.5.0. I got the following results comparing subprocess to dbt runner for 10 models: 1. `InvocationMode.SUBPROCESS`: ```shell Ran 10 models in 23.77661895751953 seconds NUM_MODELS=10 TIME=23.77661895751953 ``` 2. `InvocationMode.DBT_RUNNER`: ```shell Ran 10 models in 8.390100002288818 seconds NUM_MODELS=10 TIME=8.390100002288818 ``` So using `InvocationMode.DBT_RUNNER` is almost 3x faster, and can speed up dag runs if there are a lot of models that execute relatively quickly since there seems to be a 1-2s speed up per task. One thing I found while working on this is that a [manifest](https://docs.getdbt.com/reference/programmatic-invocations#reusing-objects) is stored in the result if you parse a project with the runner, and can be reused in subsequent commands to avoid reparsing. This could be a useful way for caching the manifest if we use dbt runner for dbt ls parsing and could speed up the initial render as well. I thought at first it would be easy to have this also work for virtualenv execution, since I at first thought the entire `execute` method was run in the virtualenv, which is not the case since the virtualenv operator creates a virtualenv and then passes the executable path to a subprocess. It may be possible to have this work for virtualenv and would be better suited for a follow-up PR. ## Related Issue(s) closes #717 ## Breaking Change? None ## Checklist - [x] I have made corresponding changes to the documentation (if required) - [x] I have added tests that prove my fix is effective or that my feature works - added unit tests and integration tests. --- .github/workflows/test.yml | 22 ++- cosmos/config.py | 13 +- cosmos/constants.py | 9 + cosmos/converter.py | 2 + cosmos/dbt/parser/output.py | 69 +++++-- cosmos/dbt/project.py | 13 ++ cosmos/operators/local.py | 149 +++++++++++---- cosmos/operators/virtualenv.py | 9 +- dev/dags/basic_cosmos_task_group.py | 3 +- dev/dags/dbt/perf/profiles.yml | 17 +- dev/dags/performance_dag.py | 15 +- docs/configuration/execution-config.rst | 1 + docs/getting_started/execution-modes.rst | 30 +++ scripts/test/performance-setup.sh | 4 +- tests/dbt/parser/test_output.py | 88 ++++++++- tests/dbt/test_project.py | 17 +- tests/operators/test_local.py | 234 +++++++++++++++++++++-- tests/operators/test_virtualenv.py | 16 +- tests/perf/test_performance.py | 22 ++- tests/test_config.py | 19 +- tests/test_converter.py | 33 +++- 21 files changed, 679 insertions(+), 106 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4b214ce119..8f61804aa7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -293,13 +293,25 @@ jobs: PYTHONPATH: /home/runner/work/astronomer-cosmos/astronomer-cosmos/:$PYTHONPATH Run-Performance-Tests: + needs: Authorize runs-on: ubuntu-latest strategy: matrix: python-version: ["3.11"] airflow-version: ["2.7"] num-models: [1, 10, 50, 100] - + services: + postgres: + image: postgres + env: + POSTGRES_PASSWORD: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 steps: - uses: actions/checkout@v3 with: @@ -335,8 +347,14 @@ jobs: AIRFLOW_CONN_AIRFLOW_DB: postgres://postgres:postgres@0.0.0.0:5432/postgres AIRFLOW__CORE__DAGBAG_IMPORT_TIMEOUT: 90.0 PYTHONPATH: /home/runner/work/astronomer-cosmos/astronomer-cosmos/:$PYTHONPATH + COSMOS_CONN_POSTGRES_PASSWORD: ${{ secrets.COSMOS_CONN_POSTGRES_PASSWORD }} + POSTGRES_HOST: localhost + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + POSTGRES_SCHEMA: public + POSTGRES_PORT: 5432 MODEL_COUNT: ${{ matrix.num-models }} - env: AIRFLOW_HOME: /home/runner/work/astronomer-cosmos/astronomer-cosmos/ AIRFLOW_CONN_AIRFLOW_DB: postgres://postgres:postgres@0.0.0.0:5432/postgres diff --git a/cosmos/config.py b/cosmos/config.py index 52763536f3..9dfb672bed 100644 --- a/cosmos/config.py +++ b/cosmos/config.py @@ -10,7 +10,14 @@ import warnings from typing import Any, Iterator, Callable -from cosmos.constants import DbtResourceType, TestBehavior, ExecutionMode, LoadMode, TestIndirectSelection +from cosmos.constants import ( + DbtResourceType, + TestBehavior, + ExecutionMode, + LoadMode, + TestIndirectSelection, + InvocationMode, +) from cosmos.dbt.executable import get_system_dbt from cosmos.exceptions import CosmosValueError from cosmos.log import get_logger @@ -295,12 +302,14 @@ class ExecutionConfig: Contains configuration about how to execute dbt. :param execution_mode: The execution mode for dbt. Defaults to local + :param invocation_mode: The invocation mode for the dbt command. This is only configurable for ExecutionMode.LOCAL. :param test_indirect_selection: The mode to configure the test behavior when performing indirect selection. :param dbt_executable_path: The path to the dbt executable for runtime execution. Defaults to dbt if available on the path. :param dbt_project_path: Configures the DBT project location accessible at runtime for dag execution. This is the project path in a docker container for ExecutionMode.DOCKER or ExecutionMode.KUBERNETES. Mutually Exclusive with ProjectConfig.dbt_project_path """ execution_mode: ExecutionMode = ExecutionMode.LOCAL + invocation_mode: InvocationMode | None = None test_indirect_selection: TestIndirectSelection = TestIndirectSelection.EAGER dbt_executable_path: str | Path = field(default_factory=get_system_dbt) @@ -308,4 +317,6 @@ class ExecutionConfig: project_path: Path | None = field(init=False) def __post_init__(self, dbt_project_path: str | Path | None) -> None: + if self.invocation_mode and self.execution_mode != ExecutionMode.LOCAL: + raise CosmosValueError("ExecutionConfig.invocation_mode is only configurable for ExecutionMode.LOCAL.") self.project_path = Path(dbt_project_path) if dbt_project_path else None diff --git a/cosmos/constants.py b/cosmos/constants.py index b5a1f3daa1..e8b9cff1dd 100644 --- a/cosmos/constants.py +++ b/cosmos/constants.py @@ -54,6 +54,15 @@ class ExecutionMode(Enum): AZURE_CONTAINER_INSTANCE = "azure_container_instance" +class InvocationMode(Enum): + """ + How the dbt command should be invoked. + """ + + SUBPROCESS = "subprocess" + DBT_RUNNER = "dbt_runner" + + class TestIndirectSelection(Enum): """ Modes to configure the test behavior when performing indirect selection. diff --git a/cosmos/converter.py b/cosmos/converter.py index 1bd227a42f..bafe094e87 100644 --- a/cosmos/converter.py +++ b/cosmos/converter.py @@ -254,6 +254,8 @@ def __init__( } if execution_config.dbt_executable_path: task_args["dbt_executable_path"] = execution_config.dbt_executable_path + if execution_config.invocation_mode: + task_args["invocation_mode"] = execution_config.invocation_mode validate_arguments( render_config.select, diff --git a/cosmos/dbt/parser/output.py b/cosmos/dbt/parser/output.py index 791c4b6057..3690a8f609 100644 --- a/cosmos/dbt/parser/output.py +++ b/cosmos/dbt/parser/output.py @@ -1,33 +1,53 @@ +from __future__ import annotations + import logging import re -from typing import List, Tuple +from typing import List, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from dbt.cli.main import dbtRunnerResult from cosmos.hooks.subprocess import FullOutputSubprocessResult -def parse_output(result: FullOutputSubprocessResult, keyword: str) -> int: +DBT_NO_TESTS_MSG = "Nothing to do" +DBT_WARN_MSG = "WARN" + + +def parse_number_of_warnings_subprocess(result: FullOutputSubprocessResult) -> int: """ - Parses the dbt test output message and returns the number of errors or warnings. + Parses the dbt test output message and returns the number of warnings. :param result: String containing the output to be parsed. - :param keyword: String representing the keyword to search for in the output (WARN, ERROR). :return: An integer value associated with the keyword, or 0 if parsing fails. Usage: ----- output_str = "Done. PASS=15 WARN=1 ERROR=0 SKIP=0 TOTAL=16" - keyword = "WARN" - num_warns = parse_output(output_str, keyword) + num_warns = parse_output(output_str) print(num_warns) # Output: 1 """ output = result.output - try: - num = int(output.split(f"{keyword}=")[1].split()[0]) - except ValueError: - logging.error( - f"Could not parse number of {keyword}s. Check your dbt/airflow version or if --quiet is not being used" - ) + num = 0 + if DBT_NO_TESTS_MSG not in result.output and DBT_WARN_MSG in result.output: + try: + num = int(output.split(f"{DBT_WARN_MSG}=")[1].split()[0]) + except ValueError: + logging.error( + f"Could not parse number of {DBT_WARN_MSG}s. Check your dbt/airflow version or if --quiet is not being used" + ) + return num + + +def parse_number_of_warnings_dbt_runner(result: dbtRunnerResult) -> int: + """Parses a dbt runner result and returns the number of warnings found. This only works for dbtRunnerResult + from invoking dbt build, compile, run, seed, snapshot, test, or run-operation. + """ + num = 0 + for run_result in result.result.results: # type: ignore + if run_result.status == "warn": + num += 1 return num @@ -67,3 +87,28 @@ def clean_line(line: str) -> str: test_results.append(test_result) return test_names, test_results + + +def extract_dbt_runner_issues( + result: dbtRunnerResult, status_levels: list[str] = ["warn"] +) -> Tuple[List[str], List[str]]: + """ + Extracts messages from the dbt runner result and returns them as a formatted string. + + This function iterates over dbtRunnerResult messages in dbt run. It extracts results that match the + status levels provided and appends them to a list of issues. + + :param result: dbtRunnerResult object containing the output to be parsed. + :param status_levels: List of strings, where each string is a result status level. Default is ["warn"]. + :return: two lists of strings, the first one containing the node names and the second one + containing the node result message. + """ + node_names = [] + node_results = [] + + for node_result in result.result.results: # type: ignore + if node_result.status in status_levels: + node_names.append(str(node_result.node.name)) + node_results.append(str(node_result.message)) + + return node_names, node_results diff --git a/cosmos/dbt/project.py b/cosmos/dbt/project.py index 889987b6de..144a1f6dfa 100644 --- a/cosmos/dbt/project.py +++ b/cosmos/dbt/project.py @@ -44,3 +44,16 @@ def environ(env_vars: dict[str, str]) -> Generator[None, None, None]: del os.environ[key] else: os.environ[key] = value + + +@contextmanager +def change_working_directory(path: str) -> Generator[None, None, None]: + """Temporarily changes the working directory to the given path, and then restores + back to the previous value on exit. + """ + previous_cwd = os.getcwd() + os.chdir(path) + try: + yield + finally: + os.chdir(previous_cwd) diff --git a/cosmos/operators/local.py b/cosmos/operators/local.py index 5b09b553fd..684d59c7a3 100644 --- a/cosmos/operators/local.py +++ b/cosmos/operators/local.py @@ -18,6 +18,7 @@ from airflow.models.taskinstance import TaskInstance from airflow.utils.context import Context from airflow.utils.session import NEW_SESSION, create_session, provide_session +from cosmos.constants import InvocationMode try: from openlineage.common.provider.dbt.local import DbtLocalArtifactProcessor @@ -31,6 +32,7 @@ if TYPE_CHECKING: from airflow.datasets import Dataset # noqa: F811 from openlineage.client.run import RunEvent + from dbt.cli.main import dbtRunner, dbtRunnerResult from sqlalchemy.orm import Session @@ -56,11 +58,14 @@ FullOutputSubprocessHook, FullOutputSubprocessResult, ) -from cosmos.dbt.parser.output import extract_log_issues, parse_output -from cosmos.dbt.project import create_symlinks, copy_msgpack_for_partial_parse +from cosmos.dbt.parser.output import ( + extract_dbt_runner_issues, + extract_log_issues, + parse_number_of_warnings_dbt_runner, + parse_number_of_warnings_subprocess, +) +from cosmos.dbt.project import create_symlinks, copy_msgpack_for_partial_parse, environ, change_working_directory -DBT_NO_TESTS_MSG = "Nothing to do" -DBT_WARN_MSG = "WARN" logger = get_logger(__name__) @@ -116,6 +121,7 @@ class DbtLocalBaseOperator(AbstractDbtBaseOperator): def __init__( self, profile_config: ProfileConfig, + invocation_mode: InvocationMode | None = None, install_deps: bool = False, callback: Callable[[str], None] | None = None, should_store_compiled_sql: bool = True, @@ -127,6 +133,12 @@ def __init__( self.compiled_sql = "" self.should_store_compiled_sql = should_store_compiled_sql self.openlineage_events_completes: list[RunEvent] = [] + self.invocation_mode = invocation_mode + self.invoke_dbt: Callable[..., FullOutputSubprocessResult | dbtRunnerResult] + self.handle_exception: Callable[..., None] + self._dbt_runner: dbtRunner | None = None + if self.invocation_mode: + self._set_invocation_methods() kwargs.pop("full_refresh", None) # usage of this param should be implemented in child classes super().__init__(**kwargs) @@ -135,7 +147,31 @@ def subprocess_hook(self) -> FullOutputSubprocessHook: """Returns hook for running the bash command.""" return FullOutputSubprocessHook() - def exception_handling(self, result: FullOutputSubprocessResult) -> None: + def _set_invocation_methods(self) -> None: + """Sets the associated run and exception handling methods based on the invocation mode.""" + if self.invocation_mode == InvocationMode.SUBPROCESS: + self.invoke_dbt = self.run_subprocess + self.handle_exception = self.handle_exception_subprocess + elif self.invocation_mode == InvocationMode.DBT_RUNNER: + self.invoke_dbt = self.run_dbt_runner + self.handle_exception = self.handle_exception_dbt_runner + + def _discover_invocation_mode(self) -> None: + """Discovers the invocation mode based on the availability of dbtRunner for import. If dbtRunner is available, it will + be used since it is faster than subprocess. If dbtRunner is not available, it will fall back to subprocess. + This method is called at runtime to work in the environment where the operator is running. + """ + try: + from dbt.cli.main import dbtRunner + except ImportError: + self.invocation_mode = InvocationMode.SUBPROCESS + logger.info("Could not import dbtRunner. Falling back to subprocess for invoking dbt.") + else: + self.invocation_mode = InvocationMode.DBT_RUNNER + logger.info("dbtRunner is available. Using dbtRunner for invoking dbt.") + self._set_invocation_methods() + + def handle_exception_subprocess(self, result: FullOutputSubprocessResult) -> None: if self.skip_exit_code is not None and result.exit_code == self.skip_exit_code: raise AirflowSkipException(f"dbt command returned exit code {self.skip_exit_code}. Skipping.") elif result.exit_code != 0: @@ -144,6 +180,16 @@ def exception_handling(self, result: FullOutputSubprocessResult) -> None: *result.full_output, ) + def handle_exception_dbt_runner(self, result: dbtRunnerResult) -> None: + """dbtRunnerResult has an attribute `success` that is False if the command failed.""" + if not result.success: + if result.exception: + raise AirflowException(f"dbt invocation did not complete with unhandled error: {result.exception}") + else: + node_names, node_results = extract_dbt_runner_issues(result, ["error", "fail", "runtime error"]) + error_message = "\n".join([f"{name}: {result}" for name, result in zip(node_names, node_results)]) + raise AirflowException(f"dbt invocation completed with errors: {error_message}") + @provide_session def store_compiled_sql(self, tmp_project_dir: str, context: Context, session: Session = NEW_SESSION) -> None: """ @@ -191,26 +237,58 @@ def store_compiled_sql(self, tmp_project_dir: str, context: Context, session: Se else: logger.info("Warning: ti is of type TaskInstancePydantic. Cannot update template_fields.") - def run_subprocess(self, *args: Any, **kwargs: Any) -> FullOutputSubprocessResult: - subprocess_result: FullOutputSubprocessResult = self.subprocess_hook.run_command(*args, **kwargs) + def run_subprocess(self, command: list[str], env: dict[str, str], cwd: str) -> FullOutputSubprocessResult: + logger.info("Trying to run the command:\n %s\nFrom %s", command, cwd) + subprocess_result: FullOutputSubprocessResult = self.subprocess_hook.run_command( + command=command, + env=env, + cwd=cwd, + output_encoding=self.output_encoding, + ) + logger.info(subprocess_result.output) return subprocess_result + def run_dbt_runner(self, command: list[str], env: dict[str, str], cwd: str) -> dbtRunnerResult: + """Invokes the dbt command programmatically.""" + try: + from dbt.cli.main import dbtRunner + except ImportError: + raise ImportError( + "Could not import dbt core. Ensure that dbt-core >= v1.5 is installed and available in the environment where the operator is running." + ) + + if self._dbt_runner is None: + self._dbt_runner = dbtRunner() + + # Exclude the dbt executable path from the command + cli_args = command[1:] + + logger.info("Trying to run dbtRunner with:\n %s\n in %s", cli_args, cwd) + + with change_working_directory(cwd), environ(env): + result = self._dbt_runner.invoke(cli_args) + + return result + def run_command( self, cmd: list[str], env: dict[str, str | bytes | os.PathLike[Any]], context: Context, - ) -> FullOutputSubprocessResult: + ) -> FullOutputSubprocessResult | dbtRunnerResult: """ Copies the dbt project to a temporary directory and runs the command. """ + if not self.invocation_mode: + self._discover_invocation_mode() + with tempfile.TemporaryDirectory() as tmp_project_dir: logger.info( "Cloning project to writable temp directory %s from %s", tmp_project_dir, self.project_dir, ) - + env = {k: str(v) for k, v in env.items()} create_symlinks(Path(self.project_dir), Path(tmp_project_dir), self.install_deps) if self.partial_parse: @@ -232,21 +310,18 @@ def run_command( if self.install_deps: deps_command = [self.dbt_executable_path, "deps"] deps_command.extend(flags) - self.run_subprocess( + self.invoke_dbt( command=deps_command, env=env, - output_encoding=self.output_encoding, cwd=tmp_project_dir, ) full_cmd = cmd + flags - logger.info("Trying to run the command:\n %s\nFrom %s", full_cmd, tmp_project_dir) logger.info("Using environment variables keys: %s", env.keys()) - result = self.run_subprocess( + result = self.invoke_dbt( command=full_cmd, env=env, - output_encoding=self.output_encoding, cwd=tmp_project_dir, ) if is_openlineage_available: @@ -263,7 +338,7 @@ def run_command( self.register_dataset(inlets, outlets) self.store_compiled_sql(tmp_project_dir, context) - self.exception_handling(result) + self.handle_exception(result) if self.callback: self.callback(tmp_project_dir) @@ -373,18 +448,20 @@ def get_openlineage_facets_on_complete(self, task_instance: TaskInstance) -> Ope job_facets=job_facets, ) - def build_and_run_cmd(self, context: Context, cmd_flags: list[str] | None = None) -> FullOutputSubprocessResult: + def build_and_run_cmd( + self, context: Context, cmd_flags: list[str] | None = None + ) -> FullOutputSubprocessResult | dbtRunnerResult: dbt_cmd, env = self.build_cmd(context=context, cmd_flags=cmd_flags) dbt_cmd = dbt_cmd or [] result = self.run_command(cmd=dbt_cmd, env=env, context=context) - logger.info(result.output) return result def on_kill(self) -> None: - if self.cancel_query_on_kill: - self.subprocess_hook.send_sigint() - else: - self.subprocess_hook.send_sigterm() + if self.invocation_mode == InvocationMode.SUBPROCESS: + if self.cancel_query_on_kill: + self.subprocess_hook.send_sigint() + else: + self.subprocess_hook.send_sigterm() class DbtBuildLocalOperator(DbtBuildMixin, DbtLocalBaseOperator): @@ -435,8 +512,10 @@ def __init__( ) -> None: super().__init__(**kwargs) self.on_warning_callback = on_warning_callback + self.extract_issues: Callable[..., tuple[list[str], list[str]]] + self.parse_number_of_warnings: Callable[..., int] - def _handle_warnings(self, result: FullOutputSubprocessResult, context: Context) -> None: + def _handle_warnings(self, result: FullOutputSubprocessResult | dbtRunnerResult, context: Context) -> None: """ Handles warnings by extracting log issues, creating additional context, and calling the on_warning_callback with the updated context. @@ -444,7 +523,7 @@ def _handle_warnings(self, result: FullOutputSubprocessResult, context: Context) :param result: The result object from the build and run command. :param context: The original airflow context in which the build and run command was executed. """ - test_names, test_results = extract_log_issues(result.full_output) + test_names, test_results = self.extract_issues(result) warning_context = dict(context) warning_context["test_names"] = test_names @@ -452,19 +531,21 @@ def _handle_warnings(self, result: FullOutputSubprocessResult, context: Context) self.on_warning_callback and self.on_warning_callback(warning_context) + def _set_test_result_parsing_methods(self) -> None: + """Sets the extract_issues and parse_number_of_warnings methods based on the invocation mode.""" + if self.invocation_mode == InvocationMode.SUBPROCESS: + self.extract_issues = lambda result: extract_log_issues(result.full_output) + self.parse_number_of_warnings = parse_number_of_warnings_subprocess + elif self.invocation_mode == InvocationMode.DBT_RUNNER: + self.extract_issues = extract_dbt_runner_issues + self.parse_number_of_warnings = parse_number_of_warnings_dbt_runner + def execute(self, context: Context) -> None: result = self.build_and_run_cmd(context=context, cmd_flags=self.add_cmd_flags()) - should_trigger_callback = all( - [ - self.on_warning_callback, - DBT_NO_TESTS_MSG not in result.output, - DBT_WARN_MSG in result.output, - ] - ) - if should_trigger_callback: - warnings = parse_output(result, "WARN") - if warnings > 0: - self._handle_warnings(result, context) + self._set_test_result_parsing_methods() + number_of_warnings = self.parse_number_of_warnings(result) # type: ignore + if self.on_warning_callback and number_of_warnings > 0: + self._handle_warnings(result, context) class DbtRunOperationLocalOperator(DbtRunOperationMixin, DbtLocalBaseOperator): diff --git a/cosmos/operators/virtualenv.py b/cosmos/operators/virtualenv.py index 6dda4dbac9..c6322ba326 100644 --- a/cosmos/operators/virtualenv.py +++ b/cosmos/operators/virtualenv.py @@ -86,11 +86,16 @@ def venv_dbt_path( self.log.info("Using dbt version %s available at %s", dbt_version, dbt_binary) return str(dbt_binary) - def run_subprocess(self, *args: Any, command: list[str], **kwargs: Any) -> FullOutputSubprocessResult: + def run_subprocess(self, command: list[str], env: dict[str, str], cwd: str) -> FullOutputSubprocessResult: if self.py_requirements: command[0] = self.venv_dbt_path - subprocess_result: FullOutputSubprocessResult = self.subprocess_hook.run_command(command, *args, **kwargs) + subprocess_result: FullOutputSubprocessResult = self.subprocess_hook.run_command( + command=command, + env=env, + cwd=cwd, + output_encoding=self.output_encoding, + ) return subprocess_result def execute(self, context: Context) -> None: diff --git a/dev/dags/basic_cosmos_task_group.py b/dev/dags/basic_cosmos_task_group.py index 4b6aae71e1..06b24f2918 100644 --- a/dev/dags/basic_cosmos_task_group.py +++ b/dev/dags/basic_cosmos_task_group.py @@ -12,6 +12,7 @@ from cosmos import DbtTaskGroup, ProjectConfig, ProfileConfig, RenderConfig, ExecutionConfig from cosmos.profiles import PostgresUserPasswordProfileMapping +from cosmos.constants import InvocationMode DEFAULT_DBT_ROOT_PATH = Path(__file__).parent / "dbt" DBT_ROOT_PATH = Path(os.getenv("DBT_ROOT_PATH", DEFAULT_DBT_ROOT_PATH)) @@ -25,7 +26,7 @@ ), ) -shared_execution_config = ExecutionConfig() +shared_execution_config = ExecutionConfig(invocation_mode=InvocationMode.DBT_RUNNER) @dag( diff --git a/dev/dags/dbt/perf/profiles.yml b/dev/dags/dbt/perf/profiles.yml index 5b3cf175d5..224f565f4a 100644 --- a/dev/dags/dbt/perf/profiles.yml +++ b/dev/dags/dbt/perf/profiles.yml @@ -1,11 +1,12 @@ -simple: +default: target: dev outputs: dev: - type: sqlite - threads: 1 - database: "database" - schema: "main" - schemas_and_paths: - main: "{{ env_var('DBT_SQLITE_PATH') }}/imdb.db" - schema_directory: "{{ env_var('DBT_SQLITE_PATH') }}" + type: postgres + host: "{{ env_var('POSTGRES_HOST') }}" + user: "{{ env_var('POSTGRES_USER') }}" + password: "{{ env_var('POSTGRES_PASSWORD') }}" + port: "{{ env_var('POSTGRES_PORT') | int }}" + dbname: "{{ env_var('POSTGRES_DB') }}" + schema: "{{ env_var('POSTGRES_SCHEMA') }}" + threads: 4 diff --git a/dev/dags/performance_dag.py b/dev/dags/performance_dag.py index caf977817d..fec5175c81 100644 --- a/dev/dags/performance_dag.py +++ b/dev/dags/performance_dag.py @@ -1,28 +1,31 @@ """ -A DAG that uses Cosmos to render a dbt project for performance testing. +An airflow DAG that uses Cosmos to render a dbt project for performance testing. """ -import airflow from datetime import datetime import os from pathlib import Path from cosmos import DbtDag, ProjectConfig, ProfileConfig, RenderConfig +from cosmos.profiles import PostgresUserPasswordProfileMapping + DEFAULT_DBT_ROOT_PATH = Path(__file__).parent / "dbt" DBT_ROOT_PATH = Path(os.getenv("DBT_ROOT_PATH", DEFAULT_DBT_ROOT_PATH)) -DBT_SQLITE_PATH = str(DEFAULT_DBT_ROOT_PATH / "data") + profile_config = ProfileConfig( - profile_name="simple", + profile_name="default", target_name="dev", - profiles_yml_filepath=(DBT_ROOT_PATH / "simple/profiles.yml"), + profile_mapping=PostgresUserPasswordProfileMapping( + conn_id="airflow_db", + profile_args={"schema": "public"}, + ), ) cosmos_perf_dag = DbtDag( project_config=ProjectConfig( DBT_ROOT_PATH / "perf", - env_vars={"DBT_SQLITE_PATH": DBT_SQLITE_PATH}, ), profile_config=profile_config, render_config=RenderConfig( diff --git a/docs/configuration/execution-config.rst b/docs/configuration/execution-config.rst index 23b511e372..dd9758d558 100644 --- a/docs/configuration/execution-config.rst +++ b/docs/configuration/execution-config.rst @@ -7,6 +7,7 @@ It does this by exposing a ``cosmos.config.ExecutionConfig`` class that you can The ``ExecutionConfig`` class takes the following arguments: - ``execution_mode``: The way dbt is run when executing within airflow. For more information, see the `execution modes <../getting_started/execution-modes.html>`_ page. +- ``invocation_mode`` (new in v1.4): The way dbt is invoked within the execution mode. This is only configurable for ``ExecutionMode.LOCAL``. For more information, see `invocation modes <../getting_started/execution-modes.html#invocation-modes>`_. - ``test_indirect_selection``: The mode to configure the test behavior when performing indirect selection. - ``dbt_executable_path``: The path to the dbt executable for dag generation. Defaults to dbt if available on the path. - ``dbt_project_path``: Configures the dbt project location accessible at runtime for dag execution. This is the project path in a docker container for ``ExecutionMode.DOCKER`` or ``ExecutionMode.KUBERNETES``. Mutually exclusive with ``ProjectConfig.dbt_project_path``. diff --git a/docs/getting_started/execution-modes.rst b/docs/getting_started/execution-modes.rst index 7c7417cc7a..8f70135722 100644 --- a/docs/getting_started/execution-modes.rst +++ b/docs/getting_started/execution-modes.rst @@ -184,3 +184,33 @@ Each task will create a new container on Azure, giving full isolation. This, how "image": "dbt-jaffle-shop:1.0.0", }, ) + + +.. _invocation_modes: +Invocation Modes +================ +.. versionadded:: 1.4 + +For ``ExecutionMode.LOCAL`` execution mode, Cosmos supports two invocation modes for running dbt: + +1. ``InvocationMode.SUBPROCESS``: In this mode, Cosmos runs dbt cli commands using the Python ``subprocess`` module and parses the output to capture logs and to raise exceptions. + +2. ``InvocationMode.DBT_RUNNER``: In this mode, Cosmos uses the ``dbtRunner`` available for `dbt programmatic invocations `__ to run dbt commands. \ + In order to use this mode, dbt must be installed in the same local environment. This mode does not have the overhead of spawning new subprocesses or parsing the output of dbt commands and is faster than ``InvocationMode.SUBPROCESS``. \ + This mode requires dbt version 1.5.0 or higher. It is up to the user to resolve :ref:`execution-modes-local-conflicts` when using this mode. + +The invocation mode can be set in the ``ExecutionConfig`` as shown below: + +.. code-block:: python + + from cosmos.constants import InvocationMode + + dag = DbtDag( + # ... + execution_config=ExecutionConfig( + execution_mode=ExecutionMode.LOCAL, + invocation_mode=InvocationMode.DBT_RUNNER, + ), + ) + +If the invocation mode is not set, Cosmos will attempt to use ``InvocationMode.DBT_RUNNER`` if dbt is installed in the same environment as the worker, otherwise it will fall back to ``InvocationMode.SUBPROCESS``. diff --git a/scripts/test/performance-setup.sh b/scripts/test/performance-setup.sh index b8bce035c0..7efb917c1e 100644 --- a/scripts/test/performance-setup.sh +++ b/scripts/test/performance-setup.sh @@ -1,4 +1,4 @@ -pip uninstall -y dbt-core dbt-sqlite openlineage-airflow openlineage-integration-common; \ +pip uninstall -y dbt-core dbt-sqlite dbt-postgres openlineage-airflow openlineage-integration-common; \ rm -rf airflow.*; \ airflow db init; \ -pip install 'dbt-core==1.4' 'dbt-sqlite<=1.4' 'dbt-databricks<=1.4' 'dbt-postgres<=1.4' +pip install 'dbt-postgres' diff --git a/tests/dbt/parser/test_output.py b/tests/dbt/parser/test_output.py index 0f4ba56cde..9fae4d3b3e 100644 --- a/tests/dbt/parser/test_output.py +++ b/tests/dbt/parser/test_output.py @@ -1,18 +1,52 @@ +import pytest +import logging +from unittest.mock import MagicMock from airflow.hooks.subprocess import SubprocessResult from cosmos.dbt.parser.output import ( + extract_dbt_runner_issues, extract_log_issues, - parse_output, + parse_number_of_warnings_subprocess, + parse_number_of_warnings_dbt_runner, ) -def test_parse_output() -> None: - for warnings in range(0, 3): - output_str = f"Done. PASS=15 WARN={warnings} ERROR=0 SKIP=0 TOTAL=16" - keyword = "WARN" +@pytest.mark.parametrize( + "output_str, expected_warnings", + [ + ("Done. PASS=15 WARN=1 ERROR=0 SKIP=0 TOTAL=16", 1), + ("Done. PASS=15 WARN=0 ERROR=0 SKIP=0 TOTAL=16", 0), + ("Done. PASS=15 WARN=2 ERROR=0 SKIP=0 TOTAL=16", 2), + ("Nothing to do. Exiting without running tests.", 0), + ], +) +def test_parse_number_of_warnings_subprocess(output_str: str, expected_warnings): + result = SubprocessResult(exit_code=0, output=output_str) + num_warns = parse_number_of_warnings_subprocess(result) + assert num_warns == expected_warnings + + +def test_parse_number_of_warnings_subprocess_error_logged(caplog): + output_str = "WARN= should log an error." + with caplog.at_level(logging.ERROR): result = SubprocessResult(exit_code=0, output=output_str) - num_warns = parse_output(result, keyword) - assert num_warns == warnings + parse_number_of_warnings_subprocess(result) + expected_error_log = ( + "Could not parse number of WARNs. Check your dbt/airflow version or if --quiet is not being used" + ) + assert expected_error_log in caplog.text + + +def test_parse_number_of_warnings_dbt_runner_with_warnings(): + runner_result = MagicMock() + runner_result.result.results = [ + MagicMock(status="pass"), + MagicMock(status="warn"), + MagicMock(status="pass"), + MagicMock(status="warn"), + ] + num_warns = parse_number_of_warnings_dbt_runner(runner_result) + assert num_warns == 2 def test_extract_log_issues() -> None: @@ -37,3 +71,43 @@ def test_extract_log_issues() -> None: test_names_no_warns, test_results_no_warns = extract_log_issues(log_list_no_warning) assert test_names_no_warns == [] assert test_results_no_warns == [] + + +def test_extract_dbt_runner_issues(): + """Tests that the function extracts the correct node names and messages from a dbt runner result + for warnings by default. + """ + runner_result = MagicMock() + runner_result.result.results = [ + MagicMock(status="pass"), + MagicMock(status="warn", message="A warning message", node=MagicMock()), + MagicMock(status="pass"), + MagicMock(status="warn", message="A different warning message", node=MagicMock()), + ] + runner_result.result.results[1].node.name = "a_test" + runner_result.result.results[3].node.name = "another_test" + + node_names, node_results = extract_dbt_runner_issues(runner_result) + + assert node_names == ["a_test", "another_test"] + assert node_results == ["A warning message", "A different warning message"] + + +def test_extract_dbt_runner_issues_with_status_levels(): + """Tests that the function extracts the correct test names and results from a dbt runner result + for status levels. + """ + runner_result = MagicMock() + runner_result.result.results = [ + MagicMock(status="pass"), + MagicMock(status="error", message="An error message", node=MagicMock()), + MagicMock(status="warn"), + MagicMock(status="fail", message="A failure message", node=MagicMock()), + ] + runner_result.result.results[1].node.name = "node1" + runner_result.result.results[3].node.name = "node2" + + node_names, node_results = extract_dbt_runner_issues(runner_result, status_levels=["error", "fail"]) + + assert node_names == ["node1", "node2"] + assert node_results == ["An error message", "A failure message"] diff --git a/tests/dbt/test_project.py b/tests/dbt/test_project.py index 85314b8e5a..a3cd308198 100644 --- a/tests/dbt/test_project.py +++ b/tests/dbt/test_project.py @@ -4,7 +4,7 @@ import pytest -from cosmos.dbt.project import create_symlinks, copy_msgpack_for_partial_parse, environ +from cosmos.dbt.project import create_symlinks, copy_msgpack_for_partial_parse, environ, change_working_directory DBT_PROJECTS_ROOT_DIR = Path(__file__).parent.parent.parent / "dev/dags/dbt" @@ -60,3 +60,18 @@ def test_environ_context_manager(): # Check if the original environment variables are still set assert "value1" == os.environ.get("VAR1") assert "value2" == os.environ.get("VAR2") + + +@patch("os.chdir") +def test_change_working_directory(mock_chdir): + """Tests that the working directory is changed and then restored correctly.""" + # Define the path to change the working directory to + path = "/path/to/directory" + + # Use the change_working_directory context manager + with change_working_directory(path): + # Check if os.chdir is called with the correct path + mock_chdir.assert_called_once_with(path) + + # Check if os.chdir is called with the previous working directory + mock_chdir.assert_called_with(os.getcwd()) diff --git a/tests/operators/test_local.py b/tests/operators/test_local.py index 90585cc95a..9a938bca96 100644 --- a/tests/operators/test_local.py +++ b/tests/operators/test_local.py @@ -31,8 +31,13 @@ DbtRunOperationLocalOperator, ) from cosmos.profiles import PostgresUserPasswordProfileMapping +from cosmos.constants import InvocationMode from tests.utils import test_dag as run_test_dag - +from cosmos.dbt.parser.output import ( + extract_dbt_runner_issues, + parse_number_of_warnings_subprocess, + parse_number_of_warnings_dbt_runner, +) DBT_PROJ_DIR = Path(__file__).parent.parent.parent / "dev/dags/dbt/jaffle_shop" MINI_DBT_PROJ_DIR = Path(__file__).parent.parent / "sample/mini" @@ -122,6 +127,51 @@ def test_dbt_base_operator_add_user_supplied_global_flags() -> None: assert cmd[-1] == "cmd" +@pytest.mark.parametrize( + "invocation_mode, invoke_dbt_method, handle_exception_method", + [ + (InvocationMode.SUBPROCESS, "run_subprocess", "handle_exception_subprocess"), + (InvocationMode.DBT_RUNNER, "run_dbt_runner", "handle_exception_dbt_runner"), + ], +) +def test_dbt_base_operator_set_invocation_methods(invocation_mode, invoke_dbt_method, handle_exception_method): + """Tests that the right methods are mapped to DbtLocalBaseOperator.invoke_dbt and + DbtLocalBaseOperator.handle_exception when a known invocation mode passed. + """ + dbt_base_operator = ConcreteDbtLocalBaseOperator( + profile_config=profile_config, task_id="my-task", project_dir="my/dir", invocation_mode=invocation_mode + ) + dbt_base_operator._set_invocation_methods() + assert dbt_base_operator.invoke_dbt.__name__ == invoke_dbt_method + assert dbt_base_operator.handle_exception.__name__ == handle_exception_method + + +@pytest.mark.parametrize( + "can_import_dbt, invoke_dbt_method, handle_exception_method", + [ + (False, "run_subprocess", "handle_exception_subprocess"), + (True, "run_dbt_runner", "handle_exception_dbt_runner"), + ], +) +def test_dbt_base_operator_discover_invocation_mode(can_import_dbt, invoke_dbt_method, handle_exception_method): + """Tests that the right methods are mapped to DbtLocalBaseOperator.invoke_dbt and + DbtLocalBaseOperator.handle_exception if dbt can be imported or not. + """ + dbt_base_operator = ConcreteDbtLocalBaseOperator( + profile_config=profile_config, task_id="my-task", project_dir="my/dir" + ) + with patch.dict(sys.modules, {"dbt.cli.main": MagicMock()} if can_import_dbt else {"dbt.cli.main": None}): + dbt_base_operator = ConcreteDbtLocalBaseOperator( + profile_config=profile_config, task_id="my-task", project_dir="my/dir" + ) + dbt_base_operator._discover_invocation_mode() + assert dbt_base_operator.invocation_mode == ( + InvocationMode.DBT_RUNNER if can_import_dbt else InvocationMode.SUBPROCESS + ) + assert dbt_base_operator.invoke_dbt.__name__ == invoke_dbt_method + assert dbt_base_operator.handle_exception.__name__ == handle_exception_method + + @pytest.mark.parametrize( "indirect_selection_type", [None, "cautious", "buildable", "empty"], @@ -145,6 +195,69 @@ def test_dbt_base_operator_use_indirect_selection(indirect_selection_type) -> No assert cmd[1] == "cmd" +def test_dbt_base_operator_run_dbt_runner_cannot_import(): + """Tests that the right error message is raised if dbtRunner cannot be imported.""" + dbt_base_operator = ConcreteDbtLocalBaseOperator( + profile_config=profile_config, + task_id="my-task", + project_dir="my/dir", + invocation_mode=InvocationMode.DBT_RUNNER, + ) + expected_error_message = "Could not import dbt core. Ensure that dbt-core >= v1.5 is installed and available in the environment where the operator is running." + with patch.dict(sys.modules, {"dbt.cli.main": None}): + with pytest.raises(ImportError, match=expected_error_message): + dbt_base_operator.run_dbt_runner(command=["cmd"], env={}, cwd="some-project") + + +@patch("cosmos.dbt.project.os.environ") +@patch("cosmos.dbt.project.os.chdir") +def test_dbt_base_operator_run_dbt_runner(mock_chdir, mock_environ): + """Tests that dbtRunner.invoke() is called with the expected cli args, that the + cwd is changed to the expected directory, and env variables are set.""" + dbt_base_operator = ConcreteDbtLocalBaseOperator( + profile_config=profile_config, + task_id="my-task", + project_dir="my/dir", + invocation_mode=InvocationMode.DBT_RUNNER, + ) + full_dbt_cmd = ["dbt", "run", "some_model"] + env_vars = {"VAR1": "value1", "VAR2": "value2"} + + mock_dbt = MagicMock() + with patch.dict(sys.modules, {"dbt.cli.main": mock_dbt}): + dbt_base_operator.run_dbt_runner(command=full_dbt_cmd, env=env_vars, cwd="some-dir") + + mock_dbt_runner = mock_dbt.dbtRunner.return_value + expected_cli_args = ["run", "some_model"] + # Assert dbtRunner.invoke was called with the expected cli args + assert mock_dbt_runner.invoke.call_count == 1 + assert mock_dbt_runner.invoke.call_args[0][0] == expected_cli_args + # Assert cwd was changed to the expected directory + assert mock_chdir.call_count == 2 + assert mock_chdir.call_args_list[0][0][0] == "some-dir" + # Assert env variables were updated + assert mock_environ.update.call_count == 1 + assert mock_environ.update.call_args[0][0] == env_vars + + +@patch("cosmos.dbt.project.os.chdir") +def test_dbt_base_operator_run_dbt_runner_is_cached(mock_chdir): + """Tests that if run_dbt_runner is called multiple times a cached runner is used.""" + dbt_base_operator = ConcreteDbtLocalBaseOperator( + profile_config=profile_config, + task_id="my-task", + project_dir="my/dir", + invocation_mode=InvocationMode.DBT_RUNNER, + ) + mock_dbt = MagicMock() + with patch.dict(sys.modules, {"dbt.cli.main": mock_dbt}): + for _ in range(3): + dbt_base_operator.run_dbt_runner(command=["cmd"], env={}, cwd="some-project") + mock_dbt_runner = mock_dbt.dbtRunner + assert mock_dbt_runner.call_count == 1 + assert dbt_base_operator._dbt_runner is not None + + @pytest.mark.parametrize( ["skip_exception", "exception_code_returned", "expected_exception"], [ @@ -158,17 +271,56 @@ def test_dbt_base_operator_use_indirect_selection(indirect_selection_type) -> No "No exception raised", ], ) -def test_dbt_base_operator_exception_handling(skip_exception, exception_code_returned, expected_exception) -> None: +def test_dbt_base_operator_exception_handling_subprocess( + skip_exception, exception_code_returned, expected_exception +) -> None: dbt_base_operator = ConcreteDbtLocalBaseOperator( profile_config=profile_config, task_id="my-task", project_dir="my/dir", + invocation_mode=InvocationMode.SUBPROCESS, ) if expected_exception: with pytest.raises(expected_exception): - dbt_base_operator.exception_handling(SubprocessResult(exception_code_returned, None)) + dbt_base_operator.handle_exception(SubprocessResult(exception_code_returned, None)) else: - dbt_base_operator.exception_handling(SubprocessResult(exception_code_returned, None)) + dbt_base_operator.handle_exception(SubprocessResult(exception_code_returned, None)) + + +def test_dbt_base_operator_handle_exception_dbt_runner_unhandled_error(): + """Tests that an AirflowException is raised if the dbtRunner result is not successful with an unhandled error.""" + operator = ConcreteDbtLocalBaseOperator( + profile_config=MagicMock(), + task_id="my-task", + project_dir="my/dir", + ) + result = MagicMock() + result.success = False + result.exception = "some exception" + expected_error_message = "dbt invocation did not complete with unhandled error: some exception" + + with pytest.raises(AirflowException, match=expected_error_message): + operator.handle_exception_dbt_runner(result) + + +@patch("cosmos.operators.local.extract_dbt_runner_issues", return_value=(["node1", "node2"], ["error1", "error2"])) +def test_dbt_base_operator_handle_exception_dbt_runner_handled_error(mock_extract_dbt_runner_issues): + """Tests that an AirflowException is raised if the dbtRunner result is not successful and with handled errors.""" + operator = ConcreteDbtLocalBaseOperator( + profile_config=MagicMock(), + task_id="my-task", + project_dir="my/dir", + ) + result = MagicMock() + result.success = False + result.exception = None + + expected_error_message = "dbt invocation completed with errors: node1: error1\nnode2: error2" + + with pytest.raises(AirflowException, match=expected_error_message): + operator.handle_exception_dbt_runner(result) + + mock_extract_dbt_runner_issues.assert_called_once() @patch("cosmos.operators.base.context_to_airflow_vars") @@ -201,6 +353,33 @@ def test_dbt_base_operator_get_env(p_context_to_airflow_vars: MagicMock) -> None assert env == expected_env +@patch("cosmos.operators.local.extract_log_issues") +def test_dbt_test_local_operator_invocation_mode_methods(mock_extract_log_issues): + # test subprocess invocation mode + operator = DbtTestLocalOperator( + profile_config=profile_config, + invocation_mode=InvocationMode.SUBPROCESS, + task_id="my-task", + project_dir="my/dir", + ) + operator._set_test_result_parsing_methods() + assert operator.parse_number_of_warnings == parse_number_of_warnings_subprocess + result = MagicMock(full_output="some output") + operator.extract_issues(result) + mock_extract_log_issues.assert_called_once_with("some output") + + # test dbt runner invocation mode + operator = DbtTestLocalOperator( + profile_config=profile_config, + invocation_mode=InvocationMode.DBT_RUNNER, + task_id="my-task", + project_dir="my/dir", + ) + operator._set_test_result_parsing_methods() + assert operator.extract_issues == extract_dbt_runner_issues + assert operator.parse_number_of_warnings == parse_number_of_warnings_dbt_runner + + @pytest.mark.skipif( version.parse(airflow_version) < version.parse("2.4"), reason="Airflow DAG did not have datasets until the 2.4 release", @@ -259,7 +438,8 @@ def test_dbt_base_operator_no_partial_parse() -> None: @pytest.mark.integration -def test_run_test_operator_with_callback(failing_test_dbt_project): +@pytest.mark.parametrize("invocation_mode", [InvocationMode.SUBPROCESS, InvocationMode.DBT_RUNNER]) +def test_run_test_operator_with_callback(invocation_mode, failing_test_dbt_project): on_warning_callback = MagicMock() with DAG("test-id-2", start_date=datetime(2022, 1, 1)) as dag: @@ -275,6 +455,7 @@ def test_run_test_operator_with_callback(failing_test_dbt_project): task_id="test", append_env=True, on_warning_callback=on_warning_callback, + invocation_mode=invocation_mode, ) run_operator >> test_operator run_test_dag(dag) @@ -282,7 +463,8 @@ def test_run_test_operator_with_callback(failing_test_dbt_project): @pytest.mark.integration -def test_run_test_operator_without_callback(): +@pytest.mark.parametrize("invocation_mode", [InvocationMode.SUBPROCESS, InvocationMode.DBT_RUNNER]) +def test_run_test_operator_without_callback(invocation_mode): on_warning_callback = MagicMock() with DAG("test-id-3", start_date=datetime(2022, 1, 1)) as dag: @@ -291,6 +473,7 @@ def test_run_test_operator_without_callback(): project_dir=MINI_DBT_PROJ_DIR, task_id="run", append_env=True, + invocation_mode=invocation_mode, ) test_operator = DbtTestLocalOperator( profile_config=mini_profile_config, @@ -298,6 +481,7 @@ def test_run_test_operator_without_callback(): task_id="test", append_env=True, on_warning_callback=on_warning_callback, + invocation_mode=invocation_mode, ) run_operator >> test_operator run_test_dag(dag) @@ -403,7 +587,13 @@ def test_store_compiled_sql() -> None: ) @patch("cosmos.operators.local.DbtLocalBaseOperator.build_and_run_cmd") def test_operator_execute_with_flags(mock_build_and_run_cmd, operator_class, kwargs, expected_call_kwargs): - task = operator_class(profile_config=profile_config, task_id="my-task", project_dir="my/dir", **kwargs) + task = operator_class( + profile_config=profile_config, + task_id="my-task", + project_dir="my/dir", + invocation_mode=InvocationMode.DBT_RUNNER, + **kwargs, + ) task.execute(context={}) mock_build_and_run_cmd.assert_called_once_with(**expected_call_kwargs) @@ -432,6 +622,7 @@ def test_operator_execute_without_flags(mock_build_and_run_cmd, operator_class): profile_config=profile_config, task_id="my-task", project_dir="my/dir", + invocation_mode=InvocationMode.DBT_RUNNER, **operator_class_kwargs.get(operator_class, {}), ) task.execute(context={}) @@ -497,11 +688,18 @@ def test_dbt_docs_gcs_local_operator(): @patch("cosmos.operators.local.DbtLocalBaseOperator.store_compiled_sql") -@patch("cosmos.operators.local.DbtLocalBaseOperator.exception_handling") +@patch("cosmos.operators.local.DbtLocalBaseOperator.handle_exception_subprocess") @patch("cosmos.config.ProfileConfig.ensure_profile") @patch("cosmos.operators.local.DbtLocalBaseOperator.run_subprocess") +@patch("cosmos.operators.local.DbtLocalBaseOperator.run_dbt_runner") +@pytest.mark.parametrize("invocation_mode", [InvocationMode.SUBPROCESS, InvocationMode.DBT_RUNNER]) def test_operator_execute_deps_parameters( - mock_build_and_run_cmd, mock_ensure_profile, mock_exception_handling, mock_store_compiled_sql + mock_dbt_runner, + mock_subprocess, + mock_ensure_profile, + mock_exception_handling, + mock_store_compiled_sql, + invocation_mode, ): expected_call_kwargs = [ "/usr/local/bin/dbt", @@ -520,10 +718,14 @@ def test_operator_execute_deps_parameters( install_deps=True, emit_datasets=False, dbt_executable_path="/usr/local/bin/dbt", + invocation_mode=invocation_mode, ) mock_ensure_profile.return_value.__enter__.return_value = (Path("/path/to/profile"), {"ENV_VAR": "value"}) task.execute(context={"task_instance": MagicMock()}) - assert mock_build_and_run_cmd.call_args_list[0].kwargs["command"] == expected_call_kwargs + if invocation_mode == InvocationMode.SUBPROCESS: + assert mock_subprocess.call_args_list[0].kwargs["command"] == expected_call_kwargs + elif invocation_mode == InvocationMode.DBT_RUNNER: + mock_dbt_runner.all_args_list[0].kwargs["command"] == expected_call_kwargs def test_dbt_docs_local_operator_with_static_flag(): @@ -541,7 +743,11 @@ def test_dbt_docs_local_operator_with_static_flag(): def test_dbt_local_operator_on_kill_sigint(mock_send_sigint) -> None: dbt_base_operator = ConcreteDbtLocalBaseOperator( - profile_config=profile_config, task_id="my-task", project_dir="my/dir", cancel_query_on_kill=True + profile_config=profile_config, + task_id="my-task", + project_dir="my/dir", + cancel_query_on_kill=True, + invocation_mode=InvocationMode.SUBPROCESS, ) dbt_base_operator.on_kill() @@ -553,7 +759,11 @@ def test_dbt_local_operator_on_kill_sigint(mock_send_sigint) -> None: def test_dbt_local_operator_on_kill_sigterm(mock_send_sigterm) -> None: dbt_base_operator = ConcreteDbtLocalBaseOperator( - profile_config=profile_config, task_id="my-task", project_dir="my/dir", cancel_query_on_kill=False + profile_config=profile_config, + task_id="my-task", + project_dir="my/dir", + cancel_query_on_kill=False, + invocation_mode=InvocationMode.SUBPROCESS, ) dbt_base_operator.on_kill() diff --git a/tests/operators/test_virtualenv.py b/tests/operators/test_virtualenv.py index 86796308b1..036f162de2 100644 --- a/tests/operators/test_virtualenv.py +++ b/tests/operators/test_virtualenv.py @@ -7,6 +7,7 @@ from cosmos.config import ProfileConfig from cosmos.profiles import PostgresUserPasswordProfileMapping +from cosmos.constants import InvocationMode profile_config = ProfileConfig( profile_name="default", @@ -25,7 +26,7 @@ class ConcreteDbtVirtualenvBaseOperator(DbtVirtualenvBaseOperator): @patch("airflow.utils.python_virtualenv.execute_in_subprocess") @patch("cosmos.operators.virtualenv.DbtLocalBaseOperator.calculate_openlineage_events_completes") @patch("cosmos.operators.virtualenv.DbtLocalBaseOperator.store_compiled_sql") -@patch("cosmos.operators.virtualenv.DbtLocalBaseOperator.exception_handling") +@patch("cosmos.operators.virtualenv.DbtLocalBaseOperator.handle_exception_subprocess") @patch("cosmos.operators.virtualenv.DbtLocalBaseOperator.subprocess_hook") @patch("airflow.hooks.base.BaseHook.get_connection") def test_run_command( @@ -53,6 +54,7 @@ def test_run_command( py_system_site_packages=False, py_requirements=["dbt-postgres==1.6.0b1"], emit_datasets=False, + invocation_mode=InvocationMode.SUBPROCESS, ) assert venv_operator._venv_tmp_dir is None # Otherwise we are creating empty directories during DAG parsing time # and not deleting them @@ -60,12 +62,12 @@ def test_run_command( run_command_args = mock_subprocess_hook.run_command.call_args_list assert len(run_command_args) == 3 python_cmd = run_command_args[0] - dbt_deps = run_command_args[1] - dbt_cmd = run_command_args[2] + dbt_deps = run_command_args[1].kwargs + dbt_cmd = run_command_args[2].kwargs assert python_cmd[0][0][0].endswith("/bin/python") assert python_cmd[0][-1][-1] == "from importlib.metadata import version; print(version('dbt-core'))" - assert dbt_deps[0][0][1] == "deps" - assert dbt_deps[0][0][0].endswith("/bin/dbt") - assert dbt_deps[0][0][0] == dbt_cmd[0][0][0] - assert dbt_cmd[0][0][1] == "do-something" + assert dbt_deps["command"][1] == "deps" + assert dbt_deps["command"][0].endswith("/bin/dbt") + assert dbt_deps["command"][0] == dbt_cmd["command"][0] + assert dbt_cmd["command"][1] == "do-something" assert mock_execute.call_count == 2 diff --git a/tests/perf/test_performance.py b/tests/perf/test_performance.py index acf5d35448..81b08d8bd8 100644 --- a/tests/perf/test_performance.py +++ b/tests/perf/test_performance.py @@ -109,14 +109,18 @@ def test_perf_dag(): # measure the time before and after the dag is run start = time.time() - dag.test() + dag_run = dag.test() end = time.time() - print(f"Ran {num_models} models in {end - start} seconds") - print(f"NUM_MODELS={num_models}\nTIME={end - start}") - - # write the results to a file - with open("/tmp/performance_results.txt", "w") as f: - f.write( - f"NUM_MODELS={num_models}\nTIME={end - start}\nMODELS_PER_SECOND={num_models / (end - start)}\nDBT_VERSION={DBT_VERSION}" - ) + # assert the dag run was successful before writing the results + if dag_run.state == "success": + print(f"Ran {num_models} models in {end - start} seconds") + print(f"NUM_MODELS={num_models}\nTIME={end - start}") + + # write the results to a file + with open("/tmp/performance_results.txt", "w") as f: + f.write( + f"NUM_MODELS={num_models}\nTIME={end - start}\nMODELS_PER_SECOND={num_models / (end - start)}\nDBT_VERSION={DBT_VERSION}" + ) + else: + raise Exception("Performance DAG run failed.") diff --git a/tests/test_config.py b/tests/test_config.py index 795fcffb69..b93ad26275 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,10 +1,12 @@ from pathlib import Path from unittest.mock import patch from cosmos.profiles.postgres.user_pass import PostgresUserPasswordProfileMapping +from contextlib import nullcontext as does_not_raise import pytest -from cosmos.config import ProfileConfig, ProjectConfig, RenderConfig, CosmosConfigException +from cosmos.constants import ExecutionMode, InvocationMode +from cosmos.config import ExecutionConfig, ProfileConfig, ProjectConfig, RenderConfig, CosmosConfigException from cosmos.exceptions import CosmosValueError @@ -195,3 +197,18 @@ def test_render_config_env_vars_deprecated(): """RenderConfig.env_vars is deprecated since Cosmos 1.3, should warn user.""" with pytest.deprecated_call(): RenderConfig(env_vars={"VAR": "value"}) + + +@pytest.mark.parametrize( + "execution_mode, expectation", + [ + (ExecutionMode.LOCAL, does_not_raise()), + (ExecutionMode.VIRTUALENV, pytest.raises(CosmosValueError)), + (ExecutionMode.KUBERNETES, pytest.raises(CosmosValueError)), + (ExecutionMode.DOCKER, pytest.raises(CosmosValueError)), + (ExecutionMode.AZURE_CONTAINER_INSTANCE, pytest.raises(CosmosValueError)), + ], +) +def test_execution_config_with_invocation_option(execution_mode, expectation): + with expectation: + ExecutionConfig(execution_mode=execution_mode, invocation_mode=InvocationMode.DBT_RUNNER) diff --git a/tests/test_converter.py b/tests/test_converter.py index b0913acae3..e66af468f1 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -7,7 +7,7 @@ from airflow.models import DAG from cosmos.converter import DbtToAirflowConverter, validate_arguments, validate_initial_user_config -from cosmos.constants import DbtResourceType, ExecutionMode, LoadMode +from cosmos.constants import DbtResourceType, ExecutionMode, LoadMode, InvocationMode from cosmos.config import ProjectConfig, ProfileConfig, ExecutionConfig, RenderConfig, CosmosConfigException from cosmos.dbt.graph import DbtNode from cosmos.exceptions import CosmosValueError @@ -438,3 +438,34 @@ def test_converter_multiple_calls_same_operator_args( operator_args=operator_args, ) assert operator_args == original_operator_args + + +@pytest.mark.parametrize("invocation_mode", [None, InvocationMode.SUBPROCESS, InvocationMode.DBT_RUNNER]) +@patch("cosmos.config.ProjectConfig.validate_project") +@patch("cosmos.converter.validate_initial_user_config") +@patch("cosmos.converter.DbtGraph") +@patch("cosmos.converter.build_airflow_graph") +def test_converter_invocation_mode_added_to_task_args( + mock_build_airflow_graph, mock_user_config, mock_dbt_graph, mock_validate_project, invocation_mode +): + """Tests that the `task_args` passed to build_airflow_graph has invocation_mode if it is not None.""" + project_config = ProjectConfig(project_name="fake-project", dbt_project_path="/some/project/path") + execution_config = ExecutionConfig(invocation_mode=invocation_mode) + render_config = MagicMock() + profile_config = MagicMock() + + with DAG("test-id", start_date=datetime(2024, 1, 1)) as dag: + DbtToAirflowConverter( + dag=dag, + nodes=nodes, + project_config=project_config, + profile_config=profile_config, + execution_config=execution_config, + render_config=render_config, + operator_args={}, + ) + _, kwargs = mock_build_airflow_graph.call_args + if invocation_mode: + assert kwargs["task_args"]["invocation_mode"] == invocation_mode + else: + assert "invocation_mode" not in kwargs["task_args"] From 8b3441e7b87e2a9863c4a1a099c31ada93e02652 Mon Sep 17 00:00:00 2001 From: Justin Bandoro <79104794+jbandoro@users.noreply.github.com> Date: Tue, 27 Feb 2024 12:47:25 -0800 Subject: [PATCH 111/223] Add import sorting (isort) to Cosmos (#866) Previously Cosmos didn't have any import sorting set in the `pyproject.toml`, and I thought it would be nice to have a uniform import style and clean up the existing imports. This PR updates adds ruff isort that will now be fixed by pre-commit. --- cosmos/__init__.py | 6 +-- cosmos/airflow/dag.py | 2 +- cosmos/airflow/graph.py | 12 ++--- cosmos/airflow/task_group.py | 3 +- cosmos/config.py | 8 +-- cosmos/constants.py | 1 - cosmos/converter.py | 4 +- cosmos/core/airflow.py | 1 - cosmos/dbt/graph.py | 5 +- cosmos/dbt/parser/output.py | 3 +- cosmos/dbt/parser/project.py | 2 +- cosmos/dbt/project.py | 7 +-- cosmos/dbt/selector.py | 1 + cosmos/hooks/subprocess.py | 2 +- cosmos/log.py | 2 +- cosmos/operators/__init__.py | 2 +- cosmos/operators/azure_container_instance.py | 6 +-- cosmos/operators/base.py | 3 +- cosmos/operators/docker.py | 4 +- cosmos/operators/kubernetes.py | 11 ++--- cosmos/operators/local.py | 49 +++++++++---------- cosmos/operators/virtualenv.py | 4 +- cosmos/profiles/__init__.py | 7 ++- cosmos/profiles/base.py | 6 +-- cosmos/profiles/bigquery/__init__.py | 2 +- .../bigquery/service_account_keyfile_dict.py | 4 +- cosmos/profiles/snowflake/__init__.py | 4 +- dev/dags/basic_cosmos_dag.py | 2 +- dev/dags/basic_cosmos_task_group.py | 5 +- dev/dags/cosmos_manifest_example.py | 4 +- dev/dags/cosmos_profile_mapping.py | 2 +- dev/dags/dbt/simple/convert_csv_to_db.py | 3 +- dev/dags/dbt_docs.py | 6 +-- dev/dags/example_cosmos_python_models.py | 2 +- dev/dags/example_cosmos_sources.py | 4 +- dev/dags/example_model_version.py | 2 +- dev/dags/example_virtualenv.py | 2 +- dev/dags/performance_dag.py | 5 +- dev/dags/user_defined_profile.py | 2 +- docs/generate_mappings.py | 3 +- pyproject.toml | 2 +- tests/airflow/test_graph.py | 2 +- tests/dbt/parser/test_output.py | 5 +- tests/dbt/test_graph.py | 8 +-- tests/dbt/test_project.py | 2 +- tests/dbt/test_selector.py | 3 +- tests/hooks/test_subprocess.py | 2 +- .../test_azure_container_instance.py | 2 +- tests/operators/test_base.py | 9 ++-- tests/operators/test_docker.py | 2 +- tests/operators/test_kubernetes.py | 5 +- tests/operators/test_local.py | 32 ++++++------ tests/operators/test_virtualenv.py | 8 ++- tests/perf/test_performance.py | 4 +- tests/plugin/test_plugin.py | 12 ++--- .../profiles/athena/test_athena_access_key.py | 3 +- .../test_bq_service_account_keyfile_dict.py | 2 +- tests/profiles/test_base_profile.py | 3 +- tests/profiles/trino/test_trino_base.py | 2 +- tests/profiles/trino/test_trino_jwt.py | 3 +- tests/profiles/trino/test_trino_ldap.py | 3 +- tests/test_config.py | 7 ++- tests/test_converter.py | 9 ++-- tests/test_example_dags_no_connections.py | 1 - tests/test_log.py | 3 +- 65 files changed, 163 insertions(+), 174 deletions(-) diff --git a/cosmos/__init__.py b/cosmos/__init__.py index c5a0ce8456..859995fe61 100644 --- a/cosmos/__init__.py +++ b/cosmos/__init__.py @@ -11,12 +11,12 @@ from cosmos.airflow.dag import DbtDag from cosmos.airflow.task_group import DbtTaskGroup from cosmos.config import ( - ProjectConfig, - ProfileConfig, ExecutionConfig, + ProfileConfig, + ProjectConfig, RenderConfig, ) -from cosmos.constants import LoadMode, TestBehavior, ExecutionMode +from cosmos.constants import ExecutionMode, LoadMode, TestBehavior from cosmos.log import get_logger from cosmos.operators.lazy_load import MissingPackage from cosmos.operators.local import ( diff --git a/cosmos/airflow/dag.py b/cosmos/airflow/dag.py index ca2672060f..de958f118f 100644 --- a/cosmos/airflow/dag.py +++ b/cosmos/airflow/dag.py @@ -8,7 +8,7 @@ from airflow.models.dag import DAG -from cosmos.converter import airflow_kwargs, specific_kwargs, DbtToAirflowConverter +from cosmos.converter import DbtToAirflowConverter, airflow_kwargs, specific_kwargs class DbtDag(DAG, DbtToAirflowConverter): diff --git a/cosmos/airflow/graph.py b/cosmos/airflow/graph.py index 1d662e7f45..348b0ce65d 100644 --- a/cosmos/airflow/graph.py +++ b/cosmos/airflow/graph.py @@ -1,26 +1,24 @@ from __future__ import annotations -from typing import Any, Callable +from typing import Any, Callable, Union from airflow.models import BaseOperator from airflow.models.dag import DAG from airflow.utils.task_group import TaskGroup +from cosmos.config import RenderConfig from cosmos.constants import ( + DEFAULT_DBT_RESOURCES, + TESTABLE_DBT_RESOURCES, DbtResourceType, + ExecutionMode, TestBehavior, TestIndirectSelection, - ExecutionMode, - TESTABLE_DBT_RESOURCES, - DEFAULT_DBT_RESOURCES, ) -from cosmos.config import RenderConfig from cosmos.core.airflow import get_airflow_task as create_airflow_task from cosmos.core.graph.entities import Task as TaskMetadata from cosmos.dbt.graph import DbtNode from cosmos.log import get_logger -from typing import Union - logger = get_logger(__name__) diff --git a/cosmos/airflow/task_group.py b/cosmos/airflow/task_group.py index 5171645f2e..64fcb298aa 100644 --- a/cosmos/airflow/task_group.py +++ b/cosmos/airflow/task_group.py @@ -3,11 +3,12 @@ """ from __future__ import annotations + from typing import Any from airflow.utils.task_group import TaskGroup -from cosmos.converter import airflow_kwargs, specific_kwargs, DbtToAirflowConverter +from cosmos.converter import DbtToAirflowConverter, airflow_kwargs, specific_kwargs class DbtTaskGroup(TaskGroup, DbtToAirflowConverter): diff --git a/cosmos/config.py b/cosmos/config.py index 9dfb672bed..729e95c75f 100644 --- a/cosmos/config.py +++ b/cosmos/config.py @@ -5,18 +5,18 @@ import contextlib import shutil import tempfile +import warnings from dataclasses import InitVar, dataclass, field from pathlib import Path -import warnings -from typing import Any, Iterator, Callable +from typing import Any, Callable, Iterator from cosmos.constants import ( DbtResourceType, - TestBehavior, ExecutionMode, + InvocationMode, LoadMode, + TestBehavior, TestIndirectSelection, - InvocationMode, ) from cosmos.dbt.executable import get_system_dbt from cosmos.exceptions import CosmosValueError diff --git a/cosmos/constants.py b/cosmos/constants.py index e8b9cff1dd..1db78d15bd 100644 --- a/cosmos/constants.py +++ b/cosmos/constants.py @@ -4,7 +4,6 @@ import aenum - DBT_PROFILE_PATH = Path(os.path.expanduser("~")).joinpath(".dbt/profiles.yml") DEFAULT_DBT_PROFILE_NAME = "cosmos_profile" DEFAULT_DBT_TARGET_NAME = "cosmos_target" diff --git a/cosmos/converter.py b/cosmos/converter.py index bafe094e87..3619fecf56 100644 --- a/cosmos/converter.py +++ b/cosmos/converter.py @@ -3,19 +3,19 @@ from __future__ import annotations +import copy import inspect from typing import Any, Callable -import copy from warnings import warn from airflow.models.dag import DAG from airflow.utils.task_group import TaskGroup from cosmos.airflow.graph import build_airflow_graph +from cosmos.config import ExecutionConfig, ProfileConfig, ProjectConfig, RenderConfig from cosmos.constants import ExecutionMode from cosmos.dbt.graph import DbtGraph from cosmos.dbt.selector import retrieve_by_label -from cosmos.config import ProjectConfig, ExecutionConfig, RenderConfig, ProfileConfig from cosmos.exceptions import CosmosValueError from cosmos.log import get_logger diff --git a/cosmos/core/airflow.py b/cosmos/core/airflow.py index 7c5dee3281..f6f7464d87 100644 --- a/cosmos/core/airflow.py +++ b/cosmos/core/airflow.py @@ -7,7 +7,6 @@ from cosmos.core.graph.entities import Task from cosmos.log import get_logger - logger = get_logger(__name__) diff --git a/cosmos/dbt/graph.py b/cosmos/dbt/graph.py index 435fbccc7a..0a9b78dc77 100644 --- a/cosmos/dbt/graph.py +++ b/cosmos/dbt/graph.py @@ -4,12 +4,13 @@ import json import os import tempfile -import yaml from dataclasses import dataclass, field from pathlib import Path from subprocess import PIPE, Popen from typing import Any +import yaml + from cosmos.config import ExecutionConfig, ProfileConfig, ProjectConfig, RenderConfig from cosmos.constants import ( DBT_LOG_DIR_NAME, @@ -22,7 +23,7 @@ LoadMode, ) from cosmos.dbt.parser.project import LegacyDbtProject -from cosmos.dbt.project import create_symlinks, copy_msgpack_for_partial_parse, environ +from cosmos.dbt.project import copy_msgpack_for_partial_parse, create_symlinks, environ from cosmos.dbt.selector import select_nodes from cosmos.log import get_logger diff --git a/cosmos/dbt/parser/output.py b/cosmos/dbt/parser/output.py index 3690a8f609..3ff377941e 100644 --- a/cosmos/dbt/parser/output.py +++ b/cosmos/dbt/parser/output.py @@ -2,14 +2,13 @@ import logging import re -from typing import List, Tuple, TYPE_CHECKING +from typing import TYPE_CHECKING, List, Tuple if TYPE_CHECKING: from dbt.cli.main import dbtRunnerResult from cosmos.hooks.subprocess import FullOutputSubprocessResult - DBT_NO_TESTS_MSG = "Nothing to do" DBT_WARN_MSG = "WARN" diff --git a/cosmos/dbt/parser/project.py b/cosmos/dbt/parser/project.py index c5e434996c..8876d68786 100644 --- a/cosmos/dbt/parser/project.py +++ b/cosmos/dbt/parser/project.py @@ -4,8 +4,8 @@ from __future__ import annotations -import os import ast +import os from dataclasses import dataclass, field from enum import Enum from pathlib import Path diff --git a/cosmos/dbt/project.py b/cosmos/dbt/project.py index 144a1f6dfa..ad328a3324 100644 --- a/cosmos/dbt/project.py +++ b/cosmos/dbt/project.py @@ -1,12 +1,13 @@ from __future__ import annotations -from pathlib import Path -import shutil import os -from cosmos.constants import DBT_LOG_DIR_NAME, DBT_TARGET_DIR_NAME, DBT_PARTIAL_PARSE_FILE_NAME +import shutil from contextlib import contextmanager +from pathlib import Path from typing import Generator +from cosmos.constants import DBT_LOG_DIR_NAME, DBT_PARTIAL_PARSE_FILE_NAME, DBT_TARGET_DIR_NAME + def create_symlinks(project_path: Path, tmp_dir: Path, ignore_dbt_packages: bool) -> None: """Helper function to create symlinks to the dbt project files.""" diff --git a/cosmos/dbt/selector.py b/cosmos/dbt/selector.py index 58c5c12b49..61458c4aae 100644 --- a/cosmos/dbt/selector.py +++ b/cosmos/dbt/selector.py @@ -1,4 +1,5 @@ from __future__ import annotations + import copy import re from collections import defaultdict diff --git a/cosmos/hooks/subprocess.py b/cosmos/hooks/subprocess.py index 2522420b72..19c88540b7 100644 --- a/cosmos/hooks/subprocess.py +++ b/cosmos/hooks/subprocess.py @@ -7,9 +7,9 @@ import contextlib import os import signal -from typing import NamedTuple from subprocess import PIPE, STDOUT, Popen from tempfile import TemporaryDirectory, gettempdir +from typing import NamedTuple from airflow.hooks.base import BaseHook diff --git a/cosmos/log.py b/cosmos/log.py index e4ad6e2bad..f7c512f17e 100644 --- a/cosmos/log.py +++ b/cosmos/log.py @@ -1,10 +1,10 @@ from __future__ import annotations + import logging from airflow.configuration import conf from airflow.utils.log.colored_log import CustomTTYColoredFormatter - LOG_FORMAT: str = ( "[%(blue)s%(asctime)s%(reset)s] " "{%(blue)s%(filename)s:%(reset)s%(lineno)d} " diff --git a/cosmos/operators/__init__.py b/cosmos/operators/__init__.py index 92f53fa083..c546da0199 100644 --- a/cosmos/operators/__init__.py +++ b/cosmos/operators/__init__.py @@ -1,9 +1,9 @@ from .local import DbtBuildLocalOperator as DbtBuildOperator from .local import DbtDepsLocalOperator as DbtDepsOperator from .local import DbtDocsAzureStorageLocalOperator as DbtDocsAzureStorageOperator +from .local import DbtDocsGCSLocalOperator as DbtDocsGCSOperator from .local import DbtDocsLocalOperator as DbtDocsOperator from .local import DbtDocsS3LocalOperator as DbtDocsS3Operator -from .local import DbtDocsGCSLocalOperator as DbtDocsGCSOperator from .local import DbtLSLocalOperator as DbtLSOperator from .local import DbtRunLocalOperator as DbtRunOperator from .local import DbtRunOperationLocalOperator as DbtRunOperationOperator diff --git a/cosmos/operators/azure_container_instance.py b/cosmos/operators/azure_container_instance.py index 903524533d..397a47551c 100644 --- a/cosmos/operators/azure_container_instance.py +++ b/cosmos/operators/azure_container_instance.py @@ -3,17 +3,17 @@ from typing import Any, Callable, Sequence from airflow.utils.context import Context -from cosmos.config import ProfileConfig +from cosmos.config import ProfileConfig from cosmos.log import get_logger from cosmos.operators.base import ( AbstractDbtBaseOperator, + DbtLSMixin, DbtRunMixin, + DbtRunOperationMixin, DbtSeedMixin, DbtSnapshotMixin, DbtTestMixin, - DbtLSMixin, - DbtRunOperationMixin, ) logger = get_logger(__name__) diff --git a/cosmos/operators/base.py b/cosmos/operators/base.py index d8fefc5239..e94cae05f0 100644 --- a/cosmos/operators/base.py +++ b/cosmos/operators/base.py @@ -1,8 +1,8 @@ from __future__ import annotations import os -from typing import Any, Sequence, Tuple from abc import ABCMeta, abstractmethod +from typing import Any, Sequence, Tuple import yaml from airflow.models.baseoperator import BaseOperator @@ -12,7 +12,6 @@ from cosmos.dbt.executable import get_system_dbt from cosmos.log import get_logger - logger = get_logger(__name__) diff --git a/cosmos/operators/docker.py b/cosmos/operators/docker.py index 571bff046d..5be03fad7d 100644 --- a/cosmos/operators/docker.py +++ b/cosmos/operators/docker.py @@ -8,12 +8,12 @@ from cosmos.operators.base import ( AbstractDbtBaseOperator, DbtBuildMixin, + DbtLSMixin, DbtRunMixin, + DbtRunOperationMixin, DbtSeedMixin, DbtSnapshotMixin, DbtTestMixin, - DbtLSMixin, - DbtRunOperationMixin, ) logger = get_logger(__name__) diff --git a/cosmos/operators/kubernetes.py b/cosmos/operators/kubernetes.py index fc0e28c051..e314b7c439 100644 --- a/cosmos/operators/kubernetes.py +++ b/cosmos/operators/kubernetes.py @@ -3,24 +3,23 @@ from os import PathLike from typing import Any, Callable, Sequence +from airflow.models import TaskInstance from airflow.utils.context import Context, context_merge -from cosmos.log import get_logger from cosmos.config import ProfileConfig +from cosmos.dbt.parser.output import extract_log_issues +from cosmos.log import get_logger from cosmos.operators.base import ( AbstractDbtBaseOperator, DbtBuildMixin, + DbtLSMixin, DbtRunMixin, + DbtRunOperationMixin, DbtSeedMixin, DbtSnapshotMixin, DbtTestMixin, - DbtLSMixin, - DbtRunOperationMixin, ) -from airflow.models import TaskInstance -from cosmos.dbt.parser.output import extract_log_issues - DBT_NO_TESTS_MSG = "Nothing to do" DBT_WARN_MSG = "WARN" diff --git a/cosmos/operators/local.py b/cosmos/operators/local.py index 684d59c7a3..1d9f61c57b 100644 --- a/cosmos/operators/local.py +++ b/cosmos/operators/local.py @@ -1,28 +1,28 @@ from __future__ import annotations import os -import signal import tempfile -from attr import define -from pathlib import Path -from typing import Any, Callable, Literal, Sequence, TYPE_CHECKING -from abc import ABC, abstractmethod import warnings +from abc import ABC, abstractmethod +from functools import cached_property +from pathlib import Path +from typing import TYPE_CHECKING, Any, Callable, Literal, Sequence import airflow import jinja2 from airflow import DAG -from functools import cached_property from airflow.configuration import conf from airflow.exceptions import AirflowException, AirflowSkipException from airflow.models.taskinstance import TaskInstance from airflow.utils.context import Context from airflow.utils.session import NEW_SESSION, create_session, provide_session +from attr import define + from cosmos.constants import InvocationMode try: - from openlineage.common.provider.dbt.local import DbtLocalArtifactProcessor from airflow.datasets import Dataset + from openlineage.common.provider.dbt.local import DbtLocalArtifactProcessor except ModuleNotFoundError: is_openlineage_available = False DbtLocalArtifactProcessor = None @@ -31,41 +31,40 @@ if TYPE_CHECKING: from airflow.datasets import Dataset # noqa: F811 - from openlineage.client.run import RunEvent from dbt.cli.main import dbtRunner, dbtRunnerResult + from openlineage.client.run import RunEvent from sqlalchemy.orm import Session +from cosmos.config import ProfileConfig from cosmos.constants import ( + DBT_PARTIAL_PARSE_FILE_NAME, + DBT_TARGET_DIR_NAME, DEFAULT_OPENLINEAGE_NAMESPACE, OPENLINEAGE_PRODUCER, - DBT_TARGET_DIR_NAME, - DBT_PARTIAL_PARSE_FILE_NAME, ) -from cosmos.config import ProfileConfig +from cosmos.dbt.parser.output import ( + extract_dbt_runner_issues, + extract_log_issues, + parse_number_of_warnings_dbt_runner, + parse_number_of_warnings_subprocess, +) +from cosmos.dbt.project import change_working_directory, copy_msgpack_for_partial_parse, create_symlinks, environ +from cosmos.hooks.subprocess import ( + FullOutputSubprocessHook, + FullOutputSubprocessResult, +) from cosmos.log import get_logger from cosmos.operators.base import ( AbstractDbtBaseOperator, DbtBuildMixin, + DbtLSMixin, DbtRunMixin, + DbtRunOperationMixin, DbtSeedMixin, DbtSnapshotMixin, DbtTestMixin, - DbtLSMixin, - DbtRunOperationMixin, ) -from cosmos.hooks.subprocess import ( - FullOutputSubprocessHook, - FullOutputSubprocessResult, -) -from cosmos.dbt.parser.output import ( - extract_dbt_runner_issues, - extract_log_issues, - parse_number_of_warnings_dbt_runner, - parse_number_of_warnings_subprocess, -) -from cosmos.dbt.project import create_symlinks, copy_msgpack_for_partial_parse, environ, change_working_directory - logger = get_logger(__name__) diff --git a/cosmos/operators/virtualenv.py b/cosmos/operators/virtualenv.py index c6322ba326..cdf62a1d57 100644 --- a/cosmos/operators/virtualenv.py +++ b/cosmos/operators/virtualenv.py @@ -1,13 +1,13 @@ from __future__ import annotations +from functools import cached_property from pathlib import Path from tempfile import TemporaryDirectory from typing import TYPE_CHECKING, Any -from functools import cached_property from airflow.utils.python_virtualenv import prepare_virtualenv -from cosmos.hooks.subprocess import FullOutputSubprocessResult +from cosmos.hooks.subprocess import FullOutputSubprocessResult from cosmos.log import get_logger from cosmos.operators.local import ( DbtBuildLocalOperator, diff --git a/cosmos/profiles/__init__.py b/cosmos/profiles/__init__.py index 446207f353..fa8e5c370e 100644 --- a/cosmos/profiles/__init__.py +++ b/cosmos/profiles/__init__.py @@ -4,20 +4,19 @@ from typing import Any, Type - from .athena import AthenaAccessKeyProfileMapping from .base import BaseProfileMapping, DbtProfileConfigVars +from .bigquery.oauth import GoogleCloudOauthProfileMapping from .bigquery.service_account_file import GoogleCloudServiceAccountFileProfileMapping from .bigquery.service_account_keyfile_dict import GoogleCloudServiceAccountDictProfileMapping -from .bigquery.oauth import GoogleCloudOauthProfileMapping from .databricks.token import DatabricksTokenProfileMapping from .exasol.user_pass import ExasolUserPasswordProfileMapping from .postgres.user_pass import PostgresUserPasswordProfileMapping from .redshift.user_pass import RedshiftUserPasswordProfileMapping +from .snowflake.user_encrypted_privatekey_env_variable import SnowflakeEncryptedPrivateKeyPemProfileMapping +from .snowflake.user_encrypted_privatekey_file import SnowflakeEncryptedPrivateKeyFilePemProfileMapping from .snowflake.user_pass import SnowflakeUserPasswordProfileMapping from .snowflake.user_privatekey import SnowflakePrivateKeyPemProfileMapping -from .snowflake.user_encrypted_privatekey_file import SnowflakeEncryptedPrivateKeyFilePemProfileMapping -from .snowflake.user_encrypted_privatekey_env_variable import SnowflakeEncryptedPrivateKeyPemProfileMapping from .spark.thrift import SparkThriftProfileMapping from .trino.certificate import TrinoCertificateProfileMapping from .trino.jwt import TrinoJWTProfileMapping diff --git a/cosmos/profiles/base.py b/cosmos/profiles/base.py index 1131dc8e10..9d17c40195 100644 --- a/cosmos/profiles/base.py +++ b/cosmos/profiles/base.py @@ -5,13 +5,13 @@ from __future__ import annotations -from abc import ABC, abstractmethod -from typing import Any, Optional, Literal, Dict, TYPE_CHECKING import warnings +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any, Dict, Literal, Optional +import yaml from airflow.hooks.base import BaseHook from pydantic import dataclasses -import yaml from cosmos.exceptions import CosmosValueError from cosmos.log import get_logger diff --git a/cosmos/profiles/bigquery/__init__.py b/cosmos/profiles/bigquery/__init__.py index c03f9a0ab0..b547bbf845 100644 --- a/cosmos/profiles/bigquery/__init__.py +++ b/cosmos/profiles/bigquery/__init__.py @@ -1,8 +1,8 @@ "BigQuery Airflow connection -> dbt profile mappings" +from .oauth import GoogleCloudOauthProfileMapping from .service_account_file import GoogleCloudServiceAccountFileProfileMapping from .service_account_keyfile_dict import GoogleCloudServiceAccountDictProfileMapping -from .oauth import GoogleCloudOauthProfileMapping __all__ = [ "GoogleCloudServiceAccountFileProfileMapping", diff --git a/cosmos/profiles/bigquery/service_account_keyfile_dict.py b/cosmos/profiles/bigquery/service_account_keyfile_dict.py index e84587faf4..038b34153d 100644 --- a/cosmos/profiles/bigquery/service_account_keyfile_dict.py +++ b/cosmos/profiles/bigquery/service_account_keyfile_dict.py @@ -1,10 +1,10 @@ "Maps Airflow GCP connections to dbt BigQuery profiles if they use a service account keyfile dict/json." from __future__ import annotations -from typing import Any import json -from cosmos.exceptions import CosmosValueError +from typing import Any +from cosmos.exceptions import CosmosValueError from cosmos.profiles.base import BaseProfileMapping diff --git a/cosmos/profiles/snowflake/__init__.py b/cosmos/profiles/snowflake/__init__.py index fdf323a766..01745d01ee 100644 --- a/cosmos/profiles/snowflake/__init__.py +++ b/cosmos/profiles/snowflake/__init__.py @@ -1,9 +1,9 @@ "Snowflake Airflow connection -> dbt profile mapping." +from .user_encrypted_privatekey_env_variable import SnowflakeEncryptedPrivateKeyPemProfileMapping +from .user_encrypted_privatekey_file import SnowflakeEncryptedPrivateKeyFilePemProfileMapping from .user_pass import SnowflakeUserPasswordProfileMapping from .user_privatekey import SnowflakePrivateKeyPemProfileMapping -from .user_encrypted_privatekey_file import SnowflakeEncryptedPrivateKeyFilePemProfileMapping -from .user_encrypted_privatekey_env_variable import SnowflakeEncryptedPrivateKeyPemProfileMapping __all__ = [ "SnowflakeUserPasswordProfileMapping", diff --git a/dev/dags/basic_cosmos_dag.py b/dev/dags/basic_cosmos_dag.py index 80e3c1a5f5..c961638b39 100644 --- a/dev/dags/basic_cosmos_dag.py +++ b/dev/dags/basic_cosmos_dag.py @@ -6,7 +6,7 @@ from datetime import datetime from pathlib import Path -from cosmos import DbtDag, ProjectConfig, ProfileConfig +from cosmos import DbtDag, ProfileConfig, ProjectConfig from cosmos.profiles import PostgresUserPasswordProfileMapping DEFAULT_DBT_ROOT_PATH = Path(__file__).parent / "dbt" diff --git a/dev/dags/basic_cosmos_task_group.py b/dev/dags/basic_cosmos_task_group.py index 06b24f2918..55842ee102 100644 --- a/dev/dags/basic_cosmos_task_group.py +++ b/dev/dags/basic_cosmos_task_group.py @@ -3,16 +3,15 @@ """ import os - from datetime import datetime from pathlib import Path from airflow.decorators import dag from airflow.operators.empty import EmptyOperator -from cosmos import DbtTaskGroup, ProjectConfig, ProfileConfig, RenderConfig, ExecutionConfig -from cosmos.profiles import PostgresUserPasswordProfileMapping +from cosmos import DbtTaskGroup, ExecutionConfig, ProfileConfig, ProjectConfig, RenderConfig from cosmos.constants import InvocationMode +from cosmos.profiles import PostgresUserPasswordProfileMapping DEFAULT_DBT_ROOT_PATH = Path(__file__).parent / "dbt" DBT_ROOT_PATH = Path(os.getenv("DBT_ROOT_PATH", DEFAULT_DBT_ROOT_PATH)) diff --git a/dev/dags/cosmos_manifest_example.py b/dev/dags/cosmos_manifest_example.py index 7b7f9d4aaa..e8721aefdf 100644 --- a/dev/dags/cosmos_manifest_example.py +++ b/dev/dags/cosmos_manifest_example.py @@ -6,8 +6,8 @@ from datetime import datetime from pathlib import Path -from cosmos import DbtDag, ProjectConfig, ProfileConfig, RenderConfig, LoadMode, ExecutionConfig -from cosmos.profiles import PostgresUserPasswordProfileMapping, DbtProfileConfigVars +from cosmos import DbtDag, ExecutionConfig, LoadMode, ProfileConfig, ProjectConfig, RenderConfig +from cosmos.profiles import DbtProfileConfigVars, PostgresUserPasswordProfileMapping DEFAULT_DBT_ROOT_PATH = Path(__file__).parent / "dbt" DBT_ROOT_PATH = Path(os.getenv("DBT_ROOT_PATH", DEFAULT_DBT_ROOT_PATH)) diff --git a/dev/dags/cosmos_profile_mapping.py b/dev/dags/cosmos_profile_mapping.py index 48040126ed..6570467e46 100644 --- a/dev/dags/cosmos_profile_mapping.py +++ b/dev/dags/cosmos_profile_mapping.py @@ -11,7 +11,7 @@ from airflow.decorators import dag from airflow.operators.empty import EmptyOperator -from cosmos import DbtTaskGroup, ProjectConfig, ProfileConfig +from cosmos import DbtTaskGroup, ProfileConfig, ProjectConfig from cosmos.profiles import get_automatic_profile_mapping DEFAULT_DBT_ROOT_PATH = Path(__file__).parent / "dbt" diff --git a/dev/dags/dbt/simple/convert_csv_to_db.py b/dev/dags/dbt/simple/convert_csv_to_db.py index 7424db32de..ef0ca301ec 100644 --- a/dev/dags/dbt/simple/convert_csv_to_db.py +++ b/dev/dags/dbt/simple/convert_csv_to_db.py @@ -1,6 +1,7 @@ -import pandas as pd import sqlite3 +import pandas as pd + df = pd.read_csv("imdb.csv") conn = sqlite3.connect("imdb.db") df.to_sql("movies_ratings", conn, if_exists="replace", index=False) diff --git a/dev/dags/dbt_docs.py b/dev/dags/dbt_docs.py index edf89bdab6..924928a801 100644 --- a/dev/dags/dbt_docs.py +++ b/dev/dags/dbt_docs.py @@ -11,16 +11,16 @@ from pathlib import Path from airflow import DAG -from airflow.hooks.base import BaseHook -from airflow.exceptions import AirflowNotFoundException from airflow.decorators import task +from airflow.exceptions import AirflowNotFoundException +from airflow.hooks.base import BaseHook from pendulum import datetime from cosmos import ProfileConfig from cosmos.operators import ( DbtDocsAzureStorageOperator, - DbtDocsS3Operator, DbtDocsGCSOperator, + DbtDocsS3Operator, ) from cosmos.profiles import PostgresUserPasswordProfileMapping diff --git a/dev/dags/example_cosmos_python_models.py b/dev/dags/example_cosmos_python_models.py index 3aa3139136..90c8cc0ca9 100644 --- a/dev/dags/example_cosmos_python_models.py +++ b/dev/dags/example_cosmos_python_models.py @@ -17,7 +17,7 @@ from datetime import datetime from pathlib import Path -from cosmos import DbtDag, ProjectConfig, ProfileConfig +from cosmos import DbtDag, ProfileConfig, ProjectConfig from cosmos.profiles import DatabricksTokenProfileMapping DEFAULT_DBT_ROOT_PATH = Path(__file__).parent / "dbt" diff --git a/dev/dags/example_cosmos_sources.py b/dev/dags/example_cosmos_sources.py index 24359ee3be..1a85b6d9f9 100644 --- a/dev/dags/example_cosmos_sources.py +++ b/dev/dags/example_cosmos_sources.py @@ -16,11 +16,11 @@ from datetime import datetime from pathlib import Path -from airflow.operators.dummy import DummyOperator from airflow.models.dag import DAG +from airflow.operators.dummy import DummyOperator from airflow.utils.task_group import TaskGroup -from cosmos import DbtDag, ProjectConfig, ProfileConfig, RenderConfig +from cosmos import DbtDag, ProfileConfig, ProjectConfig, RenderConfig from cosmos.constants import DbtResourceType from cosmos.dbt.graph import DbtNode diff --git a/dev/dags/example_model_version.py b/dev/dags/example_model_version.py index 78f38647dd..909c7494a8 100644 --- a/dev/dags/example_model_version.py +++ b/dev/dags/example_model_version.py @@ -6,7 +6,7 @@ from datetime import datetime from pathlib import Path -from cosmos import DbtDag, ProjectConfig, ProfileConfig +from cosmos import DbtDag, ProfileConfig, ProjectConfig from cosmos.profiles import PostgresUserPasswordProfileMapping DEFAULT_DBT_ROOT_PATH = Path(__file__).parent / "dbt" diff --git a/dev/dags/example_virtualenv.py b/dev/dags/example_virtualenv.py index d84646cec3..55ecf0a66f 100644 --- a/dev/dags/example_virtualenv.py +++ b/dev/dags/example_virtualenv.py @@ -6,7 +6,7 @@ from datetime import datetime from pathlib import Path -from cosmos import DbtDag, ExecutionMode, ExecutionConfig, ProjectConfig, ProfileConfig +from cosmos import DbtDag, ExecutionConfig, ExecutionMode, ProfileConfig, ProjectConfig from cosmos.profiles import PostgresUserPasswordProfileMapping DEFAULT_DBT_ROOT_PATH = Path(__file__).parent / "dbt" diff --git a/dev/dags/performance_dag.py b/dev/dags/performance_dag.py index fec5175c81..1c8d639e02 100644 --- a/dev/dags/performance_dag.py +++ b/dev/dags/performance_dag.py @@ -2,14 +2,13 @@ An airflow DAG that uses Cosmos to render a dbt project for performance testing. """ -from datetime import datetime import os +from datetime import datetime from pathlib import Path -from cosmos import DbtDag, ProjectConfig, ProfileConfig, RenderConfig +from cosmos import DbtDag, ProfileConfig, ProjectConfig, RenderConfig from cosmos.profiles import PostgresUserPasswordProfileMapping - DEFAULT_DBT_ROOT_PATH = Path(__file__).parent / "dbt" DBT_ROOT_PATH = Path(os.getenv("DBT_ROOT_PATH", DEFAULT_DBT_ROOT_PATH)) diff --git a/dev/dags/user_defined_profile.py b/dev/dags/user_defined_profile.py index 22402bc53a..a88354b049 100644 --- a/dev/dags/user_defined_profile.py +++ b/dev/dags/user_defined_profile.py @@ -9,7 +9,7 @@ from airflow.decorators import dag from airflow.operators.empty import EmptyOperator -from cosmos import DbtTaskGroup, ProjectConfig, ProfileConfig, RenderConfig, LoadMode +from cosmos import DbtTaskGroup, LoadMode, ProfileConfig, ProjectConfig, RenderConfig DEFAULT_DBT_ROOT_PATH = Path(__file__).parent / "dbt" DBT_ROOT_PATH = Path(os.getenv("DBT_ROOT_PATH", DEFAULT_DBT_ROOT_PATH)) diff --git a/docs/generate_mappings.py b/docs/generate_mappings.py index 273187ba57..8955efc731 100644 --- a/docs/generate_mappings.py +++ b/docs/generate_mappings.py @@ -9,7 +9,8 @@ from typing import Type from jinja2 import Environment, FileSystemLoader -from cosmos.profiles import profile_mappings, BaseProfileMapping + +from cosmos.profiles import BaseProfileMapping, profile_mappings @dataclass diff --git a/pyproject.toml b/pyproject.toml index 8b7d57aa52..0c26fa7995 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -197,7 +197,7 @@ no_warn_unused_ignores = true [tool.ruff] line-length = 120 [tool.ruff.lint] -select = ["C901"] +select = ["C901", "I"] [tool.ruff.lint.mccabe] max-complexity = 8 diff --git a/tests/airflow/test_graph.py b/tests/airflow/test_graph.py index 16a2c7e077..1e5648306d 100644 --- a/tests/airflow/test_graph.py +++ b/tests/airflow/test_graph.py @@ -9,13 +9,13 @@ from packaging import version from cosmos.airflow.graph import ( + _snake_case_to_camelcase, build_airflow_graph, calculate_leaves, calculate_operator_class, create_task_metadata, create_test_task_metadata, generate_task_or_group, - _snake_case_to_camelcase, ) from cosmos.config import ProfileConfig, RenderConfig from cosmos.constants import ( diff --git a/tests/dbt/parser/test_output.py b/tests/dbt/parser/test_output.py index 9fae4d3b3e..4fe11669f5 100644 --- a/tests/dbt/parser/test_output.py +++ b/tests/dbt/parser/test_output.py @@ -1,13 +1,14 @@ -import pytest import logging from unittest.mock import MagicMock + +import pytest from airflow.hooks.subprocess import SubprocessResult from cosmos.dbt.parser.output import ( extract_dbt_runner_issues, extract_log_issues, - parse_number_of_warnings_subprocess, parse_number_of_warnings_dbt_runner, + parse_number_of_warnings_subprocess, ) diff --git a/tests/dbt/test_graph.py b/tests/dbt/test_graph.py index 94951d7bdd..4a0d4c98e1 100644 --- a/tests/dbt/test_graph.py +++ b/tests/dbt/test_graph.py @@ -1,12 +1,13 @@ import shutil import tempfile from pathlib import Path -from unittest.mock import patch, MagicMock -import yaml +from subprocess import PIPE, Popen +from unittest.mock import MagicMock, patch import pytest +import yaml -from cosmos.config import ExecutionConfig, ProfileConfig, ProjectConfig, RenderConfig, CosmosConfigException +from cosmos.config import CosmosConfigException, ExecutionConfig, ProfileConfig, ProjectConfig, RenderConfig from cosmos.constants import DbtResourceType, ExecutionMode from cosmos.dbt.graph import ( CosmosLoadDbtException, @@ -17,7 +18,6 @@ run_command, ) from cosmos.profiles import PostgresUserPasswordProfileMapping -from subprocess import Popen, PIPE DBT_PROJECTS_ROOT_DIR = Path(__file__).parent.parent.parent / "dev/dags/dbt" DBT_PROJECT_NAME = "jaffle_shop" diff --git a/tests/dbt/test_project.py b/tests/dbt/test_project.py index a3cd308198..6f9e2cb844 100644 --- a/tests/dbt/test_project.py +++ b/tests/dbt/test_project.py @@ -4,7 +4,7 @@ import pytest -from cosmos.dbt.project import create_symlinks, copy_msgpack_for_partial_parse, environ, change_working_directory +from cosmos.dbt.project import change_working_directory, copy_msgpack_for_partial_parse, create_symlinks, environ DBT_PROJECTS_ROOT_DIR = Path(__file__).parent.parent.parent / "dev/dags/dbt" diff --git a/tests/dbt/test_selector.py b/tests/dbt/test_selector.py index ab7842783d..bfb6d7d4ee 100644 --- a/tests/dbt/test_selector.py +++ b/tests/dbt/test_selector.py @@ -2,10 +2,9 @@ import pytest -from cosmos.dbt.selector import SelectorConfig from cosmos.constants import DbtResourceType from cosmos.dbt.graph import DbtNode -from cosmos.dbt.selector import select_nodes +from cosmos.dbt.selector import SelectorConfig, select_nodes from cosmos.exceptions import CosmosValueError SAMPLE_PROJ_PATH = Path("/home/user/path/dbt-proj/") diff --git a/tests/hooks/test_subprocess.py b/tests/hooks/test_subprocess.py index 601d37b004..e8b16d387b 100644 --- a/tests/hooks/test_subprocess.py +++ b/tests/hooks/test_subprocess.py @@ -1,7 +1,7 @@ +import signal from pathlib import Path from tempfile import TemporaryDirectory from unittest.mock import MagicMock, patch -import signal import pytest diff --git a/tests/operators/test_azure_container_instance.py b/tests/operators/test_azure_container_instance.py index da7720958a..01fa3e20e3 100644 --- a/tests/operators/test_azure_container_instance.py +++ b/tests/operators/test_azure_container_instance.py @@ -8,8 +8,8 @@ DbtAzureContainerInstanceBaseOperator, DbtLSAzureContainerInstanceOperator, DbtRunAzureContainerInstanceOperator, - DbtTestAzureContainerInstanceOperator, DbtSeedAzureContainerInstanceOperator, + DbtTestAzureContainerInstanceOperator, ) diff --git a/tests/operators/test_base.py b/tests/operators/test_base.py index 4ac78fc5a7..edaf8a8450 100644 --- a/tests/operators/test_base.py +++ b/tests/operators/test_base.py @@ -1,15 +1,16 @@ -import pytest from unittest.mock import patch +import pytest + from cosmos.operators.base import ( AbstractDbtBaseOperator, DbtBuildMixin, DbtLSMixin, - DbtSeedMixin, + DbtRunMixin, DbtRunOperationMixin, - DbtTestMixin, + DbtSeedMixin, DbtSnapshotMixin, - DbtRunMixin, + DbtTestMixin, ) diff --git a/tests/operators/test_docker.py b/tests/operators/test_docker.py index ef26fbaff0..ad3ec54852 100644 --- a/tests/operators/test_docker.py +++ b/tests/operators/test_docker.py @@ -1,7 +1,7 @@ from pathlib import Path from unittest.mock import MagicMock, patch -import pytest +import pytest from airflow.utils.context import Context from pendulum import datetime diff --git a/tests/operators/test_kubernetes.py b/tests/operators/test_kubernetes.py index 19a6c7aeb5..75739111f2 100644 --- a/tests/operators/test_kubernetes.py +++ b/tests/operators/test_kubernetes.py @@ -2,6 +2,8 @@ from unittest.mock import MagicMock, patch import pytest +from airflow.models import TaskInstance +from airflow.utils.context import Context, context_merge from pendulum import datetime from cosmos.operators.kubernetes import ( @@ -12,9 +14,6 @@ DbtTestKubernetesOperator, ) -from airflow.utils.context import Context, context_merge -from airflow.models import TaskInstance - try: from airflow.providers.cncf.kubernetes.utils.pod_manager import OnFinishAction diff --git a/tests/operators/test_local.py b/tests/operators/test_local.py index 9a938bca96..05cca4bcc9 100644 --- a/tests/operators/test_local.py +++ b/tests/operators/test_local.py @@ -1,10 +1,10 @@ import logging import os -import sys import shutil +import sys import tempfile from pathlib import Path -from unittest.mock import MagicMock, patch, call +from unittest.mock import MagicMock, call, patch import pytest from airflow import DAG @@ -16,28 +16,28 @@ from pendulum import datetime from cosmos.config import ProfileConfig +from cosmos.constants import InvocationMode +from cosmos.dbt.parser.output import ( + extract_dbt_runner_issues, + parse_number_of_warnings_dbt_runner, + parse_number_of_warnings_subprocess, +) from cosmos.operators.local import ( - DbtLocalBaseOperator, - DbtLSLocalOperator, - DbtSnapshotLocalOperator, - DbtRunLocalOperator, - DbtTestLocalOperator, DbtBuildLocalOperator, - DbtDocsLocalOperator, - DbtDocsS3LocalOperator, DbtDocsAzureStorageLocalOperator, DbtDocsGCSLocalOperator, - DbtSeedLocalOperator, + DbtDocsLocalOperator, + DbtDocsS3LocalOperator, + DbtLocalBaseOperator, + DbtLSLocalOperator, + DbtRunLocalOperator, DbtRunOperationLocalOperator, + DbtSeedLocalOperator, + DbtSnapshotLocalOperator, + DbtTestLocalOperator, ) from cosmos.profiles import PostgresUserPasswordProfileMapping -from cosmos.constants import InvocationMode from tests.utils import test_dag as run_test_dag -from cosmos.dbt.parser.output import ( - extract_dbt_runner_issues, - parse_number_of_warnings_subprocess, - parse_number_of_warnings_dbt_runner, -) DBT_PROJ_DIR = Path(__file__).parent.parent.parent / "dev/dags/dbt/jaffle_shop" MINI_DBT_PROJ_DIR = Path(__file__).parent.parent / "sample/mini" diff --git a/tests/operators/test_virtualenv.py b/tests/operators/test_virtualenv.py index 036f162de2..1610d27078 100644 --- a/tests/operators/test_virtualenv.py +++ b/tests/operators/test_virtualenv.py @@ -1,13 +1,11 @@ -from unittest.mock import patch, MagicMock - -from cosmos.operators.virtualenv import DbtVirtualenvBaseOperator +from unittest.mock import MagicMock, patch from airflow.models.connection import Connection from cosmos.config import ProfileConfig - -from cosmos.profiles import PostgresUserPasswordProfileMapping from cosmos.constants import InvocationMode +from cosmos.operators.virtualenv import DbtVirtualenvBaseOperator +from cosmos.profiles import PostgresUserPasswordProfileMapping profile_config = ProfileConfig( profile_name="default", diff --git a/tests/perf/test_performance.py b/tests/perf/test_performance.py index 81b08d8bd8..995c33a740 100644 --- a/tests/perf/test_performance.py +++ b/tests/perf/test_performance.py @@ -1,9 +1,9 @@ from __future__ import annotations -import time import os -from pathlib import Path +import time from contextlib import contextmanager +from pathlib import Path from typing import Generator try: diff --git a/tests/plugin/test_plugin.py b/tests/plugin/test_plugin.py index df33ae13ac..8d0e3742fd 100644 --- a/tests/plugin/test_plugin.py +++ b/tests/plugin/test_plugin.py @@ -7,15 +7,15 @@ try: import flask # noqa: F401 except ImportError: - import markupsafe import jinja2 + import markupsafe jinja2.Markup = markupsafe.Markup jinja2.escape = markupsafe.escape -from unittest.mock import mock_open, patch, MagicMock, PropertyMock - import sys +from unittest.mock import MagicMock, PropertyMock, mock_open, patch + import pytest from airflow.configuration import conf from airflow.exceptions import AirflowConfigException @@ -25,18 +25,16 @@ from flask.testing import FlaskClient import cosmos.plugin - from cosmos.plugin import ( dbt_docs_view, iframe_script, - open_gcs_file, open_azure_file, + open_file, + open_gcs_file, open_http_file, open_s3_file, - open_file, ) - original_conf_get = conf.get diff --git a/tests/profiles/athena/test_athena_access_key.py b/tests/profiles/athena/test_athena_access_key.py index c224a9d4b8..71ba1eb05d 100644 --- a/tests/profiles/athena/test_athena_access_key.py +++ b/tests/profiles/athena/test_athena_access_key.py @@ -1,9 +1,10 @@ "Tests for the Athena profile." import json -from collections import namedtuple import sys +from collections import namedtuple from unittest.mock import MagicMock, patch + import pytest from airflow.models.connection import Connection diff --git a/tests/profiles/bigquery/test_bq_service_account_keyfile_dict.py b/tests/profiles/bigquery/test_bq_service_account_keyfile_dict.py index 0647e68000..00cf070c3d 100644 --- a/tests/profiles/bigquery/test_bq_service_account_keyfile_dict.py +++ b/tests/profiles/bigquery/test_bq_service_account_keyfile_dict.py @@ -3,8 +3,8 @@ import pytest from airflow.models.connection import Connection -from cosmos.exceptions import CosmosValueError +from cosmos.exceptions import CosmosValueError from cosmos.profiles import get_automatic_profile_mapping from cosmos.profiles.bigquery.service_account_keyfile_dict import GoogleCloudServiceAccountDictProfileMapping diff --git a/tests/profiles/test_base_profile.py b/tests/profiles/test_base_profile.py index b80912bcd8..7fdbdb886a 100644 --- a/tests/profiles/test_base_profile.py +++ b/tests/profiles/test_base_profile.py @@ -1,12 +1,13 @@ from __future__ import annotations + from typing import Any import pytest import yaml from pydantic.error_wrappers import ValidationError -from cosmos.profiles.base import BaseProfileMapping, DbtProfileConfigVars from cosmos.exceptions import CosmosValueError +from cosmos.profiles.base import BaseProfileMapping, DbtProfileConfigVars class TestProfileMapping(BaseProfileMapping): diff --git a/tests/profiles/trino/test_trino_base.py b/tests/profiles/trino/test_trino_base.py index 29e4026914..31f1a31666 100644 --- a/tests/profiles/trino/test_trino_base.py +++ b/tests/profiles/trino/test_trino_base.py @@ -1,7 +1,7 @@ "Tests for the Trino profile." -from unittest.mock import patch import json +from unittest.mock import patch from airflow.models.connection import Connection diff --git a/tests/profiles/trino/test_trino_jwt.py b/tests/profiles/trino/test_trino_jwt.py index d62fefc471..36d2d21530 100644 --- a/tests/profiles/trino/test_trino_jwt.py +++ b/tests/profiles/trino/test_trino_jwt.py @@ -6,8 +6,7 @@ import pytest from airflow.models.connection import Connection -from cosmos.profiles import get_automatic_profile_mapping -from cosmos.profiles import TrinoJWTProfileMapping +from cosmos.profiles import TrinoJWTProfileMapping, get_automatic_profile_mapping @pytest.fixture() diff --git a/tests/profiles/trino/test_trino_ldap.py b/tests/profiles/trino/test_trino_ldap.py index a959e9e68a..f8adddf933 100644 --- a/tests/profiles/trino/test_trino_ldap.py +++ b/tests/profiles/trino/test_trino_ldap.py @@ -5,8 +5,7 @@ import pytest from airflow.models.connection import Connection -from cosmos.profiles import get_automatic_profile_mapping -from cosmos.profiles import TrinoLDAPProfileMapping +from cosmos.profiles import TrinoLDAPProfileMapping, get_automatic_profile_mapping @pytest.fixture() diff --git a/tests/test_config.py b/tests/test_config.py index b93ad26275..acca546beb 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,14 +1,13 @@ +from contextlib import nullcontext as does_not_raise from pathlib import Path from unittest.mock import patch -from cosmos.profiles.postgres.user_pass import PostgresUserPasswordProfileMapping -from contextlib import nullcontext as does_not_raise import pytest +from cosmos.config import CosmosConfigException, ExecutionConfig, ProfileConfig, ProjectConfig, RenderConfig from cosmos.constants import ExecutionMode, InvocationMode -from cosmos.config import ExecutionConfig, ProfileConfig, ProjectConfig, RenderConfig, CosmosConfigException from cosmos.exceptions import CosmosValueError - +from cosmos.profiles.postgres.user_pass import PostgresUserPasswordProfileMapping DBT_PROJECTS_ROOT_DIR = Path(__file__).parent / "sample/" SAMPLE_PROFILE_YML = Path(__file__).parent / "sample/profiles.yml" diff --git a/tests/test_converter.py b/tests/test_converter.py index e66af468f1..f5dfe68b9d 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -1,17 +1,16 @@ from datetime import datetime from pathlib import Path -from unittest.mock import patch, MagicMock -from cosmos.profiles.postgres import PostgresUserPasswordProfileMapping +from unittest.mock import MagicMock, patch import pytest from airflow.models import DAG +from cosmos.config import CosmosConfigException, ExecutionConfig, ProfileConfig, ProjectConfig, RenderConfig +from cosmos.constants import DbtResourceType, ExecutionMode, InvocationMode, LoadMode from cosmos.converter import DbtToAirflowConverter, validate_arguments, validate_initial_user_config -from cosmos.constants import DbtResourceType, ExecutionMode, LoadMode, InvocationMode -from cosmos.config import ProjectConfig, ProfileConfig, ExecutionConfig, RenderConfig, CosmosConfigException from cosmos.dbt.graph import DbtNode from cosmos.exceptions import CosmosValueError - +from cosmos.profiles.postgres import PostgresUserPasswordProfileMapping SAMPLE_PROFILE_YML = Path(__file__).parent / "sample/profiles.yml" SAMPLE_DBT_PROJECT = Path(__file__).parent / "sample/" diff --git a/tests/test_example_dags_no_connections.py b/tests/test_example_dags_no_connections.py index ae7c354a1d..f5b5c0845b 100644 --- a/tests/test_example_dags_no_connections.py +++ b/tests/test_example_dags_no_connections.py @@ -13,7 +13,6 @@ from dbt.version import get_installed_version as get_dbt_version from packaging.version import Version - EXAMPLE_DAGS_DIR = Path(__file__).parent.parent / "dev/dags" AIRFLOW_IGNORE_FILE = EXAMPLE_DAGS_DIR / ".airflowignore" DBT_VERSION = Version(get_dbt_version().to_version_string()[1:]) diff --git a/tests/test_log.py b/tests/test_log.py index d145dbca49..c110c19181 100644 --- a/tests/test_log.py +++ b/tests/test_log.py @@ -1,8 +1,9 @@ import logging +from airflow.configuration import conf + from cosmos import get_provider_info from cosmos.log import get_logger -from airflow.configuration import conf def test_get_logger(): From b184644e4d68a1ba863916e75dbae7b7d24bf35b Mon Sep 17 00:00:00 2001 From: FouziaTariq <86288319+FouziaTariq@users.noreply.github.com> Date: Thu, 29 Feb 2024 14:46:55 +0100 Subject: [PATCH 112/223] Make PostgresUserPasswordProfileMapping schema argument optional (#683) Made PostgresUserPasswordProfileMapping schema profile argument optional Closes: #675 Co-authored-by: Tatiana Al-Chueyr --- cosmos/profiles/postgres/user_pass.py | 14 ++- tests/dbt/test_graph.py | 2 +- tests/profiles/postgres/test_pg_user_pass.py | 4 +- tests/sample/manifest_source.json | 116 +++++++++++++++++-- 4 files changed, 116 insertions(+), 20 deletions(-) diff --git a/cosmos/profiles/postgres/user_pass.py b/cosmos/profiles/postgres/user_pass.py index 731d600794..a081ff81a0 100644 --- a/cosmos/profiles/postgres/user_pass.py +++ b/cosmos/profiles/postgres/user_pass.py @@ -21,7 +21,6 @@ class PostgresUserPasswordProfileMapping(BaseProfileMapping): "user", "password", "dbname", - "schema", ] secret_fields = [ "password", @@ -47,14 +46,19 @@ def profile(self) -> dict[str, Any | None]: "password": self.get_env_var_format("password"), } + if "schema" in self.profile_args: + profile["schema"] = self.profile_args["schema"] + return self.filter_null(profile) @property def mock_profile(self) -> dict[str, Any | None]: "Gets mock profile. Defaults port to 5432." - parent_mock = super().mock_profile - - return { + profile_dict = { "port": 5432, - **parent_mock, + **super().mock_profile, } + user_defined_schema = self.profile_args.get("schema") + if user_defined_schema: + profile_dict["schema"] = user_defined_schema + return profile_dict diff --git a/tests/dbt/test_graph.py b/tests/dbt/test_graph.py index 4a0d4c98e1..f72bbb146a 100644 --- a/tests/dbt/test_graph.py +++ b/tests/dbt/test_graph.py @@ -457,7 +457,7 @@ def test_load_via_dbt_ls_with_sources(load_method): ), ) getattr(dbt_graph, load_method)() - assert len(dbt_graph.nodes) == 4 + assert len(dbt_graph.nodes) >= 4 assert "source.simple.main.movies_ratings" in dbt_graph.nodes assert "exposure.simple.weekly_metrics" in dbt_graph.nodes diff --git a/tests/profiles/postgres/test_pg_user_pass.py b/tests/profiles/postgres/test_pg_user_pass.py index 3492450b5e..174fff1391 100644 --- a/tests/profiles/postgres/test_pg_user_pass.py +++ b/tests/profiles/postgres/test_pg_user_pass.py @@ -83,9 +83,9 @@ def test_connection_claiming() -> None: assert not profile_mapping.can_claim_connection() # also test when there's no schema - conn = Connection(**potential_values) # type: ignore + conn = Connection(**{k: v for k, v in potential_values.items() if k != "schema"}) with patch("airflow.hooks.base.BaseHook.get_connection", return_value=conn): - profile_mapping = PostgresUserPasswordProfileMapping(conn, {}) + profile_mapping = PostgresUserPasswordProfileMapping(conn, {"schema": None}) assert not profile_mapping.can_claim_connection() # if we have them all, it should claim diff --git a/tests/sample/manifest_source.json b/tests/sample/manifest_source.json index 9a84180f59..67f57035ef 100644 --- a/tests/sample/manifest_source.json +++ b/tests/sample/manifest_source.json @@ -8,8 +8,10 @@ "exposure.simple.weekly_metrics" ], "source.simple.main.movies_ratings": [ - "model.simple.movies_ratings_simplified" - ] + "model.simple.movies_ratings_simplified", + "test.simple.source_not_null_imdb_movies_ratings_X.e684bf90f4" + ], + "test.simple.source_not_null_imdb_movies_ratings_X.e684bf90f4": [] }, "disabled": {}, "docs": { @@ -28,7 +30,7 @@ "config": { "enabled": true }, - "created_at": 1696859933.549042, + "created_at": 1697205180.995924, "depends_on": { "macros": [], "nodes": [ @@ -1016,7 +1018,7 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro dates_in_range(start_date_str, end_date_str=none, in_fmt=\"%Y%m%d\", out_fmt=\"%Y%m%d\") %}\n {% set end_date_str = start_date_str if end_date_str is none else end_date_str %}\n\n {% set start_date = convert_datetime(start_date_str, in_fmt) %}\n {% set end_date = convert_datetime(end_date_str, in_fmt) %}\n\n {% set day_count = (end_date - start_date).days %}\n {% if day_count < 0 %}\n {% set msg -%}\n Partition start date is after the end date ({{ start_date }}, {{ end_date }})\n {%- endset %}\n\n {{ exceptions.raise_compiler_error(msg, model) }}\n {% endif %}\n\n {% set date_list = [] %}\n {% for i in range(0, day_count + 1) %}\n {% set the_date = (modules.datetime.timedelta(days=i) + start_date) %}\n {% if not out_fmt %}\n {% set _ = date_list.append(the_date) %}\n {% else %}\n {% set _ = date_list.append(the_date.strftime(out_fmt)) %}\n {% endif %}\n {% endfor %}\n\n {{ return(date_list) }}\n{% endmacro %}", + "macro_sql": "{% macro dates_in_range(start_date_str, end_date_str=none, in_fmt=\"%Y%m%d\", out_fmt=\"%Y%m%d\") %}\n {% set end_date_str = start_date_str if end_date_str is none else end_date_str %}\n\n {% set start_date = convert_datetime(start_date_str, in_fmt) %}\n {% set end_date = convert_datetime(end_date_str, in_fmt) %}\n\n {% set day_count = (end_date - start_date).days %}\n {% if day_count < 0 %}\n {% set msg -%}\n Partiton start date is after the end date ({{ start_date }}, {{ end_date }})\n {%- endset %}\n\n {{ exceptions.raise_compiler_error(msg, model) }}\n {% endif %}\n\n {% set date_list = [] %}\n {% for i in range(0, day_count + 1) %}\n {% set the_date = (modules.datetime.timedelta(days=i) + start_date) %}\n {% if not out_fmt %}\n {% set _ = date_list.append(the_date) %}\n {% else %}\n {% set _ = date_list.append(the_date.strftime(out_fmt)) %}\n {% endif %}\n {% endfor %}\n\n {{ return(date_list) }}\n{% endmacro %}", "meta": {}, "name": "dates_in_range", "original_file_path": "macros/etc/datetime.sql", @@ -6945,8 +6947,8 @@ "dbt_schema_version": "https://schemas.getdbt.com/dbt/manifest/v8.json", "dbt_version": "1.4.0", "env": {}, - "generated_at": "2023-10-09T13:58:53.320926Z", - "invocation_id": "1790dc18-1177-4ca0-b993-a8eeb59f0c4c", + "generated_at": "2023-10-13T13:58:46.591195Z", + "invocation_id": "4f4b6d15-b4ab-4683-bbe5-efd94824b1d9", "project_id": "8dbdda48fb8748d6746f1965824e966a", "send_anonymous_usage_stats": true, "user_id": "4bdc3972-5c9f-4f16-90bd-3769a225fbe6" @@ -6986,7 +6988,7 @@ "tags": [], "unique_key": null }, - "created_at": 1696859933.5317938, + "created_at": 1697205180.9909241, "database": "database", "deferred": false, "depends_on": { @@ -7062,7 +7064,7 @@ "tags": [], "unique_key": null }, - "created_at": 1696859933.537527, + "created_at": 1697205180.984051, "database": "database", "deferred": false, "depends_on": { @@ -7103,6 +7105,84 @@ "unrendered_config": { "materialized": "table" } + }, + "test.simple.source_not_null_imdb_movies_ratings_X.e684bf90f4": { + "alias": "source_not_null_imdb_movies_ratings_X", + "build_path": null, + "checksum": { + "checksum": "", + "name": "none" + }, + "column_name": "X", + "columns": {}, + "compiled_path": null, + "config": { + "alias": null, + "database": null, + "enabled": true, + "error_if": "!= 0", + "fail_calc": "count(*)", + "limit": null, + "materialized": "test", + "meta": {}, + "schema": "dbt_test__audit", + "severity": "ERROR", + "store_failures": null, + "tags": [], + "warn_if": "!= 0", + "where": null + }, + "created_at": 1697205181.009168, + "database": "database", + "deferred": false, + "depends_on": { + "macros": [ + "macro.dbt.test_not_null" + ], + "nodes": [ + "source.simple.main.movies_ratings" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "file_key_name": "sources.imdb", + "fqn": [ + "simple", + "source_not_null_imdb_movies_ratings_X" + ], + "language": "sql", + "meta": {}, + "metrics": [], + "name": "source_not_null_imdb_movies_ratings_X", + "original_file_path": "models/source.yml", + "package_name": "simple", + "patch_path": null, + "path": "source_not_null_imdb_movies_ratings_X.sql", + "raw_code": "{{ test_not_null(**_dbt_generic_test_kwargs) }}", + "refs": [], + "relation_name": null, + "resource_type": "test", + "schema": "main_dbt_test__audit", + "sources": [ + [ + "imdb", + "movies_ratings" + ] + ], + "tags": [], + "test_metadata": { + "kwargs": { + "column_name": "X", + "model": "{{ get_where_subquery(source('imdb', 'movies_ratings')) }}" + }, + "name": "not_null", + "namespace": null + }, + "unique_id": "test.simple.source_not_null_imdb_movies_ratings_X.e684bf90f4", + "unrendered_config": {} } }, "parent_map": { @@ -7115,18 +7195,30 @@ "model.simple.top_animations": [ "model.simple.movies_ratings_simplified" ], - "source.simple.main.movies_ratings": [] + "source.simple.main.movies_ratings": [], + "test.simple.source_not_null_imdb_movies_ratings_X.e684bf90f4": [ + "source.simple.main.movies_ratings" + ] }, "selectors": {}, "sources": { "source.simple.main.movies_ratings": { - "columns": {}, + "columns": { + "X": { + "data_type": null, + "description": "", + "meta": {}, + "name": "X", + "quote": null, + "tags": [] + } + }, "config": { "enabled": true }, - "created_at": 1696859933.549542, + "created_at": 1697205181.0098429, "database": "database", - "description": "Ratings by movie", + "description": "Ratings by movie\n", "external": null, "fqn": [ "simple", From 8d3667e0592a0ad04592f9de8df2ececae0e3e1e Mon Sep 17 00:00:00 2001 From: Daniel Reeves <31971762+dwreeves@users.noreply.github.com> Date: Thu, 29 Feb 2024 18:32:44 -0500 Subject: [PATCH 113/223] Add more template fields to `DbtBaseOperator` (#786) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This partially addresses #754 via allowing for built-in templating support for the `DbtBaseOperator`. I also noticed `--full-refresh` was not documented so I added that in. ## Still missing Manual run pattern is not documented; the fact that these fields are templated is not documented. I don't really know where in the docs to put this. The docs are very API-focused more than narrative-based or suggestive, and Cosmos's maintainers prefer this style of documentation so it's hard to find a spot for this. It's possible that that's fine and we just keep this as a feature for more advanced users who dig into source code to discover for themselves? 🤷 Co-authored-by: Tatiana Al-Chueyr --- cosmos/operators/base.py | 45 ++++++++++++++++++++++++---- cosmos/operators/docker.py | 2 ++ cosmos/operators/kubernetes.py | 2 ++ cosmos/operators/local.py | 2 ++ cosmos/operators/virtualenv.py | 2 +- docs/configuration/operator-args.rst | 31 +++++++++++++++++++ tests/operators/test_base.py | 6 ++-- tests/operators/test_local.py | 15 ++++++++-- 8 files changed, 95 insertions(+), 10 deletions(-) diff --git a/cosmos/operators/base.py b/cosmos/operators/base.py index e94cae05f0..94d5d4a8ca 100644 --- a/cosmos/operators/base.py +++ b/cosmos/operators/base.py @@ -8,6 +8,7 @@ from airflow.models.baseoperator import BaseOperator from airflow.utils.context import Context from airflow.utils.operator_helpers import context_to_airflow_vars +from airflow.utils.strings import to_boolean from cosmos.dbt.executable import get_system_dbt from cosmos.log import get_logger @@ -61,7 +62,7 @@ class AbstractDbtBaseOperator(BaseOperator, metaclass=ABCMeta): :param dbt_cmd_global_flags: List of dbt global flags to be passed to the dbt command """ - template_fields: Sequence[str] = ("env", "vars") + template_fields: Sequence[str] = ("env", "select", "exclude", "selector", "vars", "models") global_flags = ( "project_dir", "select", @@ -253,6 +254,26 @@ class DbtBuildMixin: base_cmd = ["build"] ui_color = "#8194E0" + template_fields: Sequence[str] = ("full_refresh",) + + def __init__(self, full_refresh: bool | str = False, **kwargs: Any) -> None: + self.full_refresh = full_refresh + super().__init__(**kwargs) + + def add_cmd_flags(self) -> list[str]: + flags = [] + + if isinstance(self.full_refresh, str): + # Handle template fields when render_template_as_native_obj=False + full_refresh = to_boolean(self.full_refresh) + else: + full_refresh = self.full_refresh + + if full_refresh is True: + flags.append("--full-refresh") + + return flags + class DbtLSMixin: """ @@ -275,13 +296,20 @@ class DbtSeedMixin: template_fields: Sequence[str] = ("full_refresh",) - def __init__(self, full_refresh: bool = False, **kwargs: Any) -> None: + def __init__(self, full_refresh: bool | str = False, **kwargs: Any) -> None: self.full_refresh = full_refresh super().__init__(**kwargs) def add_cmd_flags(self) -> list[str]: flags = [] - if self.full_refresh is True: + + if isinstance(self.full_refresh, str): + # Handle template fields when render_template_as_native_obj=False + full_refresh = to_boolean(self.full_refresh) + else: + full_refresh = self.full_refresh + + if full_refresh is True: flags.append("--full-refresh") return flags @@ -307,13 +335,20 @@ class DbtRunMixin: template_fields: Sequence[str] = ("full_refresh",) - def __init__(self, full_refresh: bool = False, **kwargs: Any) -> None: + def __init__(self, full_refresh: bool | str = False, **kwargs: Any) -> None: self.full_refresh = full_refresh super().__init__(**kwargs) def add_cmd_flags(self) -> list[str]: flags = [] - if self.full_refresh is True: + + if isinstance(self.full_refresh, str): + # Handle template fields when render_template_as_native_obj=False + full_refresh = to_boolean(self.full_refresh) + else: + full_refresh = self.full_refresh + + if full_refresh is True: flags.append("--full-refresh") return flags diff --git a/cosmos/operators/docker.py b/cosmos/operators/docker.py index 5be03fad7d..532de380e7 100644 --- a/cosmos/operators/docker.py +++ b/cosmos/operators/docker.py @@ -69,6 +69,8 @@ class DbtBuildDockerOperator(DbtBuildMixin, DbtDockerBaseOperator): Executes a dbt core build command. """ + template_fields: Sequence[str] = DbtDockerBaseOperator.template_fields + DbtBuildMixin.template_fields # type: ignore[operator] + class DbtLSDockerOperator(DbtLSMixin, DbtDockerBaseOperator): """ diff --git a/cosmos/operators/kubernetes.py b/cosmos/operators/kubernetes.py index e314b7c439..14bcbcb84c 100644 --- a/cosmos/operators/kubernetes.py +++ b/cosmos/operators/kubernetes.py @@ -102,6 +102,8 @@ class DbtBuildKubernetesOperator(DbtBuildMixin, DbtKubernetesBaseOperator): Executes a dbt core build command. """ + template_fields: Sequence[str] = DbtKubernetesBaseOperator.template_fields + DbtBuildMixin.template_fields # type: ignore[operator] + class DbtLSKubernetesOperator(DbtLSMixin, DbtKubernetesBaseOperator): """ diff --git a/cosmos/operators/local.py b/cosmos/operators/local.py index 1d9f61c57b..924ddd0526 100644 --- a/cosmos/operators/local.py +++ b/cosmos/operators/local.py @@ -468,6 +468,8 @@ class DbtBuildLocalOperator(DbtBuildMixin, DbtLocalBaseOperator): Executes a dbt core build command. """ + template_fields: Sequence[str] = DbtLocalBaseOperator.template_fields + DbtBuildMixin.template_fields # type: ignore[operator] + class DbtLSLocalOperator(DbtLSMixin, DbtLocalBaseOperator): """ diff --git a/cosmos/operators/virtualenv.py b/cosmos/operators/virtualenv.py index cdf62a1d57..7b4d4e4ff5 100644 --- a/cosmos/operators/virtualenv.py +++ b/cosmos/operators/virtualenv.py @@ -105,7 +105,7 @@ def execute(self, context: Context) -> None: logger.info(output) -class DbtBuildVirtualenvOperator(DbtVirtualenvBaseOperator, DbtBuildLocalOperator): +class DbtBuildVirtualenvOperator(DbtVirtualenvBaseOperator, DbtBuildLocalOperator): # type: ignore[misc] """ Executes a dbt core build command within a Python Virtual Environment, that is created before running the dbt command and deleted just after. diff --git a/docs/configuration/operator-args.rst b/docs/configuration/operator-args.rst index 5ddbe6565a..4e6a40b7fc 100644 --- a/docs/configuration/operator-args.rst +++ b/docs/configuration/operator-args.rst @@ -54,6 +54,7 @@ dbt-related - ``quiet``: run ``dbt`` in silent mode, only displaying its error logs. - ``vars``: (Deprecated since Cosmos 1.3 use ``ProjectConfig.dbt_vars`` instead) Supply variables to the project. This argument overrides variables defined in the ``dbt_project.yml``. - ``warn_error``: convert ``dbt`` warnings into errors. +- ``full_refresh``: If True, then full refresh the node. This only applies to model and seed nodes. Airflow-related ............... @@ -88,3 +89,33 @@ Sample usage "skip_exit_code": 1, } ) + + +Template fields +--------------- + +Some of the operator args are `template fields `_ for your convenience. + +These template fields can be useful for hooking into Airflow `Params `_, or for more advanced customization with `XComs `_. + +The following operator args support templating, and are accessible both through the ``DbtDag`` and ``DbtTaskGroup`` constructors in addition to being accessible standalone: + +- ``env`` +- ``vars`` +- ``full_refresh`` (for the ``build``, ``seed``, and ``run`` operators since Cosmos 1.4.) + +.. note:: + Using Jinja templating for ``env`` and ``vars`` may cause problems when using ``LoadMode.DBT_LS`` to render your DAG. + +The following template fields are only selectable when using the operators in a standalone context (starting in Cosmos 1.4): + +- ``select`` +- ``exclude`` +- ``selector`` +- ``models`` + +Since Airflow resolves template fields during Airflow DAG execution and not DAG parsing, the args above cannot be templated via ``DbtDag`` and ``DbtTaskGroup`` because both need to select dbt nodes during DAG parsing. + +Additionally, the SQL for compiled dbt models is stored in the template fields, which is viewable in the Airflow UI for each task run. +This is provided for telemetry on task execution, and is not an operator arg. +For more information about this, see the `Compiled SQL `_ docs. diff --git a/tests/operators/test_base.py b/tests/operators/test_base.py index edaf8a8450..5761d66aa3 100644 --- a/tests/operators/test_base.py +++ b/tests/operators/test_base.py @@ -51,8 +51,10 @@ def test_dbt_mixin_base_cmd(dbt_command, dbt_operator_class): assert [dbt_command] == dbt_operator_class.base_cmd -@pytest.mark.parametrize("dbt_operator_class", [DbtSeedMixin, DbtRunMixin]) -@pytest.mark.parametrize("full_refresh, expected_flags", [(True, ["--full-refresh"]), (False, [])]) +@pytest.mark.parametrize("dbt_operator_class", [DbtSeedMixin, DbtRunMixin, DbtBuildMixin]) +@pytest.mark.parametrize( + "full_refresh, expected_flags", [("True", ["--full-refresh"]), (True, ["--full-refresh"]), (False, [])] +) def test_dbt_mixin_add_cmd_flags_full_refresh(full_refresh, expected_flags, dbt_operator_class): dbt_mixin = dbt_operator_class(full_refresh=full_refresh) flags = dbt_mixin.add_cmd_flags() diff --git a/tests/operators/test_local.py b/tests/operators/test_local.py index 05cca4bcc9..c054cf4d19 100644 --- a/tests/operators/test_local.py +++ b/tests/operators/test_local.py @@ -567,6 +567,7 @@ def test_store_compiled_sql() -> None: "operator_class,kwargs,expected_call_kwargs", [ (DbtSeedLocalOperator, {"full_refresh": True}, {"context": {}, "cmd_flags": ["--full-refresh"]}), + (DbtBuildLocalOperator, {"full_refresh": True}, {"context": {}, "cmd_flags": ["--full-refresh"]}), (DbtRunLocalOperator, {"full_refresh": True}, {"context": {}, "cmd_flags": ["--full-refresh"]}), ( DbtTestLocalOperator, @@ -650,8 +651,18 @@ def test_calculate_openlineage_events_completes_openlineage_errors(mock_processo @pytest.mark.parametrize( "operator_class,expected_template", [ - (DbtSeedLocalOperator, ("env", "vars", "compiled_sql", "full_refresh")), - (DbtRunLocalOperator, ("env", "vars", "compiled_sql", "full_refresh")), + ( + DbtSeedLocalOperator, + ("env", "select", "exclude", "selector", "vars", "models", "compiled_sql", "full_refresh"), + ), + ( + DbtRunLocalOperator, + ("env", "select", "exclude", "selector", "vars", "models", "compiled_sql", "full_refresh"), + ), + ( + DbtBuildLocalOperator, + ("env", "select", "exclude", "selector", "vars", "models", "compiled_sql", "full_refresh"), + ), ], ) def test_dbt_base_operator_template_fields(operator_class, expected_template): From 3083b0f95eac54269dd9229c6376adbc3849f4e2 Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Fri, 1 Mar 2024 13:49:33 +0000 Subject: [PATCH 114/223] Release 1.4.0a1 (#864) Features * Add dbt docs natively in Airflow via plugin by @dwreeves in #737 * Add support for ``InvocationMode.DBT_RUNNER`` for local execution mode by @jbandoro in #850 * Support partial parsing to render DAGs faster when using ``ExecutionMode.LOCAL``, ``ExecutionMode.VIRTUALENV`` and ``LoadMode.DBT_LS`` by @dwreeves in #800 * Add Azure Container Instance as Execution Mode by @danielvdende in #771 * Add dbt build operators by @dylanharper-qz in #795 * Add dbt profile config variables to mapped profile by @ykuc in #794 * Add more template fields to ``DbtBaseOperator`` by @dwreeves in #786 Bug fixes * Make ``PostgresUserPasswordProfileMapping`` schema argument optional by @FouziaTariq in #683 * Fix ``folder_dir`` not showing on logs for ``DbtDocsS3LocalOperator`` by @PrimOox in #856 * Improve ``dbt ls`` parsing resilience to missing tags/config by @tatiana in #859 * Fix ``operator_args`` modified in place in Airflow converter by @jbandoro in #835 * Fix Docker and Kubernetes operators execute method resolution by @jbandoro in #849 Docs * Fix docs homepage link by @jlaneve in #860 * Fix docs ``ExecutionConfig.dbt_project_path`` by @jbandoro in #847 * Fix typo in MWAA getting started guide by @jlaneve in #846 Others * Add performance integration tests by @jlaneve in #827 * Add ``connect_retries`` to databricks profile to fix expensive integration failures by @jbandoro in #826 * Add import sorting (isort) to Cosmos by @jbandoro in #866 * Add Python 3.11 to CI/tests by @tatiana and @jbandoro in #821, #824 and #825 * Fix failing ``test_created_pod`` for ``apache-airflow-providers-cncf-kubernetes`` after v8.0.0 update by @jbandoro in #854 * Extend ``DatabricksTokenProfileMapping`` test to include session properties by @tatiana in #858 * Fix broken integration test uncovered from Pytest 8.0 update by @jbandoro in #845 * Pre-commit hook updates in #834, #843 and #852 --- CHANGELOG.rst | 42 +++++++++++++++++++++++++++++++++++++++++- cosmos/__init__.py | 2 +- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 859f537426..6434baada0 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,7 +1,47 @@ Changelog ========= -1.3.2 (2023-01-26) +1.4.0a1 (2024-02-29) +-------------------- + +Features + +* Add dbt docs natively in Airflow via plugin by @dwreeves in #737 +* Add support for ``InvocationMode.DBT_RUNNER`` for local execution mode by @jbandoro in #850 +* Support partial parsing to render DAGs faster when using ``ExecutionMode.LOCAL``, ``ExecutionMode.VIRTUALENV`` and ``LoadMode.DBT_LS`` by @dwreeves in #800 +* Add Azure Container Instance as Execution Mode by @danielvdende in #771 +* Add dbt build operators by @dylanharper-qz in #795 +* Add dbt profile config variables to mapped profile by @ykuc in #794 +* Add more template fields to ``DbtBaseOperator`` by @dwreeves in #786 + +Bug fixes + +* Make ``PostgresUserPasswordProfileMapping`` schema argument optional by @FouziaTariq in #683 +* Fix ``folder_dir`` not showing on logs for ``DbtDocsS3LocalOperator`` by @PrimOox in #856 +* Improve ``dbt ls`` parsing resilience to missing tags/config by @tatiana in #859 +* Fix ``operator_args`` modified in place in Airflow converter by @jbandoro in #835 +* Fix Docker and Kubernetes operators execute method resolution by @jbandoro in #849 + +Docs + +* Fix docs homepage link by @jlaneve in #860 +* Fix docs ``ExecutionConfig.dbt_project_path`` by @jbandoro in #847 +* Fix typo in MWAA getting started guide by @jlaneve in #846 + +Others + +* Add performance integration tests by @jlaneve in #827 +* Add ``connect_retries`` to databricks profile to fix expensive integration failures by @jbandoro in #826 +* Add import sorting (isort) to Cosmos by @jbandoro in #866 +* Add Python 3.11 to CI/tests by @tatiana and @jbandoro in #821, #824 and #825 +* Fix failing ``test_created_pod`` for ``apache-airflow-providers-cncf-kubernetes`` after v8.0.0 update by @jbandoro in #854 +* Extend ``DatabricksTokenProfileMapping`` test to include session properties by @tatiana in #858 +* Fix broken integration test uncovered from Pytest 8.0 update by @jbandoro in #845 +* Pre-commit hook updates in #834, #843 and #852 + + + +1.3.2 (2024-01-26) ------------------ Bug fixes diff --git a/cosmos/__init__.py b/cosmos/__init__.py index 859995fe61..c9de9971ee 100644 --- a/cosmos/__init__.py +++ b/cosmos/__init__.py @@ -5,7 +5,7 @@ Contains dags, task groups, and operators. """ -__version__ = "1.3.2" +__version__ = "1.4.0a1" from cosmos.airflow.dag import DbtDag From 44ea98de6a34c65066f7c1384c8803193272469b Mon Sep 17 00:00:00 2001 From: Daniel Reeves <31971762+dwreeves@users.noreply.github.com> Date: Tue, 5 Mar 2024 05:46:34 -0500 Subject: [PATCH 115/223] Improve dbt docs plugin rendering padding (#876) In my testing, I've found that the padding for the search box is a little under-shot. This PR just adds a teeny bit more padding. --- cosmos/plugin/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cosmos/plugin/__init__.py b/cosmos/plugin/__init__.py index 48061b254b..b82b29f5c8 100644 --- a/cosmos/plugin/__init__.py +++ b/cosmos/plugin/__init__.py @@ -136,7 +136,7 @@ def open_file(path: str) -> str: // Model page getMaxElement('bottom', 'section.section') + 75, // Search page - getMaxElement('bottom', 'div.result-body') + 110 + getMaxElement('bottom', 'div.result-body') + 125 ) } } From 6a8f3cea82f4f16ef4d6cc225b22e2435b14f6f3 Mon Sep 17 00:00:00 2001 From: Daniel Reeves <31971762+dwreeves@users.noreply.github.com> Date: Wed, 6 Mar 2024 10:50:41 -0500 Subject: [PATCH 116/223] Update docs regarding docs plugin in Astronomer cloud (#874) Resolves #871 Turns out, there is a lot of nuance in regards to how Astronomer interacts with stuff under the hood. --- docs/configuration/hosting-docs.rst | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/docs/configuration/hosting-docs.rst b/docs/configuration/hosting-docs.rst index 5143a9f67f..2ab4fdf69e 100644 --- a/docs/configuration/hosting-docs.rst +++ b/docs/configuration/hosting-docs.rst @@ -105,11 +105,29 @@ For example, if your dbt project directory is ``/usr/local/airflow/dags/my_dbt_p AIRFLOW__COSMOS__DBT_DOCS_DIR="/usr/local/airflow/dags/my_dbt_project/target" -Using docs out of local storage has the downside that some values in the dbt docs can become stale unless the docs are periodically refreshed and redeployed: +Using docs out of local storage has a couple downsides. First, some values in the dbt docs can become stale, unless the docs are periodically refreshed and redeployed: - Counts of the numbers of rows. - The compiled SQL for incremental models before and after the first run. +Second, deployment from local storage may only be partially compatible with some managed Airflow systems. +Compatibility will depend on the managed Airflow system, as each one works differently. + +For example, Astronomer does not update the resources available to the webserver instance when ``--dags`` is specified during deployment, meaning that the dbt dcs will not be updated when this flag is used. + +.. note:: + Managed Airflow on Astronomer Cloud does not provide the webserver access to the DAGs folder. + If you want to host your docs in local storage with Astro, you should host them in a directory other than ``dags/``. + For example, you can set your ``AIRFLOW__COSMOS__DBT_DOCS_DIR`` to ``/usr/local/airflow/dbt_docs_dir`` with the following pre-deployment script: + + .. code-block:: bash + + dbt docs generate + mkdir dbt_docs_dir + cp dags/dbt/target/manifest.json dbt_docs_dir/manifest.json + cp dags/dbt/target/catalog.json dbt_docs_dir/catalog.json + cp dags/dbt/target/index.html dbt_docs_dir/index.html + Host from HTTP/HTTPS ~~~~~~~~~~~~~~~~~~~~ From 06b4952f9430bd9443ff63e875bcfc6de21938b0 Mon Sep 17 00:00:00 2001 From: Daniel Reeves <31971762+dwreeves@users.noreply.github.com> Date: Thu, 7 Mar 2024 11:01:18 -0500 Subject: [PATCH 117/223] [Bug] [1.4.0a] Fix #869 - Cosmos does not set dbt project dir to the tmp dir (#873) Fix #869. Version of dbt I was running is `1.6.4`. It's unclear to me if this is specific to that version, but I couldn't get the Dbt docs operator to work without this change. With it, it works, and I do not see any other issues. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- cosmos/operators/local.py | 2 ++ tests/operators/test_local.py | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/cosmos/operators/local.py b/cosmos/operators/local.py index 924ddd0526..e2c08697e6 100644 --- a/cosmos/operators/local.py +++ b/cosmos/operators/local.py @@ -298,6 +298,8 @@ def run_command( env.update(env_vars) flags = [ + "--project-dir", + str(tmp_project_dir), "--profiles-dir", str(profile_path.parent), "--profile", diff --git a/tests/operators/test_local.py b/tests/operators/test_local.py index c054cf4d19..6e1a6f6a92 100644 --- a/tests/operators/test_local.py +++ b/tests/operators/test_local.py @@ -703,18 +703,26 @@ def test_dbt_docs_gcs_local_operator(): @patch("cosmos.config.ProfileConfig.ensure_profile") @patch("cosmos.operators.local.DbtLocalBaseOperator.run_subprocess") @patch("cosmos.operators.local.DbtLocalBaseOperator.run_dbt_runner") +@patch("cosmos.operators.local.tempfile.TemporaryDirectory") @pytest.mark.parametrize("invocation_mode", [InvocationMode.SUBPROCESS, InvocationMode.DBT_RUNNER]) def test_operator_execute_deps_parameters( + mock_temporary_directory, mock_dbt_runner, mock_subprocess, mock_ensure_profile, mock_exception_handling, mock_store_compiled_sql, invocation_mode, + tmp_path, ): + project_dir = tmp_path / "mock_project_tmp_dir" + project_dir.mkdir() + expected_call_kwargs = [ "/usr/local/bin/dbt", "deps", + "--project-dir", + project_dir.as_posix(), "--profiles-dir", "/path/to", "--profile", @@ -732,6 +740,7 @@ def test_operator_execute_deps_parameters( invocation_mode=invocation_mode, ) mock_ensure_profile.return_value.__enter__.return_value = (Path("/path/to/profile"), {"ENV_VAR": "value"}) + mock_temporary_directory.return_value.__enter__.return_value = project_dir.as_posix() task.execute(context={"task_instance": MagicMock()}) if invocation_mode == InvocationMode.SUBPROCESS: assert mock_subprocess.call_args_list[0].kwargs["command"] == expected_call_kwargs From e7d4721edb1bc68af259d98ad4d78075575e40bc Mon Sep 17 00:00:00 2001 From: Spencer <53303191+octiva@users.noreply.github.com> Date: Fri, 8 Mar 2024 02:03:14 +1000 Subject: [PATCH 118/223] Add `pip_install_options` as an operator arg (#808) - In order to provide pip with install options when using the virtual env - When using a private server, packages need to be provided locally through plugins, adding the install options will allow us to specify where to find the links. eg `--no-index --find-links=${AIRFLOW_HOME}/plugins` - similar to how `py_requirements` is not explicitly tested in `cosmos`, we do not need to explicitly test `pip_install_options`, as it is not used by our cosmos, but allows users to modify the leveraged virtual env. --------- Co-authored-by: Spencer horton Co-authored-by: Tatiana Al-Chueyr --- cosmos/operators/virtualenv.py | 6 +++++- docs/getting_started/gcc.rst | 2 ++ tests/operators/test_virtualenv.py | 1 + 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/cosmos/operators/virtualenv.py b/cosmos/operators/virtualenv.py index 7b4d4e4ff5..3a58956324 100644 --- a/cosmos/operators/virtualenv.py +++ b/cosmos/operators/virtualenv.py @@ -38,6 +38,7 @@ class DbtVirtualenvBaseOperator(DbtLocalBaseOperator): :param py_requirements: If defined, creates a virtual environment with the specified dependencies. Example: ["dbt-postgres==1.5.0"] + :param pip_install_options: Pip options to use when installing Python dependencies. Example: ["--upgrade", "--no-cache-dir"] :param py_system_site_packages: Whether or not all the Python packages from the Airflow instance will be accessible within the virtual environment (if py_requirements argument is specified). Avoid using unless the dbt job requires it. @@ -46,10 +47,12 @@ class DbtVirtualenvBaseOperator(DbtLocalBaseOperator): def __init__( self, py_requirements: list[str] | None = None, + pip_install_options: list[str] | None = None, py_system_site_packages: bool = False, **kwargs: Any, ) -> None: self.py_requirements = py_requirements or [] + self.pip_install_options = pip_install_options or [] self.py_system_site_packages = py_system_site_packages super().__init__(**kwargs) self._venv_tmp_dir: None | TemporaryDirectory[str] = None @@ -62,7 +65,7 @@ def venv_dbt_path( Path to the dbt binary within a Python virtualenv. The first time this property is called, it creates a virtualenv and installs the dependencies based on the - self.py_requirements and self.py_system_site_packages. This value is cached for future calls. + self.py_requirements, self.pip_install_options, and self.py_system_site_packages. This value is cached for future calls. """ # We are reusing the virtualenv directory for all subprocess calls within this task/operator. # For this reason, we are not using contexts at this point. @@ -73,6 +76,7 @@ def venv_dbt_path( python_bin=PY_INTERPRETER, system_site_packages=self.py_system_site_packages, requirements=self.py_requirements, + pip_install_options=self.pip_install_options, ) dbt_binary = Path(py_interpreter).parent / "dbt" cmd_output = self.subprocess_hook.run_command( diff --git a/docs/getting_started/gcc.rst b/docs/getting_started/gcc.rst index 5baa9c37ed..ed4b931ce1 100644 --- a/docs/getting_started/gcc.rst +++ b/docs/getting_started/gcc.rst @@ -49,6 +49,8 @@ In your ``my_cosmos_dag.py`` file, import the ``DbtDag`` class from Cosmos and c Make sure to rename the ```` value below to your adapter's Python package (i.e. ``dbt-snowflake`` or ``dbt-bigquery``) +If you need to modify the pip install options, you can do so by adding ``pip_install_options`` to the ``operator_args`` dictionary. For example, if you wanted to install packages from local wheels you could set it too: ``["--no-index", "--find-links=/path/to/wheels"]``. All options can be found here: + .. code-block:: python from cosmos import DbtDag, ProjectConfig, ProfileConfig, ExecutionConfig diff --git a/tests/operators/test_virtualenv.py b/tests/operators/test_virtualenv.py index 1610d27078..5866347abe 100644 --- a/tests/operators/test_virtualenv.py +++ b/tests/operators/test_virtualenv.py @@ -50,6 +50,7 @@ def test_run_command( install_deps=True, project_dir="./dev/dags/dbt/jaffle_shop", py_system_site_packages=False, + pip_install_options=["--test-flag"], py_requirements=["dbt-postgres==1.6.0b1"], emit_datasets=False, invocation_mode=InvocationMode.SUBPROCESS, From 84f1a489d078aed3499369e7bb401b8ea3fcabaf Mon Sep 17 00:00:00 2001 From: linchun Date: Tue, 12 Mar 2024 21:08:28 +0800 Subject: [PATCH 119/223] Update pip install instructions in docs (#887) The pip3 install step does not work ``` pip3 install -e apache-airflow[cncf.kubernetes,openlineage] ``` Screenshot 2024-03-11 at 21 33 28 Update pip install with double quotation marks so that the command works for both unix and windows environment --- docs/contributing.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index 3e2ab6fe35..56826cfc8d 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -35,8 +35,8 @@ Then install ``airflow`` and ``astronomer-cosmos`` using python-venv: .. code-block:: bash python3 -m venv env && source env/bin/activate - pip3 install apache-airflow[cncf.kubernetes,openlineage] - pip3 install -e .[dbt-postgres,dbt-databricks] + pip3 install "apache-airflow[cncf.kubernetes,openlineage]" + pip3 install -e ".[dbt-postgres,dbt-databricks]" Set airflow home to the ``dev/`` directory and disabled loading example DAGs: From f575b4da58705adf52de9f00819a213420d173a4 Mon Sep 17 00:00:00 2001 From: Tommy Lim Date: Tue, 12 Mar 2024 21:25:48 +0800 Subject: [PATCH 120/223] Expose the dbt graph in the `DbtToAirflowConverter` class (#886) `dbt_graph` is now accessible from generated dbt task groups, through `DbtToAirflowConverter` Closes: #885 --- cosmos/converter.py | 6 +++--- tests/test_converter.py | 33 ++++++++++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/cosmos/converter.py b/cosmos/converter.py index 3619fecf56..fdf4d8e429 100644 --- a/cosmos/converter.py +++ b/cosmos/converter.py @@ -234,14 +234,14 @@ def __init__( # To keep this logic working, if converter is given no ProfileConfig, # we can create a default retaining this value to preserve this functionality. # We may want to consider defaulting this value in our actual ProjceConfig class? - dbt_graph = DbtGraph( + self.dbt_graph = DbtGraph( project=project_config, render_config=render_config, execution_config=execution_config, profile_config=profile_config, dbt_vars=dbt_vars, ) - dbt_graph.load(method=render_config.load_method, execution_mode=execution_config.execution_mode) + self.dbt_graph.load(method=render_config.load_method, execution_mode=execution_config.execution_mode) task_args = { **operator_args, @@ -266,7 +266,7 @@ def __init__( ) build_airflow_graph( - nodes=dbt_graph.filtered_nodes, + nodes=self.dbt_graph.filtered_nodes, dag=dag or (task_group and task_group.dag), task_group=task_group, execution_mode=execution_config.execution_mode, diff --git a/tests/test_converter.py b/tests/test_converter.py index f5dfe68b9d..10dc37f13a 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -8,7 +8,7 @@ from cosmos.config import CosmosConfigException, ExecutionConfig, ProfileConfig, ProjectConfig, RenderConfig from cosmos.constants import DbtResourceType, ExecutionMode, InvocationMode, LoadMode from cosmos.converter import DbtToAirflowConverter, validate_arguments, validate_initial_user_config -from cosmos.dbt.graph import DbtNode +from cosmos.dbt.graph import DbtGraph, DbtNode from cosmos.exceptions import CosmosValueError from cosmos.profiles.postgres import PostgresUserPasswordProfileMapping @@ -468,3 +468,34 @@ def test_converter_invocation_mode_added_to_task_args( assert kwargs["task_args"]["invocation_mode"] == invocation_mode else: assert "invocation_mode" not in kwargs["task_args"] + + +@pytest.mark.parametrize( + "execution_mode,operator_args", + [ + (ExecutionMode.KUBERNETES, {}), + ], +) +@patch("cosmos.converter.DbtGraph.filtered_nodes", nodes) +@patch("cosmos.converter.DbtGraph.load") +def test_converter_contains_dbt_graph(mock_load_dbt_graph, execution_mode, operator_args): + """ + This test validates that DbtToAirflowConverter contains and exposes a DbtGraph instance + """ + project_config = ProjectConfig(dbt_project_path=SAMPLE_DBT_PROJECT) + execution_config = ExecutionConfig(execution_mode=execution_mode) + render_config = RenderConfig(emit_datasets=True) + profile_config = ProfileConfig( + profile_name="my_profile_name", + target_name="my_target_name", + profiles_yml_filepath=SAMPLE_PROFILE_YML, + ) + converter = DbtToAirflowConverter( + nodes=nodes, + project_config=project_config, + profile_config=profile_config, + execution_config=execution_config, + render_config=render_config, + operator_args=operator_args, + ) + assert isinstance(converter.dbt_graph, DbtGraph) From 5028a288febf4e6b17bba055a48dc4f9dc3fed0c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 18 Mar 2024 09:22:14 +0000 Subject: [PATCH 121/223] =?UTF-8?q?=E2=AC=86=20[pre-commit.ci]=20pre-commi?= =?UTF-8?q?t=20autoupdate=20(#890)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.2.2 → v0.3.2](https://github.com/astral-sh/ruff-pre-commit/compare/v0.2.2...v0.3.2) - [github.com/pre-commit/mirrors-mypy: v1.8.0 → v1.9.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.8.0...v1.9.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 146e07743d..b45eefb3e8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -54,7 +54,7 @@ repos: - --py37-plus - --keep-runtime-typing - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.2.2 + rev: v0.3.2 hooks: - id: ruff args: @@ -71,7 +71,7 @@ repos: alias: black additional_dependencies: [black>=22.10.0] - repo: https://github.com/pre-commit/mirrors-mypy - rev: "v1.8.0" + rev: "v1.9.0" hooks: - id: mypy From a1f0540fedd58d41c9a04211c55fd759e6d14bfd Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 19 Mar 2024 15:14:17 +0000 Subject: [PATCH 122/223] =?UTF-8?q?=E2=AC=86=20[pre-commit.ci]=20pre-commi?= =?UTF-8?q?t=20autoupdate=20(#896)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.3.2 → v0.3.3](https://github.com/astral-sh/ruff-pre-commit/compare/v0.3.2...v0.3.3) - [github.com/psf/black: 24.2.0 → 24.3.0](https://github.com/psf/black/compare/24.2.0...24.3.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b45eefb3e8..4610fea2c5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -54,13 +54,13 @@ repos: - --py37-plus - --keep-runtime-typing - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.3.2 + rev: v0.3.3 hooks: - id: ruff args: - --fix - repo: https://github.com/psf/black - rev: 24.2.0 + rev: 24.3.0 hooks: - id: black args: ["--config", "./pyproject.toml"] From 8d9eb6217731f46af158d135b53260c24c23378a Mon Sep 17 00:00:00 2001 From: Daniel Reeves <31971762+dwreeves@users.noreply.github.com> Date: Thu, 21 Mar 2024 05:56:14 -0400 Subject: [PATCH 123/223] [Bugfix] Dbt docs operator should not look for `graph.gpickle` file when `--no-write-json` is passed. (#883) Fixes #882 --------- Co-authored-by: Tatiana Al-Chueyr --- cosmos/operators/local.py | 8 +++++--- tests/operators/test_local.py | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/cosmos/operators/local.py b/cosmos/operators/local.py index e2c08697e6..b39de10f80 100644 --- a/cosmos/operators/local.py +++ b/cosmos/operators/local.py @@ -570,7 +570,7 @@ class DbtDocsLocalOperator(DbtLocalBaseOperator): """ ui_color = "#8194E0" - required_files = ["index.html", "manifest.json", "graph.gpickle", "catalog.json"] + required_files = ["index.html", "manifest.json", "catalog.json"] base_cmd = ["docs", "generate"] def __init__(self, **kwargs: Any) -> None: @@ -578,11 +578,13 @@ def __init__(self, **kwargs: Any) -> None: self.check_static_flag() def check_static_flag(self) -> None: - flag = "--static" if self.dbt_cmd_flags: - if flag in self.dbt_cmd_flags: + if "--static" in self.dbt_cmd_flags: # For the --static flag we only upload the generated static_index.html file self.required_files = ["static_index.html"] + if self.dbt_cmd_global_flags: + if "--no-write-json" in self.dbt_cmd_global_flags and "graph.gpickle" in self.required_files: + self.required_files.remove("graph.gpickle") class DbtDocsCloudLocalOperator(DbtDocsLocalOperator, ABC): diff --git a/tests/operators/test_local.py b/tests/operators/test_local.py index 6e1a6f6a92..956c2a8d1e 100644 --- a/tests/operators/test_local.py +++ b/tests/operators/test_local.py @@ -759,6 +759,21 @@ def test_dbt_docs_local_operator_with_static_flag(): assert operator.required_files == ["static_index.html"] +def test_dbt_docs_local_operator_ignores_graph_gpickle(): + # Check when --no-write-json is passed, graph.gpickle is removed. + # This is only currently relevant for subclasses, but will become more generally relevant in the future. + class CustomDbtDocsLocalOperator(DbtDocsLocalOperator): + required_files = ["index.html", "manifest.json", "graph.gpickle", "catalog.json"] + + operator = CustomDbtDocsLocalOperator( + task_id="fake-task", + project_dir="fake-dir", + profile_config=profile_config, + dbt_cmd_global_flags=["--no-write-json"], + ) + assert operator.required_files == ["index.html", "manifest.json", "catalog.json"] + + @patch("cosmos.hooks.subprocess.FullOutputSubprocessHook.send_sigint") def test_dbt_local_operator_on_kill_sigint(mock_send_sigint) -> None: From 07b8586caabb2273414f954d78783e995409a72b Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Fri, 22 Mar 2024 12:59:06 +0000 Subject: [PATCH 124/223] Replace deprecated DummyOperator by EmptyOperator if Airflow >=2.4.0 (#900) Change the example of customising Cosmos operators to use `EmptyOperator`, since the `DummyOperator` was deprecated in Airflow 2.4. --- dev/dags/example_cosmos_sources.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/dev/dags/example_cosmos_sources.py b/dev/dags/example_cosmos_sources.py index 1a85b6d9f9..346f373704 100644 --- a/dev/dags/example_cosmos_sources.py +++ b/dev/dags/example_cosmos_sources.py @@ -17,7 +17,11 @@ from pathlib import Path from airflow.models.dag import DAG -from airflow.operators.dummy import DummyOperator + +try: # available since Airflow 2.4.0 + from airflow.operators.empty import EmptyOperator +except ImportError: + from airflow.operators.dummy import DummyOperator as EmptyOperator from airflow.utils.task_group import TaskGroup from cosmos import DbtDag, ProfileConfig, ProjectConfig, RenderConfig @@ -38,21 +42,21 @@ # [START custom_dbt_nodes] -# Cosmos will use this function to generate a DummyOperator task when it finds a source node, in the manifest. +# Cosmos will use this function to generate an empty task when it finds a source node, in the manifest. # A more realistic use case could be to use an Airflow sensor to represent a source. def convert_source(dag: DAG, task_group: TaskGroup, node: DbtNode, **kwargs): """ - Return an instance of DummyOperator to represent a dbt "source" node. + Return an instance of a desired operator to represent a dbt "source" node. """ - return DummyOperator(dag=dag, task_group=task_group, task_id=f"{node.name}_source") + return EmptyOperator(dag=dag, task_group=task_group, task_id=f"{node.name}_source") -# Cosmos will use this function to generate a DummyOperator task when it finds a exposure node, in the manifest. +# Cosmos will use this function to generate an empty task when it finds a exposure node, in the manifest. def convert_exposure(dag: DAG, task_group: TaskGroup, node: DbtNode, **kwargs): """ - Return an instance of DummyOperator to represent a dbt "exposure" node. + Return an instance of a desired operator to represent a dbt "exposure" node. """ - return DummyOperator(dag=dag, task_group=task_group, task_id=f"{node.name}_exposure") + return EmptyOperator(dag=dag, task_group=task_group, task_id=f"{node.name}_exposure") # Use `RenderConfig` to tell Cosmos, given a node type, how to convert a dbt node into an Airflow task or task group. From 39c67409c134dc2aa2c0ccb237cdb206d658eb57 Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Mon, 25 Mar 2024 10:43:26 +0000 Subject: [PATCH 125/223] Enable `append_env` in `operator_args` by default (#899) The default behaviour when parsing Cosmos DAGs is to use the system environment variables, but to execute dbt commands from Airflow operators is not. This PR solves this, making the behaviour consistent in DAG parsing and dbt command execution. Closes: #595 --- cosmos/operators/base.py | 9 +++++---- tests/operators/test_azure_container_instance.py | 2 ++ tests/operators/test_docker.py | 5 +---- tests/operators/test_kubernetes.py | 7 ++----- tests/operators/test_local.py | 4 +--- 5 files changed, 11 insertions(+), 16 deletions(-) diff --git a/cosmos/operators/base.py b/cosmos/operators/base.py index 94d5d4a8ca..b9f25758dc 100644 --- a/cosmos/operators/base.py +++ b/cosmos/operators/base.py @@ -43,10 +43,11 @@ class AbstractDbtBaseOperator(BaseOperator, metaclass=ABCMeta): environment variables for the new process; these are used instead of inheriting the current process environment, which is the default behavior. (templated) - :param append_env: If False(default) uses the environment variables passed in env params - and does not inherit the current process environment. If True, inherits the environment variables + :param append_env: . If True (default), inherits the environment variables from current passes and then environment variable passed by the user will either update the existing - inherited environment variables or the new variables gets appended to it + inherited environment variables or the new variables gets appended to it. + If False, only uses the environment variables passed in env params + and does not inherit the current process environment. :param output_encoding: Output encoding of bash command :param skip_exit_code: If task exits with this exit code, leave the task in ``skipped`` state (default: 99). If set to ``None``, any non-zero @@ -99,7 +100,7 @@ def __init__( db_name: str | None = None, schema: str | None = None, env: dict[str, Any] | None = None, - append_env: bool = False, + append_env: bool = True, output_encoding: str = "utf-8", skip_exit_code: int = 99, partial_parse: bool = True, diff --git a/tests/operators/test_azure_container_instance.py b/tests/operators/test_azure_container_instance.py index 01fa3e20e3..a99525ac30 100644 --- a/tests/operators/test_azure_container_instance.py +++ b/tests/operators/test_azure_container_instance.py @@ -53,6 +53,7 @@ def test_dbt_azure_container_instance_operator_get_env(p_context_to_airflow_vars name="my-aci", resource_group="my-rg", project_dir="my/dir", + append_env=False, ) dbt_base_operator.env = { "start_date": "20220101", @@ -90,6 +91,7 @@ def test_dbt_azure_container_instance_operator_check_environment_variables( resource_group="my-rg", project_dir="my/dir", environment_variables={"FOO": "BAR"}, + append_env=False, ) dbt_base_operator.env = { "start_date": "20220101", diff --git a/tests/operators/test_docker.py b/tests/operators/test_docker.py index ad3ec54852..2cfb6b835e 100644 --- a/tests/operators/test_docker.py +++ b/tests/operators/test_docker.py @@ -73,10 +73,7 @@ def test_dbt_docker_operator_get_env(p_context_to_airflow_vars: MagicMock, base_ If an end user passes in a """ dbt_base_operator = base_operator( - conn_id="my_airflow_connection", - task_id="my-task", - image="my_image", - project_dir="my/dir", + conn_id="my_airflow_connection", task_id="my-task", image="my_image", project_dir="my/dir", append_env=False ) dbt_base_operator.env = { "start_date": "20220101", diff --git a/tests/operators/test_kubernetes.py b/tests/operators/test_kubernetes.py index 75739111f2..d0be2acad9 100644 --- a/tests/operators/test_kubernetes.py +++ b/tests/operators/test_kubernetes.py @@ -81,10 +81,7 @@ def test_dbt_kubernetes_operator_get_env(p_context_to_airflow_vars: MagicMock, b If an end user passes in a """ dbt_kube_operator = base_operator( - conn_id="my_airflow_connection", - task_id="my-task", - image="my_image", - project_dir="my/dir", + conn_id="my_airflow_connection", task_id="my-task", image="my_image", project_dir="my/dir", append_env=False ) dbt_kube_operator.env = { "start_date": "20220101", @@ -254,7 +251,7 @@ def cleanup(pod: str, remote_pod: str): def test_created_pod(): - ls_kwargs = {"env_vars": {"FOO": "BAR"}, "namespace": "foo"} + ls_kwargs = {"env_vars": {"FOO": "BAR"}, "namespace": "foo", "append_env": False} ls_kwargs.update(base_kwargs) ls_operator = DbtLSKubernetesOperator(**ls_kwargs) ls_operator.hook = MagicMock() diff --git a/tests/operators/test_local.py b/tests/operators/test_local.py index 956c2a8d1e..17b98ee569 100644 --- a/tests/operators/test_local.py +++ b/tests/operators/test_local.py @@ -329,9 +329,7 @@ def test_dbt_base_operator_get_env(p_context_to_airflow_vars: MagicMock) -> None If an end user passes in a """ dbt_base_operator = ConcreteDbtLocalBaseOperator( - profile_config=profile_config, - task_id="my-task", - project_dir="my/dir", + profile_config=profile_config, task_id="my-task", project_dir="my/dir", append_env=False ) dbt_base_operator.env = { "start_date": "20220101", From cef5bec1ce555c4fbfaa361b1538f7047945f066 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 26 Mar 2024 13:56:15 +0000 Subject: [PATCH 126/223] =?UTF-8?q?=E2=AC=86=20[pre-commit.ci]=20pre-commi?= =?UTF-8?q?t=20autoupdate=20(#901)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/asottile/pyupgrade: v3.15.1 → v3.15.2](https://github.com/asottile/pyupgrade/compare/v3.15.1...v3.15.2) - [github.com/astral-sh/ruff-pre-commit: v0.3.3 → v0.3.4](https://github.com/astral-sh/ruff-pre-commit/compare/v0.3.3...v0.3.4) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4610fea2c5..2026027918 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -47,14 +47,14 @@ repos: - id: remove-tabs exclude: ^docs/make.bat$|^docs/Makefile$|^dev/dags/dbt/jaffle_shop/seeds/raw_orders.csv$ - repo: https://github.com/asottile/pyupgrade - rev: v3.15.1 + rev: v3.15.2 hooks: - id: pyupgrade args: - --py37-plus - --keep-runtime-typing - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.3.3 + rev: v0.3.4 hooks: - id: ruff args: From e5b3039539de47f6e700e0be11f4d34b211bb293 Mon Sep 17 00:00:00 2001 From: Daniel van der Ende Date: Tue, 26 Mar 2024 15:02:55 +0100 Subject: [PATCH 127/223] Fix ACI integration (#872) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inheritance order of the `DbtAzureContainerInstanceBaseOperator` was incorrect, as well as the wrong execute method being called. This led to no dbt command actually being built nor run. I noticed this when testing the 1.4.0a1 release. Example log before: ``` [2024-03-01, 20:27:29 UTC] {container_instances.py:366} INFO - Usage: dbt [OPTIONS] COMMAND [ARGS]... [2024-03-01, 20:27:29 UTC] {container_instances.py:366} INFO - ``` After this fix: ``` [2024-03-01, 20:28:44 UTC] {container_instances.py:366} INFO - 20:28:44 Found 5 models, 3 seeds, 20 tests, 0 sources, 0 exposures, 0 metrics, 403 macros, 0 groups, 0 semantic models [2024-03-01, 20:28:44 UTC] {container_instances.py:366} INFO - 20:28:44 ``` ## Checklist - [x] I have made corresponding changes to the documentation (if required) - [x] I have added tests that prove my fix is effective or that my feature works --- cosmos/operators/azure_container_instance.py | 16 +++++++------- .../test_azure_container_instance.py | 22 +++++++++++++++++++ 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/cosmos/operators/azure_container_instance.py b/cosmos/operators/azure_container_instance.py index 397a47551c..d8427b2fbb 100644 --- a/cosmos/operators/azure_container_instance.py +++ b/cosmos/operators/azure_container_instance.py @@ -28,7 +28,7 @@ ) -class DbtAzureContainerInstanceBaseOperator(AzureContainerInstancesOperator, AbstractDbtBaseOperator): # type: ignore +class DbtAzureContainerInstanceBaseOperator(AbstractDbtBaseOperator, AzureContainerInstancesOperator): # type: ignore """ Executes a dbt core cli command in an Azure Container Instance """ @@ -66,7 +66,7 @@ def __init__( def build_and_run_cmd(self, context: Context, cmd_flags: list[str] | None = None) -> None: self.build_command(context, cmd_flags) self.log.info(f"Running command: {self.command}") - result = super().execute(context) + result = AzureContainerInstancesOperator.execute(self, context) logger.info(result) def build_command(self, context: Context, cmd_flags: list[str] | None = None) -> None: @@ -79,13 +79,13 @@ def build_command(self, context: Context, cmd_flags: list[str] | None = None) -> self.command: list[str] = dbt_cmd -class DbtLSAzureContainerInstanceOperator(DbtLSMixin, DbtAzureContainerInstanceBaseOperator): +class DbtLSAzureContainerInstanceOperator(DbtLSMixin, DbtAzureContainerInstanceBaseOperator): # type: ignore """ Executes a dbt core ls command. """ -class DbtSeedAzureContainerInstanceOperator(DbtSeedMixin, DbtAzureContainerInstanceBaseOperator): +class DbtSeedAzureContainerInstanceOperator(DbtSeedMixin, DbtAzureContainerInstanceBaseOperator): # type: ignore """ Executes a dbt core seed command. @@ -95,14 +95,14 @@ class DbtSeedAzureContainerInstanceOperator(DbtSeedMixin, DbtAzureContainerInsta template_fields: Sequence[str] = DbtAzureContainerInstanceBaseOperator.template_fields + DbtRunMixin.template_fields # type: ignore[operator] -class DbtSnapshotAzureContainerInstanceOperator(DbtSnapshotMixin, DbtAzureContainerInstanceBaseOperator): +class DbtSnapshotAzureContainerInstanceOperator(DbtSnapshotMixin, DbtAzureContainerInstanceBaseOperator): # type: ignore """ Executes a dbt core snapshot command. """ -class DbtRunAzureContainerInstanceOperator(DbtRunMixin, DbtAzureContainerInstanceBaseOperator): +class DbtRunAzureContainerInstanceOperator(DbtRunMixin, DbtAzureContainerInstanceBaseOperator): # type: ignore """ Executes a dbt core run command. """ @@ -110,7 +110,7 @@ class DbtRunAzureContainerInstanceOperator(DbtRunMixin, DbtAzureContainerInstanc template_fields: Sequence[str] = DbtAzureContainerInstanceBaseOperator.template_fields + DbtRunMixin.template_fields # type: ignore[operator] -class DbtTestAzureContainerInstanceOperator(DbtTestMixin, DbtAzureContainerInstanceBaseOperator): +class DbtTestAzureContainerInstanceOperator(DbtTestMixin, DbtAzureContainerInstanceBaseOperator): # type: ignore """ Executes a dbt core test command. """ @@ -121,7 +121,7 @@ def __init__(self, on_warning_callback: Callable[..., Any] | None = None, **kwar self.on_warning_callback = on_warning_callback -class DbtRunOperationAzureContainerInstanceOperator(DbtRunOperationMixin, DbtAzureContainerInstanceBaseOperator): +class DbtRunOperationAzureContainerInstanceOperator(DbtRunOperationMixin, DbtAzureContainerInstanceBaseOperator): # type: ignore """ Executes a dbt core run-operation command. diff --git a/tests/operators/test_azure_container_instance.py b/tests/operators/test_azure_container_instance.py index a99525ac30..84d733ce38 100644 --- a/tests/operators/test_azure_container_instance.py +++ b/tests/operators/test_azure_container_instance.py @@ -145,3 +145,25 @@ def test_dbt_azure_container_instance_build_command(): "start_time: '{{ data_interval_start.strftime(''%Y%m%d%H%M%S'') }}'\n", "--no-version-check", ] + + +@patch("cosmos.operators.azure_container_instance.AzureContainerInstancesOperator.execute") +def test_dbt_azure_container_instance_build_and_run_cmd(mock_execute): + dbt_base_operator = ConcreteDbtAzureContainerInstanceOperator( + ci_conn_id="my_airflow_connection", + task_id="my-task", + image="my_image", + region="Mordor", + name="my-aci", + resource_group="my-rg", + project_dir="my/dir", + environment_variables={"FOO": "BAR"}, + ) + mock_build_command = MagicMock() + dbt_base_operator.build_command = mock_build_command + + mock_context = MagicMock() + dbt_base_operator.build_and_run_cmd(context=mock_context) + + mock_build_command.assert_called_with(mock_context, None) + mock_execute.assert_called_once_with(dbt_base_operator, mock_context) From 9c0d26c87ae101003b0b824e91a503c272e2df2d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 4 Apr 2024 00:11:04 +0530 Subject: [PATCH 128/223] =?UTF-8?q?=E2=AC=86=20[pre-commit.ci]=20pre-commi?= =?UTF-8?q?t=20autoupdate=20(#905)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.3.4 → v0.3.5](https://github.com/astral-sh/ruff-pre-commit/compare/v0.3.4...v0.3.5) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2026027918..5e76c4a98b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -54,7 +54,7 @@ repos: - --py37-plus - --keep-runtime-typing - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.3.4 + rev: v0.3.5 hooks: - id: ruff args: From 9daba19d3abc0bfe834fdcfabd8b5f77d94d3d45 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 16 Apr 2024 12:06:36 +0530 Subject: [PATCH 129/223] =?UTF-8?q?=E2=AC=86=20[pre-commit.ci]=20pre-commi?= =?UTF-8?q?t=20autoupdate=20(#908)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/pre-commit/pre-commit-hooks: v4.5.0 → v4.6.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.5.0...v4.6.0) - [github.com/astral-sh/ruff-pre-commit: v0.3.5 → v0.3.7](https://github.com/astral-sh/ruff-pre-commit/compare/v0.3.5...v0.3.7) - [github.com/psf/black: 24.3.0 → 24.4.0](https://github.com/psf/black/compare/24.3.0...24.4.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5e76c4a98b..a92fd1ee70 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ repos: types: [file] pass_filenames: false - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v4.6.0 hooks: - id: check-added-large-files - id: check-merge-conflict @@ -54,13 +54,13 @@ repos: - --py37-plus - --keep-runtime-typing - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.3.5 + rev: v0.3.7 hooks: - id: ruff args: - --fix - repo: https://github.com/psf/black - rev: 24.3.0 + rev: 24.4.0 hooks: - id: black args: ["--config", "./pyproject.toml"] From 581fea7e7c629e0cccd7ce7a3f954a2d8f2e00a3 Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 16 Apr 2024 11:43:04 +0200 Subject: [PATCH 130/223] update testing behavior docs (#910) ## Description Update testing behavior docs to reflect support of warning feature in virtualenv execution mode. Currently, the docs say that only `local` executor mode is supporting `on_warning_callback`. However, this also works with `virtualenv` mode. ## Related Issue(s) #909 ## Breaking Change? None ## Checklist - [x ] I have made corresponding changes to the documentation (if required) --- docs/configuration/testing-behavior.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration/testing-behavior.rst b/docs/configuration/testing-behavior.rst index 951c90ca57..7af9ceedd9 100644 --- a/docs/configuration/testing-behavior.rst +++ b/docs/configuration/testing-behavior.rst @@ -37,7 +37,7 @@ Warning Behavior .. note:: - As of now, this feature is only available for the default execution mode ``local`` + As of now, this feature is only available for the default execution mode ``local`` and for ``virtualenv`` Cosmos enables you to receive warning notifications from tests and process them using a callback function. The ``on_warning_callback`` parameter adds two extra context variables to the callback function: ``test_names`` and ``test_results``. From c65412359cedbbf1d5e6c242e5c6578f37c401cc Mon Sep 17 00:00:00 2001 From: Toni Boutaour <32482271+tboutaour@users.noreply.github.com> Date: Fri, 19 Apr 2024 12:03:25 +0200 Subject: [PATCH 131/223] Add dag arg_key as specific_args_keys (#916) This code fixes the problem that dag parameter cannot be passed as argument at `DbtTaskGroup`. Previously, this would work: ``` with DAG(...): DbtTaskGroup(...) ``` But this would fail: ``` dag = DAG(...) DbtTaskGroup(dag=dag...) ``` Both work now. This change has been made to not affecting `DbtToAirflowConverter` class at all. Closes: #915 --- cosmos/converter.py | 2 +- tests/airflow/test_graph.py | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/cosmos/converter.py b/cosmos/converter.py index fdf4d8e429..f9511ab821 100644 --- a/cosmos/converter.py +++ b/cosmos/converter.py @@ -57,7 +57,7 @@ def airflow_kwargs(**kwargs: dict[str, Any]) -> dict[str, Any]: new_kwargs = {} non_airflow_kwargs = specific_kwargs(**kwargs) for arg_key, arg_value in kwargs.items(): - if arg_key not in non_airflow_kwargs: + if arg_key not in non_airflow_kwargs or arg_key == "dag": new_kwargs[arg_key] = arg_value return new_kwargs diff --git a/tests/airflow/test_graph.py b/tests/airflow/test_graph.py index 1e5648306d..4ef7d112ce 100644 --- a/tests/airflow/test_graph.py +++ b/tests/airflow/test_graph.py @@ -24,6 +24,7 @@ TestBehavior, TestIndirectSelection, ) +from cosmos.converter import airflow_kwargs from cosmos.dbt.graph import DbtNode from cosmos.profiles import PostgresUserPasswordProfileMapping @@ -431,3 +432,28 @@ def test_create_test_task_metadata(node_type, node_unique_id, test_indirect_sele ) def test_snake_case_to_camelcase(input, expected): assert _snake_case_to_camelcase(input) == expected + + +def test_airflow_kwargs_generation(): + """ + airflow_kwargs_generation should always contain dag. + """ + task_args = { + "group_id": "fake_group_id", + "project_dir": SAMPLE_PROJ_PATH, + "conn_id": "fake_conn", + "render_config": RenderConfig(select=["fake-render"]), + "default_args": {"retries": 2}, + "profile_config": ProfileConfig( + profile_name="default", + target_name="default", + profile_mapping=PostgresUserPasswordProfileMapping( + conn_id="fake_conn", + profile_args={"schema": "public"}, + ), + ), + "dag": DAG(dag_id="fake_dag_name"), + } + result = airflow_kwargs(**task_args) + + assert "dag" in result From 24edcb07d21c97dbf5e21d1459ec6f7d84892338 Mon Sep 17 00:00:00 2001 From: Siddique Ahmad Date: Sat, 20 Apr 2024 23:52:08 +0500 Subject: [PATCH 132/223] Fix: Typo (#917) it is `has_test` property instead of `has_text` --- cosmos/dbt/graph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cosmos/dbt/graph.py b/cosmos/dbt/graph.py index 0a9b78dc77..160738fb55 100644 --- a/cosmos/dbt/graph.py +++ b/cosmos/dbt/graph.py @@ -460,7 +460,7 @@ def load_from_dbt_manifest(self) -> None: def update_node_dependency(self) -> None: """ - This will update the property `has_text` if node has `dbt` test + This will update the property `has_test` if node has `dbt` test Updates in-place: * self.filtered_nodes From a31b9e6c281ebcc7ef9c18d62e3455e443c6143c Mon Sep 17 00:00:00 2001 From: Siddique Ahmad Date: Sat, 20 Apr 2024 23:53:50 +0500 Subject: [PATCH 133/223] docs: use of datasets for airflow >= 2.4 (#879) schedule is right parameter name ## Description schedule is right parameter name. ## Related Issue(s) ## Breaking Change? ## Checklist - [ ] I have made corresponding changes to the documentation (if required) - [ ] I have added tests that prove my fix is effective or that my feature works --------- Co-authored-by: Tatiana Al-Chueyr Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- docs/configuration/scheduling.rst | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/configuration/scheduling.rst b/docs/configuration/scheduling.rst index de21f84950..a1275ee190 100644 --- a/docs/configuration/scheduling.rst +++ b/docs/configuration/scheduling.rst @@ -17,7 +17,7 @@ To schedule a dbt project on a time-based schedule, you can use Airflow's schedu jaffle_shop = DbtDag( # ... start_date=datetime(2023, 1, 1), - schedule_interval="@daily", + schedule="@daily", ) @@ -45,12 +45,14 @@ Then, you can use Airflow's data-aware scheduling capabilities to schedule ``my_ project_one = DbtDag( # ... start_date=datetime(2023, 1, 1), - schedule_interval="@daily", + schedule="@daily", ) project_two = DbtDag( - # ... - schedule_interval=[get_dbt_dataset("my_conn", "project_one", "my_model")], + # for airflow <=2.3 + # schedule=[get_dbt_dataset("my_conn", "project_one", "my_model")], + # for airflow > 2.3 + schedule=[get_dbt_dataset("my_conn", "project_one", "my_model")], dbt_project_name="project_two", ) From 752bcdbb6bd02b99a4badebb870a03d8db6758ae Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 23 Apr 2024 22:17:26 +0100 Subject: [PATCH 134/223] =?UTF-8?q?=E2=AC=86=20[pre-commit.ci]=20pre-commi?= =?UTF-8?q?t=20autoupdate=20(#919)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.3.7 → v0.4.1](https://github.com/astral-sh/ruff-pre-commit/compare/v0.3.7...v0.4.1) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a92fd1ee70..31dddbe68c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -54,7 +54,7 @@ repos: - --py37-plus - --keep-runtime-typing - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.3.7 + rev: v0.4.1 hooks: - id: ruff args: From abc4eda217715d9edf833ee233149cc81ad91eb4 Mon Sep 17 00:00:00 2001 From: AlexandrKhabarov <38005223+AlexandrKhabarov@users.noreply.github.com> Date: Wed, 24 Apr 2024 13:41:04 +0300 Subject: [PATCH 135/223] fix: missed required parameter for non method authentication (#921) Usage of TrinoBaseProfileMapping leads to the issue related with missed user field in profile with authentication method equals to None ``` Runtime Error Credentials in profile "test_profile", target "dev" invalid: 'user' is a required property ``` --- cosmos/profiles/trino/base.py | 5 ++++- cosmos/profiles/trino/certificate.py | 2 +- cosmos/profiles/trino/jwt.py | 2 +- cosmos/profiles/trino/ldap.py | 2 +- tests/profiles/trino/test_trino_base.py | 2 ++ 5 files changed, 9 insertions(+), 4 deletions(-) diff --git a/cosmos/profiles/trino/base.py b/cosmos/profiles/trino/base.py index 1e7e841246..3ed5c7e6a7 100644 --- a/cosmos/profiles/trino/base.py +++ b/cosmos/profiles/trino/base.py @@ -13,16 +13,19 @@ class TrinoBaseProfileMapping(BaseProfileMapping): dbt_profile_type: str = "trino" is_community: bool = True - required_fields = [ + base_fields = [ "host", "database", "schema", "port", ] + required_fields = base_fields + ["user"] + airflow_param_mapping = { "host": "host", "port": "port", + "user": "login", "session_properties": "extra.session_properties", } diff --git a/cosmos/profiles/trino/certificate.py b/cosmos/profiles/trino/certificate.py index b792664a43..c87e7c67cb 100644 --- a/cosmos/profiles/trino/certificate.py +++ b/cosmos/profiles/trino/certificate.py @@ -15,7 +15,7 @@ class TrinoCertificateProfileMapping(TrinoBaseProfileMapping): dbt_profile_method: str = "certificate" - required_fields = TrinoBaseProfileMapping.required_fields + [ + required_fields = TrinoBaseProfileMapping.base_fields + [ "client_certificate", "client_private_key", ] diff --git a/cosmos/profiles/trino/jwt.py b/cosmos/profiles/trino/jwt.py index 5d6d989081..4f09cd3f14 100644 --- a/cosmos/profiles/trino/jwt.py +++ b/cosmos/profiles/trino/jwt.py @@ -16,7 +16,7 @@ class TrinoJWTProfileMapping(TrinoBaseProfileMapping): dbt_profile_method: str = "jwt" - required_fields = TrinoBaseProfileMapping.required_fields + [ + required_fields = TrinoBaseProfileMapping.base_fields + [ "jwt_token", ] secret_fields = [ diff --git a/cosmos/profiles/trino/ldap.py b/cosmos/profiles/trino/ldap.py index d456f7c68d..5ba122b932 100644 --- a/cosmos/profiles/trino/ldap.py +++ b/cosmos/profiles/trino/ldap.py @@ -16,7 +16,7 @@ class TrinoLDAPProfileMapping(TrinoBaseProfileMapping): dbt_profile_method: str = "ldap" - required_fields = TrinoBaseProfileMapping.required_fields + [ + required_fields = TrinoBaseProfileMapping.base_fields + [ "user", "password", ] diff --git a/tests/profiles/trino/test_trino_base.py b/tests/profiles/trino/test_trino_base.py index 31f1a31666..ad1e84748f 100644 --- a/tests/profiles/trino/test_trino_base.py +++ b/tests/profiles/trino/test_trino_base.py @@ -40,6 +40,7 @@ def test_profile_args() -> None: "schema": "my_schema", "host": "my_host", "port": 8080, + "user": "my_login", "session_properties": {"my_property": "my_value"}, } @@ -80,5 +81,6 @@ def test_profile_args_overrides() -> None: "schema": "my_schema", "host": "my_host_override", "port": 8080, + "user": "my_login", "session_properties": {"my_property": "my_value_override"}, } From 7653372d10bf7535e8e852dd15a5265bdc7425ae Mon Sep 17 00:00:00 2001 From: Toni Boutaour <32482271+tboutaour@users.noreply.github.com> Date: Wed, 24 Apr 2024 23:51:21 +0200 Subject: [PATCH 136/223] Fix at docs: GCS generation (#922) Just a simple fix of a documentation typo. --- docs/configuration/generating-docs.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration/generating-docs.rst b/docs/configuration/generating-docs.rst index 54ec80fc9e..69b38be15b 100644 --- a/docs/configuration/generating-docs.rst +++ b/docs/configuration/generating-docs.rst @@ -69,7 +69,7 @@ Upload to GCS GCS supports serving static files directly from a bucket. To learn more (and to set it up), check out the `official GCS documentation `_. -You can use the :class:`~cosmos.operators.DbtDocsGCSOperator` to generate and upload docs to a S3 bucket. The following code snippet shows how to do this with the default jaffle_shop project: +You can use the :class:`~cosmos.operators.DbtDocsGCSOperator` to generate and upload docs to a GCS bucket. The following code snippet shows how to do this with the default jaffle_shop project: .. code-block:: python From 645f504b0f2f024ed89ebe74aace2d090a786976 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcin=20Szyma=C5=84ski?= Date: Fri, 26 Apr 2024 16:46:44 +0100 Subject: [PATCH 137/223] Fix global flags for lists (#863) Correctly deals with global flags when they are a list. Note - in the module there's no distinguishing which are and which aren't. Co-authored-by: Tatiana Al-Chueyr --- cosmos/operators/base.py | 35 +++++++++++++------------- tests/operators/test_local.py | 46 ++++++++++++++++++++++++++++------- 2 files changed, 54 insertions(+), 27 deletions(-) diff --git a/cosmos/operators/base.py b/cosmos/operators/base.py index b9f25758dc..b6e1797d87 100644 --- a/cosmos/operators/base.py +++ b/cosmos/operators/base.py @@ -195,17 +195,28 @@ def add_global_flags(self) -> list[str]: dbt_name = f"--{global_flag.replace('_', '-')}" global_flag_value = self.__getattribute__(global_flag) - if global_flag_value is not None: - if isinstance(global_flag_value, dict): - yaml_string = yaml.dump(global_flag_value) - flags.extend([dbt_name, yaml_string]) - else: - flags.extend([dbt_name, str(global_flag_value)]) + flags.extend(self._process_global_flag(dbt_name, global_flag_value)) + for global_boolean_flag in self.global_boolean_flags: if self.__getattribute__(global_boolean_flag): flags.append(f"--{global_boolean_flag.replace('_', '-')}") return flags + @staticmethod + def _process_global_flag(flag_name: str, flag_value: Any) -> list[str]: + """Helper method to process global flags and reduce complexity.""" + if flag_value is None: + return [] + elif isinstance(flag_value, dict): + yaml_string = yaml.dump(flag_value) + return [flag_name, yaml_string] + elif isinstance(flag_value, list) and flag_value: + return [flag_name, " ".join(flag_value)] + elif isinstance(flag_value, list): + return [] + else: + return [flag_name, str(flag_value)] + def add_cmd_flags(self) -> list[str]: """Allows subclasses to override to add flags for their dbt command""" return [] @@ -373,18 +384,6 @@ def __init__( self.selector = selector super().__init__(exclude=exclude, select=select, selector=selector, **kwargs) # type: ignore - def add_cmd_flags(self) -> list[str]: - flags = [] - if self.exclude: - flags.extend(["--exclude", *self.exclude]) - - if self.select: - flags.extend(["--select", *self.select]) - - if self.selector: - flags.extend(["--selector", self.selector]) - return flags - class DbtRunOperationMixin: """ diff --git a/tests/operators/test_local.py b/tests/operators/test_local.py index 17b98ee569..250b044fad 100644 --- a/tests/operators/test_local.py +++ b/tests/operators/test_local.py @@ -88,8 +88,11 @@ def test_dbt_base_operator_add_global_flags() -> None: "end_time": "{{ data_interval_end.strftime('%Y%m%d%H%M%S') }}", }, no_version_check=True, + select=["my_first_model", "my_second_model"], ) assert dbt_base_operator.add_global_flags() == [ + "--select", + "my_first_model my_second_model", "--vars", "end_time: '{{ data_interval_end.strftime(''%Y%m%d%H%M%S'') }}'\n" "start_time: '{{ data_interval_start.strftime(''%Y%m%d%H%M%S'') }}'\n", @@ -564,28 +567,50 @@ def test_store_compiled_sql() -> None: @pytest.mark.parametrize( "operator_class,kwargs,expected_call_kwargs", [ - (DbtSeedLocalOperator, {"full_refresh": True}, {"context": {}, "cmd_flags": ["--full-refresh"]}), - (DbtBuildLocalOperator, {"full_refresh": True}, {"context": {}, "cmd_flags": ["--full-refresh"]}), - (DbtRunLocalOperator, {"full_refresh": True}, {"context": {}, "cmd_flags": ["--full-refresh"]}), + ( + DbtSeedLocalOperator, + {"full_refresh": True}, + {"context": {}, "env": {}, "cmd_flags": ["seed", "--full-refresh"]}, + ), + ( + DbtBuildLocalOperator, + {"full_refresh": True}, + {"context": {}, "env": {}, "cmd_flags": ["build", "--full-refresh"]}, + ), + ( + DbtRunLocalOperator, + {"full_refresh": True}, + {"context": {}, "env": {}, "cmd_flags": ["run", "--full-refresh"]}, + ), + ( + DbtTestLocalOperator, + {}, + {"context": {}, "env": {}, "cmd_flags": ["test"]}, + ), + ( + DbtTestLocalOperator, + {"select": []}, + {"context": {}, "env": {}, "cmd_flags": ["test"]}, + ), ( DbtTestLocalOperator, {"full_refresh": True, "select": ["tag:daily"], "exclude": ["tag:disabled"]}, - {"context": {}, "cmd_flags": ["--exclude", "tag:disabled", "--select", "tag:daily"]}, + {"context": {}, "env": {}, "cmd_flags": ["test", "--select", "tag:daily", "--exclude", "tag:disabled"]}, ), ( DbtTestLocalOperator, {"full_refresh": True, "selector": "nightly_snowplow"}, - {"context": {}, "cmd_flags": ["--selector", "nightly_snowplow"]}, + {"context": {}, "env": {}, "cmd_flags": ["test", "--selector", "nightly_snowplow"]}, ), ( DbtRunOperationLocalOperator, {"args": {"days": 7, "dry_run": True}, "macro_name": "bla"}, - {"context": {}, "cmd_flags": ["--args", "days: 7\ndry_run: true\n"]}, + {"context": {}, "env": {}, "cmd_flags": ["run-operation", "bla", "--args", "days: 7\ndry_run: true\n"]}, ), ], ) -@patch("cosmos.operators.local.DbtLocalBaseOperator.build_and_run_cmd") -def test_operator_execute_with_flags(mock_build_and_run_cmd, operator_class, kwargs, expected_call_kwargs): +@patch("cosmos.operators.local.DbtLocalBaseOperator.run_command") +def test_operator_execute_with_flags(mock_run_cmd, operator_class, kwargs, expected_call_kwargs): task = operator_class( profile_config=profile_config, task_id="my-task", @@ -593,8 +618,11 @@ def test_operator_execute_with_flags(mock_build_and_run_cmd, operator_class, kwa invocation_mode=InvocationMode.DBT_RUNNER, **kwargs, ) + task.get_env = MagicMock(return_value={}) task.execute(context={}) - mock_build_and_run_cmd.assert_called_once_with(**expected_call_kwargs) + mock_run_cmd.assert_called_once_with( + cmd=[task.dbt_executable_path, *expected_call_kwargs.pop("cmd_flags")], **expected_call_kwargs + ) @pytest.mark.parametrize( From fad82eb8f0df00a36488f847af9d20ea5efc466c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 30 Apr 2024 10:33:49 +0100 Subject: [PATCH 138/223] =?UTF-8?q?=E2=AC=86=20[pre-commit.ci]=20pre-commi?= =?UTF-8?q?t=20autoupdate=20(#931)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.4.1 → v0.4.2](https://github.com/astral-sh/ruff-pre-commit/compare/v0.4.1...v0.4.2) - [github.com/psf/black: 24.4.0 → 24.4.2](https://github.com/psf/black/compare/24.4.0...24.4.2) - [github.com/pre-commit/mirrors-mypy: v1.9.0 → v1.10.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.9.0...v1.10.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 31dddbe68c..9ec32119bf 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -54,13 +54,13 @@ repos: - --py37-plus - --keep-runtime-typing - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.4.1 + rev: v0.4.2 hooks: - id: ruff args: - --fix - repo: https://github.com/psf/black - rev: 24.4.0 + rev: 24.4.2 hooks: - id: black args: ["--config", "./pyproject.toml"] @@ -71,7 +71,7 @@ repos: alias: black additional_dependencies: [black>=22.10.0] - repo: https://github.com/pre-commit/mirrors-mypy - rev: "v1.9.0" + rev: "v1.10.0" hooks: - id: mypy From 35220226a939b81c3f2bfc6d3dea501188cbf3ae Mon Sep 17 00:00:00 2001 From: Gleb <34161740+glebkrapivin@users.noreply.github.com> Date: Tue, 30 Apr 2024 11:34:50 +0200 Subject: [PATCH 139/223] Fixed getting values from extra__ keys in airflow con (#923) Fixes an issue #913 where in airflow 2.5.3 (and maybe other versions) `keyfile_json` could not be parsed from the airflow connection, because of the way it was referring to the wrong key in the `extra` dict in the connection. I also wrote unit tests - logically i think it might be a better idea to put them in the tests for base class, but i wanted to adhere to the overall logic. Pre-commit fails with `get_dbt_value` being to complex. Personally i would ignore that as i think branching for different `extras` increases readability in this case, but i am up for a discussion Closes #913 --- cosmos/profiles/base.py | 21 ++++++++++++------- .../test_bq_service_account_keyfile_dict.py | 21 ++++++++++++++++--- 2 files changed, 31 insertions(+), 11 deletions(-) mode change 100644 => 100755 cosmos/profiles/base.py mode change 100644 => 100755 tests/profiles/bigquery/test_bq_service_account_keyfile_dict.py diff --git a/cosmos/profiles/base.py b/cosmos/profiles/base.py old mode 100644 new mode 100755 index 9d17c40195..308d71a814 --- a/cosmos/profiles/base.py +++ b/cosmos/profiles/base.py @@ -241,6 +241,18 @@ def get_profile_file_contents( return str(yaml.dump(profile_contents, indent=4)) + def _get_airflow_conn_field(self, airflow_field: str) -> Any: + # make sure there's no "extra." prefix + if airflow_field.startswith("extra."): + airflow_field = airflow_field.replace("extra.", "", 1) + value = self.conn.extra_dejson.get(airflow_field) + elif airflow_field.startswith("extra__"): + value = self.conn.extra_dejson.get(airflow_field) + else: + value = getattr(self.conn, airflow_field, None) + + return value + def get_dbt_value(self, name: str) -> Any: """ Gets values for the dbt profile based on the required_by_dbt and required_in_profile_args lists. @@ -260,16 +272,9 @@ def get_dbt_value(self, name: str) -> Any: airflow_fields = [airflow_fields] for airflow_field in airflow_fields: - # make sure there's no "extra." prefix - if airflow_field.startswith("extra."): - airflow_field = airflow_field.replace("extra.", "", 1) - value = self.conn.extra_dejson.get(airflow_field) - else: - value = getattr(self.conn, airflow_field, None) - + value = self._get_airflow_conn_field(airflow_field) if not value: continue - # if there's a transform method, use it if hasattr(self, f"transform_{name}"): return getattr(self, f"transform_{name}")(value) diff --git a/tests/profiles/bigquery/test_bq_service_account_keyfile_dict.py b/tests/profiles/bigquery/test_bq_service_account_keyfile_dict.py old mode 100644 new mode 100755 index 00cf070c3d..4e56f5ba13 --- a/tests/profiles/bigquery/test_bq_service_account_keyfile_dict.py +++ b/tests/profiles/bigquery/test_bq_service_account_keyfile_dict.py @@ -1,4 +1,5 @@ import json +from collections import namedtuple from unittest.mock import patch import pytest @@ -8,6 +9,7 @@ from cosmos.profiles import get_automatic_profile_mapping from cosmos.profiles.bigquery.service_account_keyfile_dict import GoogleCloudServiceAccountDictProfileMapping +ConnExtraParams = namedtuple("ConnExtraParams", ["keyfile_dict", "keyfile_json_extra_key"]) sample_keyfile_dict = { "type": "service_account", "private_key_id": "my_private_key_id", @@ -15,7 +17,21 @@ } -@pytest.fixture(params=[sample_keyfile_dict, json.dumps(sample_keyfile_dict)]) +def get_fixture_params(): + """ + Make a matrix of params for the fixture that mock connection, as there are multiple fields in + the airflow param mapping for the "keyfile_json" in GoogleCloudServiceAccountDictProfileMapping + """ + fixture_params = [] + for d in (sample_keyfile_dict, json.dumps(sample_keyfile_dict)): + for key in GoogleCloudServiceAccountDictProfileMapping.airflow_param_mapping.get("keyfile_json"): + if key.startswith("extra."): + key = key.replace("extra.", "") + fixture_params.append(ConnExtraParams(keyfile_dict=d, keyfile_json_extra_key=key)) + return fixture_params + + +@pytest.fixture(params=get_fixture_params()) def mock_bigquery_conn_with_dict(request): # type: ignore """ Mocks and returns an Airflow BigQuery connection. @@ -23,14 +39,13 @@ def mock_bigquery_conn_with_dict(request): # type: ignore extra = { "project": "my_project", "dataset": "my_dataset", - "keyfile_dict": request.param, + request.param.keyfile_json_extra_key: request.param.keyfile_dict, } conn = Connection( conn_id="my_bigquery_connection", conn_type="google_cloud_platform", extra=json.dumps(extra), ) - with patch("airflow.hooks.base.BaseHook.get_connection", return_value=conn): yield conn From 7715c3f1aad46798bc8b07556acfd7395e473f01 Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Wed, 1 May 2024 15:51:33 +0100 Subject: [PATCH 140/223] Improve performance by 22-35% or more by caching partial parse artefact (#904) Improve the performance to run the benchmark DAG with 100 tasks by 34% and the benchmark DAG with 10 tasks by 22%, by persisting the dbt partial parse artifact in Airflow nodes. This performance can be even higher in the case of dbt projects that take more time to be parsed. With the introduction of #800, Cosmos supports using dbt partial parsing files. This feature has led to a substantial performance improvement, particularly for large dbt projects, both during Airflow DAG parsing (using LoadMode.DBT_LS) and also Airflow task execution (when using `ExecutionMode.LOCAL` and `ExecutionMode.VIRTUALENV`). There were two limitations with the initial support to partial parsing, which the current PR aims to address: 1. DAGs using Cosmos `ProfileMapping` classes could not leverage this feature. This is because the partial parsing relies on profile files not changing, and by default, Cosmos would mock the dbt profile in several parts of the code. The consequence is that users trying Cosmos 1.4.0a1 will see the following message: ``` 13:33:16 Unable to do partial parsing because profile has changed 13:33:16 Unable to do partial parsing because env vars used in profiles.yml have changed ``` 2. The user had to explicitly provide a `partial_parse.msgpack` file in the original project folder for their Airflow deployment - and if, for any reason, this became outdated, the user would not leverage the partial parsing feature. Since Cosmos runs dbt tasks from within a temporary directory, the partial parse would be stale for some users, it would be updated in the temporary directory, but the next time the task was run, Cosmos/dbt would not leverage the recently updated `partial_parse.msgpack` file. The current PR addresses these two issues respectfully by: 1. Allowing users that want to leverage Cosmos `ProfileMapping` and partial parsing to use `RenderConfig(enable_mock_profile=False)` 2. Introducing a Cosmos cache directory where we are persisting partial parsing files. This feature is enabled by default, but users can opt out by setting the Airflow configuration `[cosmos][enable_cache] = False` (exporting the environment variable `AIRFLOW__COSMOS__ENABLE_CACHE=0`). Users can also define the temporary directory used to store these files using the `[cosmos][cache_dir]` Airflow configuration. By default, Cosmos will create and use a folder `cosmos` inside the system's temporary directory: https://docs.python.org/3/library/tempfile.html#tempfile.gettempdir . This PR affects both DAG parsing and task execution. Although it does not introduce an optimisation per se, it makes the partial parse feature implemented #800 available to more users. Closes: #722 I updated the documentation in the PR: #898 Some future steps related to optimization associated to caching to be addressed in separate PRs: i. Change how we create mocked profiles, to create the file itself in the same way, referencing an environment variable with the same name - and only changing the value of the environment variable (#924) ii. Extend caching to the `profiles.yml` created by Cosmos in the newly introduced `tmp/cosmos` without the need to recreate it every time (#925). iii. Extend caching to the Airflow DAG/Task group as a pickle file - this approach is more generic and would work for every type of DAG parsing and executor. (#926) iv. Support persisting/fetching the cache from remote storage so we don't have to replicate it for every Airflow scheduler and worker node. (#927) v. Cache dbt deps lock file/avoid installing dbt steps every time. We can leverage `package-lock.yml` introduced in dbt t 1.7 (https://docs.getdbt.com/reference/commands/deps#predictable-package-installs), but ideally, we'd have a strategy to support older versions of dbt as well. (#930) vi. Support caching `partial_parse.msgpack` even when vars change: https://medium.com/@sebastian.daum89/how-to-speed-up-single-dbt-invocations-when-using-changing-dbt-variables-b9d91ce3fb0d vii. Support partial parsing in Docker and Kubernetes Cosmos executors (#929) viii. Centralise all the Airflow-based config into Cosmos settings.py & create a dedicated docs page containing information about these (#928) **How to validate this change** Run the performance benchmark against this and the `main` branch, checking the value of `/tmp/performance_results.txt`. Example of commands run locally: ``` # Setup AIRFLOW_HOME=`pwd` AIRFLOW_CONN_AIRFLOW_DB="postgres://postgres:postgres@0.0.0.0:5432/postgres" PYTHONPATH=`pwd` AIRFLOW_HOME=`pwd` AIRFLOW__CORE__DAGBAG_IMPORT_TIMEOUT=20000 AIRFLOW__CORE__DAG_FILE_PROCESSOR_TIMEOUT=20000 hatch run tests.py3.11-2.7:test-performance-setup # Run test for 100 dbt models per DAG: MODEL_COUNT=100 AIRFLOW_HOME=`pwd` AIRFLOW_CONN_AIRFLOW_DB="postgres://postgres:postgres@0.0.0.0:5432/postgres" PYTHONPATH=`pwd` AIRFLOW_HOME=`pwd` AIRFLOW__CORE__DAGBAG_IMPORT_TIMEOUT=20000 AIRFLOW__CORE__DAG_FILE_PROCESSOR_TIMEOUT=20000 hatch run tests.py3.11-2.7:test-performance ``` An example of output when running 100 with the main branch: ``` NUM_MODELS=100 TIME=114.18614888191223 MODELS_PER_SECOND=0.8757629623135543 DBT_VERSION=1.7.13 ``` And with the current PR: ``` NUM_MODELS=100 TIME=75.17766404151917 MODELS_PER_SECOND=1.33018232576064 DBT_VERSION=1.7.13 ``` --- cosmos/cache.py | 124 ++++++++++++++++++++++++++++ cosmos/config.py | 6 +- cosmos/constants.py | 2 + cosmos/converter.py | 19 ++--- cosmos/dbt/graph.py | 29 +++++-- cosmos/dbt/project.py | 13 ++- cosmos/operators/base.py | 6 ++ cosmos/operators/local.py | 26 ++++-- cosmos/settings.py | 11 +++ dev/dags/basic_cosmos_task_group.py | 11 ++- dev/dags/cosmos_profile_mapping.py | 6 +- pyproject.toml | 2 +- tests/dbt/test_graph.py | 43 +++++++++- tests/dbt/test_project.py | 26 +----- tests/operators/test_local.py | 30 +++++++ tests/operators/test_virtualenv.py | 3 + tests/test_cache.py | 66 +++++++++++++++ tests/test_converter.py | 73 ++++++++++++++++ 18 files changed, 426 insertions(+), 70 deletions(-) create mode 100644 cosmos/cache.py create mode 100644 cosmos/settings.py create mode 100644 tests/test_cache.py diff --git a/cosmos/cache.py b/cosmos/cache.py new file mode 100644 index 0000000000..3c2086c7ae --- /dev/null +++ b/cosmos/cache.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import shutil +from pathlib import Path + +from airflow.models.dag import DAG +from airflow.utils.task_group import TaskGroup + +from cosmos import settings +from cosmos.constants import DBT_MANIFEST_FILE_NAME, DBT_TARGET_DIR_NAME +from cosmos.dbt.project import get_partial_parse_path + + +# It was considered to create a cache identifier based on the dbt project path, as opposed +# to where it is used in Airflow. However, we could have concurrency issues if the same +# dbt cached directory was being used by different dbt task groups or DAGs within the same +# node. For this reason, as a starting point, the cache is identified by where it is used. +# This can be reviewed in the future. +def _create_cache_identifier(dag: DAG, task_group: TaskGroup | None) -> str: + """ + Given a DAG name and a (optional) task_group_name, create the identifier for caching. + + :param dag_name: Name of the Cosmos DbtDag being cached + :param task_group_name: (optional) Name of the Cosmos DbtTaskGroup being cached + :return: Unique identifier representing the cache + """ + if task_group: + if task_group.dag_id is not None: + cache_identifiers_list = [task_group.dag_id] + if task_group.group_id is not None: + cache_identifiers_list.extend([task_group.group_id.replace(".", "__")]) + cache_identifier = "__".join(cache_identifiers_list) + else: + cache_identifier = dag.dag_id + + return cache_identifier + + +def _obtain_cache_dir_path(cache_identifier: str, base_dir: Path = settings.cache_dir) -> Path: + """ + Return a directory used to cache a specific Cosmos DbtDag or DbtTaskGroup. If the directory + does not exist, create it. + + :param cache_identifier: Unique key used as a cache identifier + :param base_dir: Root directory where cache will be stored + :return: Path to directory used to cache this specific Cosmos DbtDag or DbtTaskGroup + """ + cache_dir_path = base_dir / cache_identifier + tmp_target_dir = cache_dir_path / DBT_TARGET_DIR_NAME + tmp_target_dir.mkdir(parents=True, exist_ok=True) + return cache_dir_path + + +def _get_timestamp(path: Path) -> float: + """ + Return the timestamp of a path or 0, if it does not exist. + + :param path: Path to the file or directory of interest + :return: File or directory timestamp + """ + try: + timestamp = path.stat().st_mtime + except FileNotFoundError: + timestamp = 0 + return timestamp + + +def _get_latest_partial_parse(dbt_project_path: Path, cache_dir: Path) -> Path | None: + """ + Return the path to the latest partial parse file, if defined. + + :param dbt_project_path: Original dbt project path + :param cache_dir: Path to the Cosmos project cache directory + :return: Either return the Path to the latest partial parse file, or None. + """ + project_partial_parse_path = get_partial_parse_path(dbt_project_path) + cosmos_cached_partial_parse_filepath = get_partial_parse_path(cache_dir) + + age_project_partial_parse = _get_timestamp(project_partial_parse_path) + age_cosmos_cached_partial_parse_filepath = _get_timestamp(cosmos_cached_partial_parse_filepath) + + if age_project_partial_parse and age_cosmos_cached_partial_parse_filepath: + if age_project_partial_parse > age_cosmos_cached_partial_parse_filepath: + return project_partial_parse_path + else: + return cosmos_cached_partial_parse_filepath + elif age_project_partial_parse: + return project_partial_parse_path + elif age_cosmos_cached_partial_parse_filepath: + return cosmos_cached_partial_parse_filepath + + return None + + +def _update_partial_parse_cache(latest_partial_parse_filepath: Path, cache_dir: Path) -> None: + """ + Update the cache to have the latest partial parse file contents. + + :param latest_partial_parse_filepath: Path to the most up-to-date partial parse file + :param cache_dir: Path to the Cosmos project cache directory + """ + cache_path = get_partial_parse_path(cache_dir) + manifest_path = get_partial_parse_path(cache_dir).parent / DBT_MANIFEST_FILE_NAME + latest_manifest_filepath = latest_partial_parse_filepath.parent / DBT_MANIFEST_FILE_NAME + + shutil.copy(str(latest_partial_parse_filepath), str(cache_path)) + shutil.copy(str(latest_manifest_filepath), str(manifest_path)) + + +def _copy_partial_parse_to_project(partial_parse_filepath: Path, project_path: Path) -> None: + """ + Update target dbt project directory to have the latest partial parse file contents. + + :param partial_parse_filepath: Path to the most up-to-date partial parse file + :param project_path: Path to the target dbt project directory + """ + target_partial_parse_file = get_partial_parse_path(project_path) + tmp_target_dir = project_path / DBT_TARGET_DIR_NAME + tmp_target_dir.mkdir(exist_ok=True) + + source_manifest_filepath = partial_parse_filepath.parent / DBT_MANIFEST_FILE_NAME + target_manifest_filepath = target_partial_parse_file.parent / DBT_MANIFEST_FILE_NAME + shutil.copy(str(partial_parse_filepath), str(target_partial_parse_file)) + shutil.copy(str(source_manifest_filepath), str(target_manifest_filepath)) diff --git a/cosmos/config.py b/cosmos/config.py index 729e95c75f..64a7acd089 100644 --- a/cosmos/config.py +++ b/cosmos/config.py @@ -54,6 +54,7 @@ class RenderConfig: :param env_vars: (Deprecated since Cosmos 1.3 use ProjectConfig.env_vars) A dictionary of environment variables for rendering. Only supported when using ``LoadMode.DBT_LS``. :param dbt_project_path: Configures the DBT project location accessible on the airflow controller for DAG rendering. Mutually Exclusive with ProjectConfig.dbt_project_path. Required when using ``load_method=LoadMode.DBT_LS`` or ``load_method=LoadMode.CUSTOM``. :param dbt_ls_path: Configures the location of an output of ``dbt ls``. Required when using ``load_method=LoadMode.DBT_LS_FILE``. + :param enable_mock_profile: Allows to enable/disable mocking profile. Enabled by default. Mock profiles are useful for parsing Cosmos DAGs in the CI, but should be disabled to benefit from partial parsing (since Cosmos 1.4). """ emit_datasets: bool = True @@ -68,8 +69,8 @@ class RenderConfig: env_vars: dict[str, str] | None = None dbt_project_path: InitVar[str | Path | None] = None dbt_ls_path: Path | None = None - project_path: Path | None = field(init=False) + enable_mock_profile: bool = True def __post_init__(self, dbt_project_path: str | Path | None) -> None: if self.env_vars: @@ -288,7 +289,8 @@ def ensure_profile( with tempfile.TemporaryDirectory() as temp_dir: temp_file = Path(temp_dir) / DEFAULT_PROFILES_FILE_NAME logger.info( - "Creating temporary profiles.yml at %s with the following contents:\n%s", + "Creating temporary profiles.yml with use_mock_values=%s at %s with the following contents:\n%s", + use_mock_values, temp_file, profile_contents, ) diff --git a/cosmos/constants.py b/cosmos/constants.py index 1db78d15bd..bea5e25eb2 100644 --- a/cosmos/constants.py +++ b/cosmos/constants.py @@ -7,11 +7,13 @@ DBT_PROFILE_PATH = Path(os.path.expanduser("~")).joinpath(".dbt/profiles.yml") DEFAULT_DBT_PROFILE_NAME = "cosmos_profile" DEFAULT_DBT_TARGET_NAME = "cosmos_target" +DEFAULT_COSMOS_CACHE_DIR_NAME = "cosmos" DBT_LOG_PATH_ENVVAR = "DBT_LOG_PATH" DBT_LOG_DIR_NAME = "logs" DBT_TARGET_PATH_ENVVAR = "DBT_TARGET_PATH" DBT_TARGET_DIR_NAME = "target" DBT_PARTIAL_PARSE_FILE_NAME = "partial_parse.msgpack" +DBT_MANIFEST_FILE_NAME = "manifest.json" DBT_LOG_FILENAME = "dbt.log" DBT_BINARY_NAME = "dbt" diff --git a/cosmos/converter.py b/cosmos/converter.py index f9511ab821..08a44b6766 100644 --- a/cosmos/converter.py +++ b/cosmos/converter.py @@ -11,6 +11,7 @@ from airflow.models.dag import DAG from airflow.utils.task_group import TaskGroup +from cosmos import cache, settings from cosmos.airflow.graph import build_airflow_graph from cosmos.config import ExecutionConfig, ProfileConfig, ProjectConfig, RenderConfig from cosmos.constants import ExecutionMode @@ -214,8 +215,6 @@ def __init__( validate_initial_user_config(execution_config, profile_config, project_config, render_config, operator_args) - # If we are using the old interface, we should migrate it to the new interface - # This is safe to do now since we have validated which config interface we're using if project_config.dbt_project_path: execution_config, render_config = migrate_to_new_interface(execution_config, project_config, render_config) @@ -224,21 +223,16 @@ def __init__( env_vars = project_config.env_vars or operator_args.get("env") dbt_vars = project_config.dbt_vars or operator_args.get("vars") - # Previously, we were creating a cosmos.dbt.project.DbtProject - # DbtProject has now been replaced with ProjectConfig directly - # since the interface of the two classes were effectively the same - # Under this previous implementation, we were passing: - # - name, root dir, models dir, snapshots dir and manifest path - # Internally in the dbtProject class, we were defaulting the profile_path - # To be root dir/profiles.yml - # To keep this logic working, if converter is given no ProfileConfig, - # we can create a default retaining this value to preserve this functionality. - # We may want to consider defaulting this value in our actual ProjceConfig class? + cache_dir = None + if settings.enable_cache: + cache_dir = cache._obtain_cache_dir_path(cache_identifier=cache._create_cache_identifier(dag, task_group)) + self.dbt_graph = DbtGraph( project=project_config, render_config=render_config, execution_config=execution_config, profile_config=profile_config, + cache_dir=cache_dir, dbt_vars=dbt_vars, ) self.dbt_graph.load(method=render_config.load_method, execution_mode=execution_config.execution_mode) @@ -251,6 +245,7 @@ def __init__( "emit_datasets": render_config.emit_datasets, "env": env_vars, "vars": dbt_vars, + "cache_dir": cache_dir, } if execution_config.dbt_executable_path: task_args["dbt_executable_path"] = execution_config.dbt_executable_path diff --git a/cosmos/dbt/graph.py b/cosmos/dbt/graph.py index 160738fb55..09c00c6d13 100644 --- a/cosmos/dbt/graph.py +++ b/cosmos/dbt/graph.py @@ -11,6 +11,7 @@ import yaml +from cosmos import cache from cosmos.config import ExecutionConfig, ProfileConfig, ProjectConfig, RenderConfig from cosmos.constants import ( DBT_LOG_DIR_NAME, @@ -23,7 +24,7 @@ LoadMode, ) from cosmos.dbt.parser.project import LegacyDbtProject -from cosmos.dbt.project import copy_msgpack_for_partial_parse, create_symlinks, environ +from cosmos.dbt.project import create_symlinks, environ, get_partial_parse_path from cosmos.dbt.selector import select_nodes from cosmos.log import get_logger @@ -98,7 +99,7 @@ def is_freshness_effective(freshness: dict[str, Any]) -> bool: def run_command(command: list[str], tmp_dir: Path, env_vars: dict[str, str]) -> str: """Run a command in a subprocess, returning the stdout.""" logger.info("Running command: `%s`", " ".join(command)) - logger.info("Environment variable keys: %s", env_vars.keys()) + logger.debug("Environment variable keys: %s", env_vars.keys()) process = Popen( command, stdout=PIPE, @@ -164,6 +165,7 @@ def __init__( render_config: RenderConfig = RenderConfig(), execution_config: ExecutionConfig = ExecutionConfig(), profile_config: ProfileConfig | None = None, + cache_dir: Path | None = None, # dbt_vars only supported for LegacyDbtProject dbt_vars: dict[str, str] | None = None, ): @@ -171,6 +173,7 @@ def __init__( self.render_config = render_config self.profile_config = profile_config self.execution_config = execution_config + self.cache_dir = cache_dir self.dbt_vars = dbt_vars or {} def load( @@ -285,14 +288,19 @@ def load_via_dbt_ls(self) -> None: f"Content of the dbt project dir {self.render_config.project_path}: `{os.listdir(self.render_config.project_path)}`" ) tmpdir_path = Path(tmpdir) - create_symlinks(self.render_config.project_path, tmpdir_path, self.render_config.dbt_deps) - if self.project.partial_parse: - copy_msgpack_for_partial_parse(self.render_config.project_path, tmpdir_path) + abs_project_path = self.render_config.project_path.absolute() + create_symlinks(abs_project_path, tmpdir_path, self.render_config.dbt_deps) - with self.profile_config.ensure_profile(use_mock_values=True) as profile_values, environ( - self.project.env_vars or self.render_config.env_vars or {} - ): + if self.project.partial_parse and self.cache_dir: + latest_partial_parse = cache._get_latest_partial_parse(abs_project_path, self.cache_dir) + logger.info("Partial parse is enabled and the latest partial parse file is %s", latest_partial_parse) + if latest_partial_parse is not None: + cache._copy_partial_parse_to_project(latest_partial_parse, tmpdir_path) + + with self.profile_config.ensure_profile( + use_mock_values=self.render_config.enable_mock_profile + ) as profile_values, environ(self.project.env_vars or self.render_config.env_vars or {}): (profile_path, env_vars) = profile_values env = os.environ.copy() env.update(env_vars) @@ -323,6 +331,11 @@ def load_via_dbt_ls(self) -> None: self.nodes = nodes self.filtered_nodes = nodes + if self.project.partial_parse and self.cache_dir: + partial_parse_file = get_partial_parse_path(tmpdir_path) + if partial_parse_file.exists(): + cache._update_partial_parse_cache(partial_parse_file, self.cache_dir) + def load_via_dbt_ls_file(self) -> None: """ This is between dbt ls and full manifest. It allows to use the output (needs to be json output) of the dbt ls as a diff --git a/cosmos/dbt/project.py b/cosmos/dbt/project.py index ad328a3324..4a3b036b36 100644 --- a/cosmos/dbt/project.py +++ b/cosmos/dbt/project.py @@ -20,14 +20,11 @@ def create_symlinks(project_path: Path, tmp_dir: Path, ignore_dbt_packages: bool os.symlink(project_path / child_name, tmp_dir / child_name) -def copy_msgpack_for_partial_parse(project_path: Path, tmp_dir: Path) -> None: - partial_parse_file = Path(project_path) / DBT_TARGET_DIR_NAME / DBT_PARTIAL_PARSE_FILE_NAME - - if partial_parse_file.exists(): - tmp_target_dir = tmp_dir / DBT_TARGET_DIR_NAME - tmp_target_dir.mkdir(exist_ok=True) - - shutil.copy(str(partial_parse_file), str(tmp_target_dir / DBT_PARTIAL_PARSE_FILE_NAME)) +def get_partial_parse_path(project_dir_path: Path) -> Path: + """ + Return the partial parse (partial_parse.msgpack) path for a given dbt project directory. + """ + return project_dir_path / DBT_TARGET_DIR_NAME / DBT_PARTIAL_PARSE_FILE_NAME @contextmanager diff --git a/cosmos/operators/base.py b/cosmos/operators/base.py index b6e1797d87..f9f4645b60 100644 --- a/cosmos/operators/base.py +++ b/cosmos/operators/base.py @@ -2,6 +2,8 @@ import os from abc import ABCMeta, abstractmethod +from functools import cached_property +from pathlib import Path from typing import Any, Sequence, Tuple import yaml @@ -10,6 +12,7 @@ from airflow.utils.operator_helpers import context_to_airflow_vars from airflow.utils.strings import to_boolean +from cosmos import cache from cosmos.dbt.executable import get_system_dbt from cosmos.log import get_logger @@ -61,6 +64,7 @@ class AbstractDbtBaseOperator(BaseOperator, metaclass=ABCMeta): (i.e. /home/astro/.pyenv/versions/dbt_venv/bin/dbt) :param dbt_cmd_flags: List of flags to pass to dbt command :param dbt_cmd_global_flags: List of dbt global flags to be passed to the dbt command + :param cache_dir: Directory used to cache Cosmos/dbt artifacts in Airflow worker nodes """ template_fields: Sequence[str] = ("env", "select", "exclude", "selector", "vars", "models") @@ -108,6 +112,7 @@ def __init__( dbt_executable_path: str = get_system_dbt(), dbt_cmd_flags: list[str] | None = None, dbt_cmd_global_flags: list[str] | None = None, + cache_dir: Path | None = None, **kwargs: Any, ) -> None: self.project_dir = project_dir @@ -135,6 +140,7 @@ def __init__( self.dbt_executable_path = dbt_executable_path self.dbt_cmd_flags = dbt_cmd_flags self.dbt_cmd_global_flags = dbt_cmd_global_flags or [] + self.cache_dir = cache_dir super().__init__(**kwargs) def get_env(self, context: Context) -> dict[str, str | bytes | os.PathLike[Any]]: diff --git a/cosmos/operators/local.py b/cosmos/operators/local.py index b39de10f80..4a34da13f9 100644 --- a/cosmos/operators/local.py +++ b/cosmos/operators/local.py @@ -18,7 +18,9 @@ from airflow.utils.session import NEW_SESSION, create_session, provide_session from attr import define +from cosmos import cache from cosmos.constants import InvocationMode +from cosmos.dbt.project import get_partial_parse_path try: from airflow.datasets import Dataset @@ -49,7 +51,7 @@ parse_number_of_warnings_dbt_runner, parse_number_of_warnings_subprocess, ) -from cosmos.dbt.project import change_working_directory, copy_msgpack_for_partial_parse, create_symlinks, environ +from cosmos.dbt.project import change_working_directory, create_symlinks, environ from cosmos.hooks.subprocess import ( FullOutputSubprocessHook, FullOutputSubprocessResult, @@ -261,7 +263,6 @@ def run_dbt_runner(self, command: list[str], env: dict[str, str], cwd: str) -> d # Exclude the dbt executable path from the command cli_args = command[1:] - logger.info("Trying to run dbtRunner with:\n %s\n in %s", cli_args, cwd) with change_working_directory(cwd), environ(env): @@ -282,16 +283,21 @@ def run_command( self._discover_invocation_mode() with tempfile.TemporaryDirectory() as tmp_project_dir: + logger.info( "Cloning project to writable temp directory %s from %s", tmp_project_dir, self.project_dir, ) + tmp_dir_path = Path(tmp_project_dir) env = {k: str(v) for k, v in env.items()} - create_symlinks(Path(self.project_dir), Path(tmp_project_dir), self.install_deps) + create_symlinks(Path(self.project_dir), tmp_dir_path, self.install_deps) - if self.partial_parse: - copy_msgpack_for_partial_parse(Path(self.project_dir), Path(tmp_project_dir)) + if self.partial_parse and self.cache_dir is not None: + latest_partial_parse = cache._get_latest_partial_parse(Path(self.project_dir), self.cache_dir) + logger.info("Partial parse is enabled and the latest partial parse file is %s", latest_partial_parse) + if latest_partial_parse is not None: + cache._copy_partial_parse_to_project(latest_partial_parse, tmp_dir_path) with self.profile_config.ensure_profile() as profile_values: (profile_path, env_vars) = profile_values @@ -319,14 +325,15 @@ def run_command( full_cmd = cmd + flags - logger.info("Using environment variables keys: %s", env.keys()) + logger.debug("Using environment variables keys: %s", env.keys()) + result = self.invoke_dbt( command=full_cmd, env=env, cwd=tmp_project_dir, ) if is_openlineage_available: - self.calculate_openlineage_events_completes(env, Path(tmp_project_dir)) + self.calculate_openlineage_events_completes(env, tmp_dir_path) context[ "task_instance" ].openlineage_events_completes = self.openlineage_events_completes # type: ignore @@ -338,6 +345,11 @@ def run_command( logger.info("Outlets: %s", outlets) self.register_dataset(inlets, outlets) + if self.partial_parse and self.cache_dir: + partial_parse_file = get_partial_parse_path(tmp_dir_path) + if partial_parse_file.exists(): + cache._update_partial_parse_cache(partial_parse_file, self.cache_dir) + self.store_compiled_sql(tmp_project_dir, context) self.handle_exception(result) if self.callback: diff --git a/cosmos/settings.py b/cosmos/settings.py new file mode 100644 index 0000000000..35e235edc1 --- /dev/null +++ b/cosmos/settings.py @@ -0,0 +1,11 @@ +import tempfile +from pathlib import Path + +from airflow.configuration import conf + +from cosmos.constants import DEFAULT_COSMOS_CACHE_DIR_NAME + +# In MacOS users may want to set the envvar `TMPDIR` if they do not want the value of the temp directory to change +DEFAULT_CACHE_DIR = Path(tempfile.gettempdir(), DEFAULT_COSMOS_CACHE_DIR_NAME) +cache_dir = Path(conf.get("cosmos", "cache_dir", fallback=DEFAULT_CACHE_DIR) or DEFAULT_CACHE_DIR) +enable_cache = conf.get("cosmos", "enable_cache", fallback=True) diff --git a/dev/dags/basic_cosmos_task_group.py b/dev/dags/basic_cosmos_task_group.py index 55842ee102..f230e87d4e 100644 --- a/dev/dags/basic_cosmos_task_group.py +++ b/dev/dags/basic_cosmos_task_group.py @@ -25,7 +25,9 @@ ), ) -shared_execution_config = ExecutionConfig(invocation_mode=InvocationMode.DBT_RUNNER) +shared_execution_config = ExecutionConfig( + invocation_mode=InvocationMode.SUBPROCESS, +) @dag( @@ -44,7 +46,7 @@ def basic_cosmos_task_group() -> None: project_config=ProjectConfig( (DBT_ROOT_PATH / "jaffle_shop").as_posix(), ), - render_config=RenderConfig(select=["path:seeds/raw_customers.csv"]), + render_config=RenderConfig(select=["path:seeds/raw_customers.csv"], enable_mock_profile=False), execution_config=shared_execution_config, operator_args={"install_deps": True}, profile_config=profile_config, @@ -56,7 +58,10 @@ def basic_cosmos_task_group() -> None: project_config=ProjectConfig( (DBT_ROOT_PATH / "jaffle_shop").as_posix(), ), - render_config=RenderConfig(select=["path:seeds/raw_orders.csv"]), + render_config=RenderConfig( + select=["path:seeds/raw_orders.csv"], + enable_mock_profile=False, # This is necessary to benefit from partial parsing when using ProfileMapping + ), execution_config=shared_execution_config, operator_args={"install_deps": True}, profile_config=profile_config, diff --git a/dev/dags/cosmos_profile_mapping.py b/dev/dags/cosmos_profile_mapping.py index 6570467e46..3c9a503ba3 100644 --- a/dev/dags/cosmos_profile_mapping.py +++ b/dev/dags/cosmos_profile_mapping.py @@ -11,12 +11,15 @@ from airflow.decorators import dag from airflow.operators.empty import EmptyOperator -from cosmos import DbtTaskGroup, ProfileConfig, ProjectConfig +from cosmos import DbtTaskGroup, ExecutionConfig, ProfileConfig, ProjectConfig +from cosmos.constants import InvocationMode from cosmos.profiles import get_automatic_profile_mapping DEFAULT_DBT_ROOT_PATH = Path(__file__).parent / "dbt" DBT_ROOT_PATH = Path(os.getenv("DBT_ROOT_PATH", DEFAULT_DBT_ROOT_PATH)) +execution_config = ExecutionConfig(invocation_mode=InvocationMode.DBT_RUNNER) + @dag( schedule_interval="@daily", @@ -30,6 +33,7 @@ def cosmos_profile_mapping() -> None: pre_dbt = EmptyOperator(task_id="pre_dbt") jaffle_shop = DbtTaskGroup( + execution_config=execution_config, project_config=ProjectConfig( DBT_ROOT_PATH / "jaffle_shop", ), diff --git a/pyproject.toml b/pyproject.toml index 0c26fa7995..efad8e043d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -199,7 +199,7 @@ line-length = 120 [tool.ruff.lint] select = ["C901", "I"] [tool.ruff.lint.mccabe] -max-complexity = 8 +max-complexity = 10 [tool.distutils.bdist_wheel] universal = true diff --git a/tests/dbt/test_graph.py b/tests/dbt/test_graph.py index f72bbb146a..22cd5c6178 100644 --- a/tests/dbt/test_graph.py +++ b/tests/dbt/test_graph.py @@ -8,7 +8,7 @@ import yaml from cosmos.config import CosmosConfigException, ExecutionConfig, ProfileConfig, ProjectConfig, RenderConfig -from cosmos.constants import DbtResourceType, ExecutionMode +from cosmos.constants import DBT_TARGET_DIR_NAME, DbtResourceType, ExecutionMode from cosmos.dbt.graph import ( CosmosLoadDbtException, DbtGraph, @@ -482,7 +482,9 @@ def test_load_via_dbt_ls_without_dbt_deps(postgres_profile_config): @pytest.mark.integration -def test_load_via_dbt_ls_without_dbt_deps_and_preinstalled_dbt_packages(tmp_dbt_project_dir, postgres_profile_config): +def test_load_via_dbt_ls_without_dbt_deps_and_preinstalled_dbt_packages( + tmp_dbt_project_dir, postgres_profile_config, caplog +): local_flags = [ "--project-dir", tmp_dbt_project_dir / DBT_PROJECT_NAME, @@ -515,7 +517,42 @@ def test_load_via_dbt_ls_without_dbt_deps_and_preinstalled_dbt_packages(tmp_dbt_ profile_config=postgres_profile_config, ) - dbt_graph.load_via_dbt_ls() # does not raise exception + assert dbt_graph.load_via_dbt_ls() is None # Doesn't raise any exceptions + + +@pytest.mark.integration +def test_load_via_dbt_ls_caching_partial_parsing(tmp_dbt_project_dir, postgres_profile_config, caplog, tmp_path): + """ + When using RenderConfig.enable_mock_profile=False and defining DbtGraph.cache_dir, + Cosmos should leverage dbt partial parsing. + """ + import logging + + caplog.set_level(logging.DEBUG) + + project_config = ProjectConfig(dbt_project_path=tmp_dbt_project_dir / DBT_PROJECT_NAME) + render_config = RenderConfig( + dbt_project_path=tmp_dbt_project_dir / DBT_PROJECT_NAME, dbt_deps=True, enable_mock_profile=False + ) + execution_config = ExecutionConfig(dbt_project_path=tmp_dbt_project_dir / DBT_PROJECT_NAME) + dbt_graph = DbtGraph( + project=project_config, + render_config=render_config, + execution_config=execution_config, + profile_config=postgres_profile_config, + cache_dir=tmp_path, + ) + + (tmp_path / DBT_TARGET_DIR_NAME).mkdir(parents=True, exist_ok=True) + + # First time dbt ls is run, partial parsing was not cached, so we don't benefit from this + dbt_graph.load_via_dbt_ls() + assert "Unable to do partial parsing" in caplog.text + + # From the second time we run dbt ls onwards, we benefit from partial parsing + caplog.clear() + dbt_graph.load_via_dbt_ls() # should not not raise exception + assert not "Unable to do partial parsing" in caplog.text @pytest.mark.integration diff --git a/tests/dbt/test_project.py b/tests/dbt/test_project.py index 6f9e2cb844..09ab1a7358 100644 --- a/tests/dbt/test_project.py +++ b/tests/dbt/test_project.py @@ -4,7 +4,7 @@ import pytest -from cosmos.dbt.project import change_working_directory, copy_msgpack_for_partial_parse, create_symlinks, environ +from cosmos.dbt.project import change_working_directory, create_symlinks, environ DBT_PROJECTS_ROOT_DIR = Path(__file__).parent.parent.parent / "dev/dags/dbt" @@ -20,30 +20,6 @@ def test_create_symlinks(tmp_path): assert child.name not in ("logs", "target", "profiles.yml", "dbt_packages") -@pytest.mark.parametrize("exists", [True, False]) -def test_copy_manifest_for_partial_parse(tmp_path, exists): - project_path = tmp_path / "project" - target_path = project_path / "target" - partial_parse_file = target_path / "partial_parse.msgpack" - - target_path.mkdir(parents=True) - - if exists: - partial_parse_file.write_bytes(b"") - - tmp_dir = tmp_path / "tmp_dir" - tmp_dir.mkdir() - - copy_msgpack_for_partial_parse(project_path, tmp_dir) - - tmp_partial_parse_file = tmp_dir / "target" / "partial_parse.msgpack" - - if exists: - assert tmp_partial_parse_file.exists() - else: - assert not tmp_partial_parse_file.exists() - - @patch.dict(os.environ, {"VAR1": "value1", "VAR2": "value2"}) def test_environ_context_manager(): # Define the expected environment variables diff --git a/tests/operators/test_local.py b/tests/operators/test_local.py index 250b044fad..80c6c58a46 100644 --- a/tests/operators/test_local.py +++ b/tests/operators/test_local.py @@ -3,6 +3,7 @@ import shutil import sys import tempfile +from datetime import datetime from pathlib import Path from unittest.mock import MagicMock, call, patch @@ -15,6 +16,7 @@ from packaging import version from pendulum import datetime +from cosmos import cache from cosmos.config import ProfileConfig from cosmos.constants import InvocationMode from cosmos.dbt.parser.output import ( @@ -422,6 +424,33 @@ def test_run_operator_dataset_inlets_and_outlets(): assert test_operator.outlets == [] +@pytest.mark.integration +def test_run_operator_caches_partial_parsing(caplog, tmp_path): + caplog.set_level(logging.DEBUG) + with DAG("test-partial-parsing", start_date=datetime(2022, 1, 1)) as dag: + seed_operator = DbtSeedLocalOperator( + profile_config=real_profile_config, + project_dir=DBT_PROJ_DIR, + task_id="seed", + dbt_cmd_flags=["--select", "raw_customers"], + install_deps=True, + append_env=True, + cache_dir=cache._obtain_cache_dir_path("test-partial-parsing", tmp_path), + invocation_mode=InvocationMode.SUBPROCESS, + ) + seed_operator + + run_test_dag(dag) + + # Unable to do partial parsing because saved manifest not found. Starting full parse. + assert "Unable to do partial parsing" in caplog.text + + caplog.clear() + run_test_dag(dag) + + assert not "Unable to do partial parsing" in caplog.text + + def test_dbt_base_operator_no_partial_parse() -> None: dbt_base_operator = ConcreteDbtLocalBaseOperator( @@ -757,6 +786,7 @@ def test_operator_execute_deps_parameters( "dev", ] task = DbtRunLocalOperator( + dag=DAG("sample_dag", start_date=datetime(2024, 4, 16)), profile_config=real_profile_config, task_id="my-task", project_dir=DBT_PROJ_DIR, diff --git a/tests/operators/test_virtualenv.py b/tests/operators/test_virtualenv.py index 5866347abe..acf3c72af9 100644 --- a/tests/operators/test_virtualenv.py +++ b/tests/operators/test_virtualenv.py @@ -1,5 +1,7 @@ +from datetime import datetime from unittest.mock import MagicMock, patch +from airflow.models import DAG from airflow.models.connection import Connection from cosmos.config import ProfileConfig @@ -45,6 +47,7 @@ def test_run_command( schema="fake_schema", ) venv_operator = ConcreteDbtVirtualenvBaseOperator( + dag=DAG("sample_dag", start_date=datetime(2024, 4, 16)), profile_config=profile_config, task_id="fake_task", install_deps=True, diff --git a/tests/test_cache.py b/tests/test_cache.py new file mode 100644 index 0000000000..2898475d09 --- /dev/null +++ b/tests/test_cache.py @@ -0,0 +1,66 @@ +import time +from datetime import datetime + +import pytest +from airflow import DAG +from airflow.utils.task_group import TaskGroup + +from cosmos.cache import _create_cache_identifier, _get_latest_partial_parse +from cosmos.constants import DBT_PARTIAL_PARSE_FILE_NAME, DBT_TARGET_DIR_NAME + +START_DATE = datetime(2024, 4, 16) +example_dag = DAG("dag", start_date=START_DATE) + + +@pytest.mark.parametrize( + "dag, task_group, result_identifier", + [ + (example_dag, None, "dag"), + (None, TaskGroup(dag=example_dag, group_id="inner_tg"), "dag__inner_tg"), + ( + None, + TaskGroup( + dag=example_dag, group_id="child_tg", parent_group=TaskGroup(dag=example_dag, group_id="parent_tg") + ), + "dag__parent_tg__child_tg", + ), + ( + None, + TaskGroup( + dag=example_dag, + group_id="child_tg", + parent_group=TaskGroup( + dag=example_dag, group_id="mum_tg", parent_group=TaskGroup(dag=example_dag, group_id="nana_tg") + ), + ), + "dag__nana_tg__mum_tg__child_tg", + ), + ], +) +def test_create_cache_identifier(dag, task_group, result_identifier): + assert _create_cache_identifier(dag, task_group) == result_identifier + + +def test_get_latest_partial_parse(tmp_path): + old_tmp_dir = tmp_path / "old" + old_tmp_target_dir = old_tmp_dir / DBT_TARGET_DIR_NAME + old_tmp_target_dir.mkdir(parents=True, exist_ok=True) + old_partial_parse_filepath = old_tmp_target_dir / DBT_PARTIAL_PARSE_FILE_NAME + old_partial_parse_filepath.touch() + + # This is necessary in the CI, but not on local MacOS dev env, since the files + # were being created too quickly and sometimes had the same st_mtime + time.sleep(1) + + new_tmp_dir = tmp_path / "new" + new_tmp_target_dir = new_tmp_dir / DBT_TARGET_DIR_NAME + new_tmp_target_dir.mkdir(parents=True, exist_ok=True) + new_partial_parse_filepath = new_tmp_target_dir / DBT_PARTIAL_PARSE_FILE_NAME + new_partial_parse_filepath.touch() + + assert _get_latest_partial_parse(old_tmp_dir, new_tmp_dir) == new_partial_parse_filepath + assert _get_latest_partial_parse(new_tmp_dir, old_tmp_dir) == new_partial_parse_filepath + assert _get_latest_partial_parse(old_tmp_dir, old_tmp_dir) == old_partial_parse_filepath + assert _get_latest_partial_parse(old_tmp_dir, tmp_path) == old_partial_parse_filepath + assert _get_latest_partial_parse(tmp_path, old_tmp_dir) == old_partial_parse_filepath + assert _get_latest_partial_parse(tmp_path, tmp_path) is None diff --git a/tests/test_converter.py b/tests/test_converter.py index 10dc37f13a..bef2dc06d4 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -1,3 +1,4 @@ +import tempfile from datetime import datetime from pathlib import Path from unittest.mock import MagicMock, patch @@ -152,6 +153,7 @@ def test_converter_creates_dag_with_seed(mock_load_dbt_graph, execution_mode, op profiles_yml_filepath=SAMPLE_PROFILE_YML, ) converter = DbtToAirflowConverter( + dag=DAG("sample_dag", start_date=datetime(2024, 4, 16)), nodes=nodes, project_config=project_config, profile_config=profile_config, @@ -185,6 +187,7 @@ def test_converter_creates_dag_with_project_path_str(mock_load_dbt_graph, execut profiles_yml_filepath=SAMPLE_PROFILE_YML, ) converter = DbtToAirflowConverter( + dag=DAG("sample_dag", start_date=datetime(2024, 4, 16)), nodes=nodes, project_config=project_config, profile_config=profile_config, @@ -470,6 +473,75 @@ def test_converter_invocation_mode_added_to_task_args( assert "invocation_mode" not in kwargs["task_args"] +@patch("cosmos.config.ProjectConfig.validate_project") +@patch("cosmos.converter.validate_initial_user_config") +@patch("cosmos.converter.DbtGraph") +@patch("cosmos.converter.build_airflow_graph") +def test_converter_uses_cache_dir( + mock_build_airflow_graph, + mock_dbt_graph, + mock_user_config, + mock_validate_project, +): + """Tests that DbtGraph and operator and Airflow task args contain expected cache dir .""" + project_config = ProjectConfig(project_name="fake-project", dbt_project_path="/some/project/path") + execution_config = ExecutionConfig() + render_config = RenderConfig(enable_mock_profile=False) + profile_config = MagicMock() + + with DAG("test-id", start_date=datetime(2024, 1, 1)) as dag: + DbtToAirflowConverter( + dag=dag, + nodes=nodes, + project_config=project_config, + profile_config=profile_config, + execution_config=execution_config, + render_config=render_config, + operator_args={}, + ) + task_args_cache_dir = mock_build_airflow_graph.call_args[1]["task_args"]["cache_dir"] + dbt_graph_cache_dir = mock_dbt_graph.call_args[1]["cache_dir"] + + assert Path(tempfile.gettempdir()) in task_args_cache_dir.parents + assert task_args_cache_dir.parent.stem == "cosmos" + assert task_args_cache_dir.stem == "test-id" + assert task_args_cache_dir == dbt_graph_cache_dir + + +@patch("cosmos.settings.enable_cache", False) +@patch("cosmos.config.ProjectConfig.validate_project") +@patch("cosmos.converter.validate_initial_user_config") +@patch("cosmos.converter.DbtGraph") +@patch("cosmos.converter.build_airflow_graph") +def test_converter_disable_cache_sets_cache_dir_to_none( + mock_build_airflow_graph, + mock_dbt_graph, + mock_user_config, + mock_validate_project, +): + """Tests that DbtGraph and operator and Airflow task args contain expected cache dir.""" + project_config = ProjectConfig(project_name="fake-project", dbt_project_path="/some/project/path") + execution_config = ExecutionConfig() + render_config = RenderConfig(enable_mock_profile=False) + profile_config = MagicMock() + + with DAG("test-id", start_date=datetime(2024, 1, 1)) as dag: + DbtToAirflowConverter( + dag=dag, + nodes=nodes, + project_config=project_config, + profile_config=profile_config, + execution_config=execution_config, + render_config=render_config, + operator_args={}, + ) + task_args_cache_dir = mock_build_airflow_graph.call_args[1]["task_args"]["cache_dir"] + dbt_graph_cache_dir = mock_dbt_graph.call_args[1]["cache_dir"] + + assert dbt_graph_cache_dir is None + assert task_args_cache_dir == dbt_graph_cache_dir + + @pytest.mark.parametrize( "execution_mode,operator_args", [ @@ -491,6 +563,7 @@ def test_converter_contains_dbt_graph(mock_load_dbt_graph, execution_mode, opera profiles_yml_filepath=SAMPLE_PROFILE_YML, ) converter = DbtToAirflowConverter( + dag=DAG("sample_dag", start_date=datetime(2024, 4, 16)), nodes=nodes, project_config=project_config, profile_config=profile_config, From 98704b8d492acf77c1aa26b17376a16481583a8c Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Wed, 1 May 2024 15:52:50 +0100 Subject: [PATCH 141/223] Improve partial parsing docs (#898) Improves docs to highlight the limitation of the parsing parsing approach (introduced in #800), following up on the feedback on #722 and the changes introduced in #904 --- docs/configuration/index.rst | 1 + docs/configuration/partial-parsing.rst | 68 ++++++++++++++++++++++++ docs/configuration/project-config.rst | 2 +- docs/getting_started/execution-modes.rst | 6 ++- 4 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 docs/configuration/partial-parsing.rst diff --git a/docs/configuration/index.rst b/docs/configuration/index.rst index 919ed9b1e5..ec69c1f528 100644 --- a/docs/configuration/index.rst +++ b/docs/configuration/index.rst @@ -20,6 +20,7 @@ Cosmos offers a number of configuration options to customize its behavior. For m Scheduling Testing Behavior Selecting & Excluding + Partial Parsing Operator Args Compiled SQL Logging diff --git a/docs/configuration/partial-parsing.rst b/docs/configuration/partial-parsing.rst new file mode 100644 index 0000000000..911e828b3f --- /dev/null +++ b/docs/configuration/partial-parsing.rst @@ -0,0 +1,68 @@ +.. _partial-parsing: + +Partial parsing +=============== + +Starting in the 1.4 version, Cosmos tries to leverage dbt's partial parsing (``partial_parse.msgpack``) to speed up both the task execution and the DAG parsing (if using ``LoadMode.DBT_LS``). + +This feature is bound to `dbt partial parsing limitations `_. +As an example, ``dbt`` requires the same ``--vars``, ``--target``, ``--profile``, and ``profile.yml`` environment variables (as called by the ``env_var()`` macro) while running dbt commands, otherwise it will reparse the project from scratch. + +Profile configuration +--------------------- + +To respect the dbt requirement of having the same profile to benefit from partial parsing, Cosmos users should either: +* If using Cosmos profile mapping (``ProfileConfig(profile_mapping=...``), disable using mocked profile mappings by setting ``render_config=RenderConfig(enable_mock_profile=False)`` +* Declare their own ``profiles.yml`` file, via ``ProfileConfig(profiles_yml_filepath=...)`` + +If users don't follow these guidelines, Cosmos will use different profiles to parse the dbt project and to run tasks, and the user won't leverage dbt partial parsing. +Their logs will contain multiple ``INFO`` messages similar to the following, meaning that Cosmos is not using partial parsing: + +.. code-block:: + + 13:33:16 Unable to do partial parsing because profile has changed + 13:33:16 Unable to do partial parsing because env vars used in profiles.yml have changed + +dbt vars +-------- + +If the Airflow scheduler and worker processes run in the same node, users must ensure the dbt ``--vars`` flag is the same in the ``RenderConfig`` and ``ExecutionConfig``. + +Otherwise, users may see messages similar to the following in their logs: + +.. code-block:: + + [2024-03-14, 17:04:57 GMT] {{subprocess.py:94}} INFO - Unable to do partial parsing because config vars, config profile, or config target have changed + + +Caching +------- + +If the dbt project ``target`` directory has a ``partial_parse.msgpack``, Cosmos will attempt to use it. + +There is a chance, however, that the file is stale or was generated in a way that is different to how Cosmos runs the dbt commands. + +Therefore, Cosmos also caches the most up-to-date ``partial_parse.msgpack`` file after running a dbt command in the `system temporary directory `_. +With this, unless there are code changes, each Airflow node should only run the dbt command with a full dbt project parse once, and benefit from partial parsing from then onwards. + + +Caching is enabled by default. +It is possible to disable caching or override the directory that Cosmos uses caching with the Airflow configuration: + +.. code-block:: cfg + + [cosmos] + cache_dir = path/to/docs/here # to override default caching directory (by default, uses the system temporary directory) + enable_cache = False # to disable caching (enabled by default) + +Or environment variable: + +.. code-block:: cfg + + AIRFLOW__COSMOS__CACHE_DIR="path/to/docs/here" # to override default caching directory (by default, uses the system temporary directory) + AIRFLOW__COSMOS__ENABLE_CACHE="False" # to disable caching (enabled by default) + +Disabling +--------- + +To switch off partial parsing in Cosmos, use the argument ``partial_parse=False`` in the ``ProjectConfig``. diff --git a/docs/configuration/project-config.rst b/docs/configuration/project-config.rst index 3bf524ac82..2882ee9cc3 100644 --- a/docs/configuration/project-config.rst +++ b/docs/configuration/project-config.rst @@ -25,7 +25,7 @@ variables that should be used for rendering and execution. It takes the followin env vars is only supported when using ``RenderConfig.LoadMode.DBT_LS`` load mode. - ``partial_parse``: (new in v1.4) If True, then attempt to use the ``partial_parse.msgpack`` if it exists. This is only used for the ``LoadMode.DBT_LS`` load mode, and for the ``ExecutionMode.LOCAL`` and ``ExecutionMode.VIRTUALENV`` - execution modes. + execution modes. Due to the way that dbt `partial parsing works `_, it does not work with Cosmos profile mapping classes. To benefit from this feature, users have to set the ``profiles_yml_filepath`` argument in ``ProfileConfig``. Project Config Example ---------------------- diff --git a/docs/getting_started/execution-modes.rst b/docs/getting_started/execution-modes.rst index 8f70135722..1765144d99 100644 --- a/docs/getting_started/execution-modes.rst +++ b/docs/getting_started/execution-modes.rst @@ -56,8 +56,10 @@ The ``local`` execution mode assumes a ``dbt`` binary is reachable within the Ai If ``dbt`` was not installed as part of the Cosmos packages, users can define a custom path to ``dbt`` by declaring the argument ``dbt_executable_path``. -By default, if Cosmos sees a ``partial_parse.msgpack`` in the target directory of the dbt project directory when using ``local`` execution, it will use this for partial parsing to speed up task execution. -This can be turned off by setting ``partial_parse=False`` in the ``ProjectConfig``. +.. note:: + Starting in the 1.4 version, Cosmos tries to leverage the dbt partial parsing (``partial_parse.msgpack``) to speed up task execution. + This feature is bound to `dbt partial parsing limitations `_. + Learn more: :ref:`partial-parsing`. When using the ``local`` execution mode, Cosmos converts Airflow Connections into a native ``dbt`` profiles file (``profiles.yml``). From 60001fec6821d8b571248b706e55399ecf285de2 Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Thu, 2 May 2024 14:49:35 +0100 Subject: [PATCH 142/223] Fix netlify error (#935) It seems the [google/re2](https://github.com/google/re2) release `1.1.20240501` is leading to an initialization issue in Netlify: ``` 2:32:59 PM: Running setup.py install for google-re2: finished with status 'error' 2:32:59 PM: ERROR: Command errored out with exit status 1: 2:32:59 PM: command: /opt/buildhome/python3.8/bin/python -u -c 'import sys, setuptools, tokenize; sys.argv[0] = '"/tmp/pip-install-ti3twlby/google-re2/setup.py"'; __file__='"/tmp/pip-install-ti3twlby/google-re2/setup.py"';f=getattr(tokenize, '"open"', open)(__file__);code=f.read().replace('"rn"', '"n"');f.close();exec(compile(code, __file__, '"exec"'))' install --record /tmp/pip-record-8yg4w9lc/install-record.txt --single-version-externally-managed --compile --install-headers /opt/buildhome/python3.8/include/site/python3.8/google-re2 2:32:59 PM: cwd: /tmp/pip-install-ti3twlby/google-re2/ 2:32:59 PM: Complete output (28 lines): 2:32:59 PM: running install 2:32:59 PM: /opt/buildhome/python3.8/lib/python3.8/site-packages/setuptools/_distutils/cmd.py:66: SetuptoolsDeprecationWarning: setup.py install is deprecated. 2:32:59 PM: git checkout main 2:32:59 PM: ******************************************************************************** 2:32:59 PM: Please avoid running setup.py directly. 2:32:59 PM: Instead, use pypa/build, pypa/installer or other 2:32:59 PM: standards-based tools. 2:32:59 PM: See https://blog.ganssle.io/articles/2021/10/setup-py-deprecated.html for details. 2:32:59 PM: ******************************************************************************** 2:32:59 PM: git checkout main 2:32:59 PM: self.initialize_options() 2:32:59 PM: running build 2:32:59 PM: running build_py 2:32:59 PM: creating build 2:32:59 PM: creating build/lib.linux-x86_64-cpython-38 2:32:59 PM: copying re2.py -> build/lib.linux-x86_64-cpython-38 2:32:59 PM: running build_ext 2:32:59 PM: building '_re2' extension 2:32:59 PM: creating build/temp.linux-x86_64-cpython-38 2:32:59 PM: x86_64-linux-gnu-gcc -pthread -Wno-unused-result -Wsign-compare -DNDEBUG -g -fwrapv -O2 -Wall -g -fstack-protector-strong -Wformat -Werror=format-security -g -fwrapv -O2 -fPIC -I/opt/buildhome/python3.8/lib/python3.8/site-packages/pybind11/include -I/opt/buildhome/python3.8/include -I/usr/include/python3.8 -c _re2.cc -o build/temp.linux-x86_64-cpython-38/_re2.o -fvisibility=hidden 2:32:59 PM: _re2.cc:13:10: fatal error: absl/strings/string_view.h: No such file or directory 2:32:59 PM: 13 | #include absl/strings/string_view.h 2:32:59 PM: | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~ 2:32:59 PM: compilation terminated. 2:32:59 PM: error: command '/usr/bin/x86_64-linux-gnu-gcc' failed with exit code 1 2:32:59 PM: ---------------------------------------- 2:33:00 PM: Failed during stage 'Install dependencies': dependency_installation script returned non-zero exit code: 1 2:33:00 PM: ERROR: Command errored out with exit status 1: /opt/buildhome/python3.8/bin/python -u -c 'import sys, setuptools, tokenize; sys.argv[0] = '"/tmp/pip-install-ti3twlby/google-re2/setup.py"'; __file__='"/tmp/pip-install-ti3twlby/google-re2/setup.py"';f=getattr(tokenize, '"open"', open)(__file__);code=f.read().replace('"rn"', '"n"');f.close();exec(compile(code, __file__, '"exec"'))' install --record /tmp/pip-record-8yg4w9lc/install-record.txt --single-version-externally-managed --compile --install-headers /opt/buildhome/python3.8/include/site/python3.8/google-re2 Check the logs for full command output. ``` As seen in: https://app.netlify.com/sites/sunny-pastelito-5ecb04/deploys/663395dcce3dc1000843d9fd During the Cosmos release 1.4.0: https://github.com/astronomer/astronomer-cosmos/pull/934 --- docs/requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/requirements.txt b/docs/requirements.txt index f681c20093..430993ff88 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,3 +1,4 @@ +google-re2==1.1 aenum sphinx pydata-sphinx-theme From c24b1d8d12c5f7321388dfa99ab24eb39651d461 Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Fri, 3 May 2024 22:34:26 +0100 Subject: [PATCH 143/223] Add Apache Airflow 2.9 to the test matrix (#940) [Apache Airflow 2.9](https://pypi.org/project/apache-airflow/2.9.0/ ) was released in April 2024; this PR adds it to our test matrix to confirm Cosmos continues behaving as expected in this last Airflow release. --- .github/workflows/test.yml | 4 ++-- pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8f61804aa7..f6e3701a87 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -39,7 +39,7 @@ jobs: strategy: matrix: python-version: ["3.8", "3.9", "3.10", "3.11"] - airflow-version: ["2.3", "2.4", "2.5", "2.6", "2.7", "2.8"] + airflow-version: ["2.3", "2.4", "2.5", "2.6", "2.7", "2.8", "2.9"] exclude: - python-version: "3.11" airflow-version: "2.3" @@ -83,7 +83,7 @@ jobs: strategy: matrix: python-version: ["3.8", "3.9", "3.10", "3.11"] - airflow-version: ["2.3", "2.4", "2.5", "2.6", "2.7", "2.8"] + airflow-version: ["2.3", "2.4", "2.5", "2.6", "2.7", "2.8", "2.9"] exclude: - python-version: "3.11" airflow-version: "2.3" diff --git a/pyproject.toml b/pyproject.toml index efad8e043d..05b8254346 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -133,7 +133,7 @@ pre-install-commands = ["sh scripts/test/pre-install-airflow.sh {matrix:airflow} [[tool.hatch.envs.tests.matrix]] python = ["3.8", "3.9", "3.10", "3.11"] -airflow = ["2.3", "2.4", "2.5", "2.6", "2.7", "2.8"] +airflow = ["2.3", "2.4", "2.5", "2.6", "2.7", "2.8", "2.9"] [tool.hatch.envs.tests.overrides] matrix.airflow.dependencies = [ From 217795f2a633932b31f41da57933fa74ae3b4717 Mon Sep 17 00:00:00 2001 From: Pankaj Koti Date: Sat, 4 May 2024 03:14:32 +0530 Subject: [PATCH 144/223] Make Pydantic a required dependency (#939) The pull request #736 initially designated Pydantic as an optional dependency. However, a subsequent pull request, #794 as an [import](https://github.com/astronomer/astronomer-cosmos/pull/794/files#diff-bd3fa47d7a9b96d7bb365f3ba3b60eaf0b20e06a48b28814cf6f6e6fb64d4da6R12), introduced an implementation that requires Pydantic to be a mandatory dependency for astronomer-cosmos. This pull request addresses the bug encountered while running `airflow db init`, as described in #936, by enforcing Pydantic as a required dependency. Closes: #936 --- pyproject.toml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 05b8254346..f0bf9b0b38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ dependencies = [ "apache-airflow>=2.3.0", "importlib-metadata; python_version < '3.8'", "Jinja2>=3.0.0", + "pydantic>=1.10.0", "typing-extensions; python_version < '3.8'", "virtualenv", ] @@ -85,9 +86,6 @@ docker = [ kubernetes = [ "apache-airflow-providers-cncf-kubernetes>=5.1.1", ] -pydantic = [ - "pydantic>=1.10.0", -] azure-container-instance = [ "apache-airflow-providers-microsoft-azure>=8.4.0", ] From 5db607e1fc4caab4da7844bd32cc9cc3b0866169 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 7 May 2024 11:24:30 +0100 Subject: [PATCH 145/223] =?UTF-8?q?=E2=AC=86=20[pre-commit.ci]=20pre-commi?= =?UTF-8?q?t=20autoupdate=20(#941)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.4.2 → v0.4.3](https://github.com/astral-sh/ruff-pre-commit/compare/v0.4.2...v0.4.3) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9ec32119bf..1c673abe73 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -54,7 +54,7 @@ repos: - --py37-plus - --keep-runtime-typing - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.4.2 + rev: v0.4.3 hooks: - id: ruff args: From 12ca94b2e9bf75ba5237faf0c03bb63039b37f22 Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Thu, 9 May 2024 10:30:39 +0100 Subject: [PATCH 146/223] Fix issue when publishing a new release to PyPI (#946) Faced while running the following command in the CI (I was not able to reproduce this locally): ``` Run python -m build ``` Resulted in the stacktrace: ``` val = self.func(instance) File "/tmp/build-env-o83v4k67/lib/python3.10/site-packages/hatchling/builders/wheel.py", line 247, in default_file_selection_options raise ValueError(message) ValueError: Unable to determine which files to ship inside the wheel using the following heuristics: https://hatch.pypa.io/latest/plugins/builder/wheel/#default-file-selection The most likely cause of this is that there is no directory that matches the name of your project (astronomer_cosmos). At least one file selection option must be defined in the `tool.hatch.build.targets.wheel` table, see: https://hatch.pypa.io/latest/config/build/ As an example, if you intend to ship a directory named `foo` that resides within a `src` directory located at the root of your project, you can define the following: [tool.hatch.build.targets.wheel] packages = ["src/foo"] ERROR Backend subprocess exited when trying to invoke build_wheel ``` As seen in: https://github.com/astronomer/astronomer-cosmos/actions/runs/8986616060/job/24683237685 I confirmed this worked between releasing Cosmos 1.4.0a3 and Cosmos 1.4.0a4. (cherry picked from commit ebbc50cbcd270205f1819334d4601df6e2ae34fa) --- .github/workflows/deploy.yml | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f6ed27a6ce..283d07d892 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Install build dependencies run: python -m pip install --upgrade build diff --git a/pyproject.toml b/pyproject.toml index f0bf9b0b38..04486f552f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -108,7 +108,7 @@ path = "cosmos/__init__.py" include = ["/cosmos"] [tool.hatch.build.targets.wheel] -packages = ["cosmos"] +packages = ["/cosmos"] ###################################### # TESTING From 135a06955db81ebc7261f3bddca834b777762955 Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Thu, 9 May 2024 10:30:53 +0100 Subject: [PATCH 147/223] Improve logs to troubleshoot issue in 1.4.0a2 with astro-cli (#947) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improve the logs so we can understand which Dataset URIs Cosmos was setting, while trying to execute a task in Airflow 2.9: ``` [2024-05-07, 14:20:09 UTC] {taskinstance.py:441} ▼ Post task execution logs [2024-05-07, 14:20:09 UTC] {taskinstance.py:2905} ERROR - Task failed with exception Traceback (most recent call last): File "/usr/local/lib/python3.11/site-packages/airflow/models/taskinstance.py", line 465, in _execute_task result = _execute_callable(context=context, **execute_callable_kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/local/lib/python3.11/site-packages/airflow/models/taskinstance.py", line 432, in _execute_callable return execute_callable(context=context, **execute_callable_kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/local/lib/python3.11/site-packages/airflow/models/baseoperator.py", line 400, in wrapper return func(self, *args, **kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/local/lib/python3.11/site-packages/cosmos/operators/base.py", line 266, in execute self.build_and_run_cmd(context=context, cmd_flags=self.add_cmd_flags()) File "/usr/local/lib/python3.11/site-packages/cosmos/operators/local.py", line 470, in build_and_run_cmd result = self.run_command(cmd=dbt_cmd, env=env, context=context) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/local/lib/python3.11/site-packages/cosmos/operators/local.py", line 343, in run_command outlets = self.get_datasets("outputs") ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/local/lib/python3.11/site-packages/cosmos/operators/local.py", line 410, in get_datasets return [Dataset(uri) for uri in uris] ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/local/lib/python3.11/site-packages/cosmos/operators/local.py", line 410, in return [Dataset(uri) for uri in uris] ^^^^^^^^^^^^ File "", line 3, in __init__ _setattr('uri', __attr_converter_uri(uri)) ^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/local/lib/python3.11/site-packages/airflow/datasets/__init__.py", line 81, in _sanitize_uri parsed = normalizer(parsed) ^^^^^^^^^^^^^^^^^^ File "/usr/local/lib/python3.11/site-packages/airflow/providers/postgres/datasets/postgres.py", line 34, in sanitize_uri raise ValueError("URI format postgres:// must contain database, schema, and table names") ``` This improvement allowed us to confirm how the Dataset URIs Cosmos was attempting to generate, allowing us to log the following issue: https://github.com/astronomer/astronomer-cosmos/issues/945 (cherry picked from commit c7a4599ee68b51b4326a2541c80b49c1e2f07a90) --- cosmos/operators/local.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cosmos/operators/local.py b/cosmos/operators/local.py index 4a34da13f9..64178b49f6 100644 --- a/cosmos/operators/local.py +++ b/cosmos/operators/local.py @@ -406,6 +406,7 @@ def get_datasets(self, source: Literal["inputs", "outputs"]) -> list[Dataset]: for output in getattr(completed, source): dataset_uri = output.namespace + "/" + output.name uris.append(dataset_uri) + logger.debug("URIs to be converted to Dataset: %s", uris) return [Dataset(uri) for uri in uris] def register_dataset(self, new_inlets: list[Dataset], new_outlets: list[Dataset]) -> None: From 0c2ee75c32188f7ce97b1b09d249f268a85c2a91 Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Fri, 10 May 2024 14:39:58 +0100 Subject: [PATCH 148/223] Gracefully error if users try to emit_datasets with Airflow 2.9.0 or 2.9.1 (#948) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improve Cosmos error message when using Airflow 2.9.0 or 2.9.1 and emitting OL events, to avoid this: ``` [2024-05-07, 14:20:09 UTC] {local.py:409} DEBUG - URIs to be converted to Dataset: [] [2024-05-07, 14:20:09 UTC] {local.py:409} DEBUG - URIs to be converted to Dataset: ['***://***:5432/***.dbt.stg_customers'] [2024-05-07, 14:20:09 UTC] {providers_manager.py:376} DEBUG - Initializing Providers Manager[dataset_uris] [2024-05-07, 14:20:09 UTC] {providers_manager.py:379} DEBUG - Initialization of Providers Manager[dataset_uris] took 0.00 seconds [2024-05-07, 14:20:09 UTC] {taskinstance.py:441} ▼ Post task execution logs [2024-05-07, 14:20:09 UTC] {taskinstance.py:2905} ERROR - Task failed with exception Traceback (most recent call last): File "/usr/local/lib/python3.11/site-packages/airflow/models/taskinstance.py", line 465, in _execute_task result = _execute_callable(context=context, **execute_callable_kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/local/lib/python3.11/site-packages/airflow/models/taskinstance.py", line 432, in _execute_callable return execute_callable(context=context, **execute_callable_kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/local/lib/python3.11/site-packages/airflow/models/baseoperator.py", line 400, in wrapper return func(self, *args, **kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/local/lib/python3.11/site-packages/cosmos/operators/base.py", line 266, in execute self.build_and_run_cmd(context=context, cmd_flags=self.add_cmd_flags()) File "/usr/local/lib/python3.11/site-packages/cosmos/operators/local.py", line 470, in build_and_run_cmd result = self.run_command(cmd=dbt_cmd, env=env, context=context) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/local/lib/python3.11/site-packages/cosmos/operators/local.py", line 343, in run_command outlets = self.get_datasets("outputs") ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/local/lib/python3.11/site-packages/cosmos/operators/local.py", line 410, in get_datasets return [Dataset(uri) for uri in uris] ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/local/lib/python3.11/site-packages/cosmos/operators/local.py", line 410, in return [Dataset(uri) for uri in uris] ^^^^^^^^^^^^ File "", line 3, in __init__ _setattr('uri', __attr_converter_uri(uri)) ^^^^^^^^^^^^^^^^^^^^^^^^^ File "/usr/local/lib/python3.11/site-packages/airflow/datasets/__init__.py", line 81, in _sanitize_uri parsed = normalizer(parsed) ^^^^^^^^^^^^^^^^^^ File "/usr/local/lib/python3.11/site-packages/airflow/providers/postgres/datasets/postgres.py", line 34, in sanitize_uri raise ValueError("URI format postgres:// must contain database, schema, and table names") ValueError: URI format ***:// must contain database, schema, and table names ``` Closes: #945 --- cosmos/constants.py | 5 ++ cosmos/exceptions.py | 4 ++ cosmos/operators/local.py | 17 ++++++- dev/dags/example_virtualenv.py | 1 + pyproject.toml | 3 +- tests/operators/test_local.py | 92 ++++++++++++++++++++++++++++++++-- tests/test_example_dags.py | 13 ++++- 7 files changed, 128 insertions(+), 7 deletions(-) diff --git a/cosmos/constants.py b/cosmos/constants.py index bea5e25eb2..847820ff20 100644 --- a/cosmos/constants.py +++ b/cosmos/constants.py @@ -3,6 +3,7 @@ from pathlib import Path import aenum +from packaging.version import Version DBT_PROFILE_PATH = Path(os.path.expanduser("~")).joinpath(".dbt/profiles.yml") DEFAULT_DBT_PROFILE_NAME = "cosmos_profile" @@ -20,6 +21,10 @@ DEFAULT_OPENLINEAGE_NAMESPACE = "cosmos" OPENLINEAGE_PRODUCER = "https://github.com/astronomer/astronomer-cosmos/" +# Cosmos will not emit datasets for the following Airflow versions, due to a breaking change that's fixed in later Airflow 2.x versions +# https://github.com/apache/airflow/issues/39486 +PARTIALLY_SUPPORTED_AIRFLOW_VERSIONS = [Version("2.9.0"), Version("2.9.1")] + class LoadMode(Enum): """ diff --git a/cosmos/exceptions.py b/cosmos/exceptions.py index 74091f4a13..85df285b11 100644 --- a/cosmos/exceptions.py +++ b/cosmos/exceptions.py @@ -3,3 +3,7 @@ class CosmosValueError(ValueError): """Raised when a Cosmos config value is invalid.""" + + +class AirflowCompatibilityError(Exception): + """Raised when Cosmos features are limited for Airflow version being used.""" diff --git a/cosmos/operators/local.py b/cosmos/operators/local.py index 64178b49f6..ff4bb8280d 100644 --- a/cosmos/operators/local.py +++ b/cosmos/operators/local.py @@ -21,6 +21,7 @@ from cosmos import cache from cosmos.constants import InvocationMode from cosmos.dbt.project import get_partial_parse_path +from cosmos.exceptions import AirflowCompatibilityError try: from airflow.datasets import Dataset @@ -407,7 +408,21 @@ def get_datasets(self, source: Literal["inputs", "outputs"]) -> list[Dataset]: dataset_uri = output.namespace + "/" + output.name uris.append(dataset_uri) logger.debug("URIs to be converted to Dataset: %s", uris) - return [Dataset(uri) for uri in uris] + + datasets = [] + try: + datasets = [Dataset(uri) for uri in uris] + except ValueError as e: + raise AirflowCompatibilityError( + """ + Apache Airflow 2.9.0 & 2.9.1 introduced a breaking change in Dataset URIs, to be fixed in newer versions: + https://github.com/apache/airflow/issues/39486 + + If you want to use Cosmos with one of these Airflow versions, you will have to disable emission of Datasets: + By setting ``emit_datasets=False`` in ``RenderConfig``. For more information, see https://astronomer.github.io/astronomer-cosmos/configuration/render-config.html. + """ + ) + return datasets def register_dataset(self, new_inlets: list[Dataset], new_outlets: list[Dataset]) -> None: """ diff --git a/dev/dags/example_virtualenv.py b/dev/dags/example_virtualenv.py index 55ecf0a66f..6275b10485 100644 --- a/dev/dags/example_virtualenv.py +++ b/dev/dags/example_virtualenv.py @@ -36,6 +36,7 @@ "py_system_site_packages": False, "py_requirements": ["dbt-postgres==1.6.0b1"], "install_deps": True, + "emit_datasets": False, # Example of how to not set inlets and outlets }, # normal dag parameters schedule_interval="@daily", diff --git a/pyproject.toml b/pyproject.toml index 04486f552f..5f0e5ee0e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -117,6 +117,7 @@ packages = ["/cosmos"] [tool.hatch.envs.tests] dependencies = [ "astronomer-cosmos[tests]", + "apache-airflow-providers-postgres", "apache-airflow-providers-cncf-kubernetes>=5.1.1", "apache-airflow-providers-docker>=3.5.0", "apache-airflow-providers-microsoft-azure", @@ -135,7 +136,7 @@ airflow = ["2.3", "2.4", "2.5", "2.6", "2.7", "2.8", "2.9"] [tool.hatch.envs.tests.overrides] matrix.airflow.dependencies = [ - { value = "typing_extensions<4.6", if = ["2.6"] } + { value = "typing_extensions<4.6", if = ["2.6"] }, ] [tool.hatch.envs.tests.scripts] diff --git a/tests/operators/test_local.py b/tests/operators/test_local.py index 80c6c58a46..419be9e107 100644 --- a/tests/operators/test_local.py +++ b/tests/operators/test_local.py @@ -18,12 +18,13 @@ from cosmos import cache from cosmos.config import ProfileConfig -from cosmos.constants import InvocationMode +from cosmos.constants import PARTIALLY_SUPPORTED_AIRFLOW_VERSIONS, InvocationMode from cosmos.dbt.parser.output import ( extract_dbt_runner_issues, parse_number_of_warnings_dbt_runner, parse_number_of_warnings_subprocess, ) +from cosmos.exceptions import AirflowCompatibilityError from cosmos.operators.local import ( DbtBuildLocalOperator, DbtDocsAzureStorageLocalOperator, @@ -384,11 +385,12 @@ def test_dbt_test_local_operator_invocation_mode_methods(mock_extract_log_issues @pytest.mark.skipif( - version.parse(airflow_version) < version.parse("2.4"), - reason="Airflow DAG did not have datasets until the 2.4 release", + version.parse(airflow_version) < version.parse("2.4") + or version.parse(airflow_version) in PARTIALLY_SUPPORTED_AIRFLOW_VERSIONS, + reason="Airflow DAG did not have datasets until the 2.4 release, inlets and outlets do not work by default in Airflow 2.9.0 and 2.9.1", ) @pytest.mark.integration -def test_run_operator_dataset_inlets_and_outlets(): +def test_run_operator_dataset_inlets_and_outlets(caplog): from airflow.datasets import Dataset with DAG("test-id-1", start_date=datetime(2022, 1, 1)) as dag: @@ -417,13 +419,95 @@ def test_run_operator_dataset_inlets_and_outlets(): append_env=True, ) seed_operator >> run_operator >> test_operator + run_test_dag(dag) + assert run_operator.inlets == [] assert run_operator.outlets == [Dataset(uri="postgres://0.0.0.0:5432/postgres.public.stg_customers", extra=None)] assert test_operator.inlets == [Dataset(uri="postgres://0.0.0.0:5432/postgres.public.stg_customers", extra=None)] assert test_operator.outlets == [] +@pytest.mark.skipif( + version.parse(airflow_version) not in PARTIALLY_SUPPORTED_AIRFLOW_VERSIONS, + reason="Airflow 2.9.0 and 2.9.1 have a breaking change in Dataset URIs", + # https://github.com/apache/airflow/issues/39486 +) +@pytest.mark.integration +def test_run_operator_dataset_emission_fails(caplog): + from airflow.datasets import Dataset + + with DAG("test-id-1", start_date=datetime(2022, 1, 1)) as dag: + seed_operator = DbtSeedLocalOperator( + profile_config=real_profile_config, + project_dir=DBT_PROJ_DIR, + task_id="seed", + dbt_cmd_flags=["--select", "raw_customers"], + install_deps=True, + append_env=True, + ) + run_operator = DbtRunLocalOperator( + profile_config=real_profile_config, + project_dir=DBT_PROJ_DIR, + task_id="run", + dbt_cmd_flags=["--models", "stg_customers"], + install_deps=True, + append_env=True, + ) + + seed_operator >> run_operator + + with pytest.raises(AirflowCompatibilityError) as exc: + run_test_dag(dag) + + err_msg = str(exc.value) + assert ( + "Apache Airflow 2.9.0 & 2.9.1 introduced a breaking change in Dataset URIs, to be fixed in newer versions" + in err_msg + ) + assert ( + "If you want to use Cosmos with one of these Airflow versions, you will have to disable emission of Datasets" + in err_msg + ) + + +@pytest.mark.skipif( + version.parse(airflow_version) not in PARTIALLY_SUPPORTED_AIRFLOW_VERSIONS, + reason="Airflow 2.9.0 and 2.9.1 have a breaking change in Dataset URIs", + # https://github.com/apache/airflow/issues/39486 +) +@pytest.mark.integration +def test_run_operator_dataset_emission_is_skipped(caplog): + from airflow.datasets import Dataset + + with DAG("test-id-1", start_date=datetime(2022, 1, 1)) as dag: + seed_operator = DbtSeedLocalOperator( + profile_config=real_profile_config, + project_dir=DBT_PROJ_DIR, + task_id="seed", + dbt_cmd_flags=["--select", "raw_customers"], + install_deps=True, + append_env=True, + emit_datasets=False, + ) + run_operator = DbtRunLocalOperator( + profile_config=real_profile_config, + project_dir=DBT_PROJ_DIR, + task_id="run", + dbt_cmd_flags=["--models", "stg_customers"], + install_deps=True, + append_env=True, + emit_datasets=False, + ) + + seed_operator >> run_operator + + run_test_dag(dag) + + assert run_operator.inlets == [] + assert run_operator.outlets == [] + + @pytest.mark.integration def test_run_operator_caches_partial_parsing(caplog, tmp_path): caplog.set_level(logging.DEBUG) diff --git a/tests/test_example_dags.py b/tests/test_example_dags.py index 91fd1d6c20..af45191c9a 100644 --- a/tests/test_example_dags.py +++ b/tests/test_example_dags.py @@ -16,11 +16,14 @@ from dbt.version import get_installed_version as get_dbt_version from packaging.version import Version +from cosmos.constants import PARTIALLY_SUPPORTED_AIRFLOW_VERSIONS + from . import utils as test_utils EXAMPLE_DAGS_DIR = Path(__file__).parent.parent / "dev/dags" AIRFLOW_IGNORE_FILE = EXAMPLE_DAGS_DIR / ".airflowignore" DBT_VERSION = Version(get_dbt_version().to_version_string()[1:]) +AIRFLOW_VERSION = Version(airflow.__version__) MIN_VER_DAG_FILE: dict[str, list[str]] = { "2.4": ["cosmos_seed_dag.py"], @@ -28,6 +31,7 @@ IGNORED_DAG_FILES = ["performance_dag.py"] + # Sort descending based on Versions and convert string to an actual version MIN_VER_DAG_FILE_VER: dict[Version, list[str]] = { Version(version): MIN_VER_DAG_FILE[version] for version in sorted(MIN_VER_DAG_FILE, key=Version, reverse=True) @@ -48,9 +52,12 @@ def session(): @cache def get_dag_bag() -> DagBag: """Create a DagBag by adding the files that are not supported to .airflowignore""" + if AIRFLOW_VERSION in PARTIALLY_SUPPORTED_AIRFLOW_VERSIONS: + return DagBag(dag_folder=None, include_examples=False) + with open(AIRFLOW_IGNORE_FILE, "w+") as file: for min_version, files in MIN_VER_DAG_FILE_VER.items(): - if Version(airflow.__version__) < min_version: + if AIRFLOW_VERSION < min_version: print(f"Adding {files} to .airflowignore") file.writelines([f"{file}\n" for file in files]) @@ -77,6 +84,10 @@ def get_dag_ids() -> list[str]: return dag_bag.dag_ids +@pytest.mark.skipif( + AIRFLOW_VERSION in PARTIALLY_SUPPORTED_AIRFLOW_VERSIONS, + reason="Airflow 2.9.0 and 2.9.1 have a breaking change in Dataset URIs, and Cosmos errors if `emit_datasets` is not False", +) @pytest.mark.integration @pytest.mark.parametrize("dag_id", get_dag_ids()) def test_example_dag(session, dag_id: str): From 3fb8976a60a9aad2675aa25657f2eb323747dd3b Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Fri, 10 May 2024 15:20:59 +0100 Subject: [PATCH 149/223] Improve docs on how to run Cosmos in Astro (#951) Based on: https://github.com/astronomer/astronomer-cosmos/pull/778 Co-authored-by: Ryan Hatter <25823361+RNHTTR@users.noreply.github.com> Co-authored-by: Pankaj Koti --- docs/getting_started/astro.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/getting_started/astro.rst b/docs/getting_started/astro.rst index 8aaa194e5f..816c6622a0 100644 --- a/docs/getting_started/astro.rst +++ b/docs/getting_started/astro.rst @@ -5,6 +5,10 @@ Getting Started on Astro While it is possible to use Cosmos on Astro with all :ref:`Execution Modes `, we recommend using the ``local`` execution mode. It's the simplest to set up and use. +If you'd like to see a fully functional project to run in Astro (CLI or Cloud), check out `cosmos-demo `_. + +Below you can find a step-by-step guide to run your own dbt project within Astro. + Pre-requisites ~~~~~~~~~~~~~~ @@ -12,6 +16,7 @@ To get started, you should have: - The Astro CLI installed. You can find installation instructions `here `_. - An Astro CLI project. You can initialize a new project with ``astro dev init``. +- A dbt project. The `jaffle shop example `_ is a good example. Create a virtual environment ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -26,6 +31,7 @@ Create a virtual environment in your ``Dockerfile`` using the sample below. Be s RUN python -m venv dbt_venv && source dbt_venv/bin/activate && \ pip install --no-cache-dir && deactivate +An example of dbt adapter is ``dbt-postgres``. Install Cosmos ~~~~~~~~~~~~~~ From 95d0d72c94c55b8228ee2cc6e9a05925180df222 Mon Sep 17 00:00:00 2001 From: Julian LaNeve Date: Fri, 10 May 2024 10:58:39 -0400 Subject: [PATCH 150/223] Ensure tags don't run into index errors when there are no upstream nodes (#933) This PR ensures we only try to access `node.depends_on[0]` if it is an iterable with items. Co-authored-by: Tatiana Al-Chueyr --- cosmos/dbt/selector.py | 2 +- tests/dbt/test_selector.py | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/cosmos/dbt/selector.py b/cosmos/dbt/selector.py index 61458c4aae..47c118b281 100644 --- a/cosmos/dbt/selector.py +++ b/cosmos/dbt/selector.py @@ -296,7 +296,7 @@ def _should_include_node(self, node_id: str, node: DbtNode) -> bool: self.visited_nodes.add(node_id) - if node.resource_type == DbtResourceType.TEST and node.depends_on: + if node.resource_type == DbtResourceType.TEST and node.depends_on and len(node.depends_on) > 0: node.tags = getattr(self.nodes.get(node.depends_on[0]), "tags", []) logger.debug( "The test node <%s> inherited these tags from the parent node <%s>: %s", diff --git a/tests/dbt/test_selector.py b/tests/dbt/test_selector.py index bfb6d7d4ee..ece32ac951 100644 --- a/tests/dbt/test_selector.py +++ b/tests/dbt/test_selector.py @@ -4,7 +4,7 @@ from cosmos.constants import DbtResourceType from cosmos.dbt.graph import DbtNode -from cosmos.dbt.selector import SelectorConfig, select_nodes +from cosmos.dbt.selector import NodeSelector, SelectorConfig, select_nodes from cosmos.exceptions import CosmosValueError SAMPLE_PROJ_PATH = Path("/home/user/path/dbt-proj/") @@ -418,3 +418,17 @@ def test_node_without_depends_on_with_tag_selector_should_not_raise_exception(): ) nodes = {standalone_test_node.unique_id: standalone_test_node} assert not select_nodes(project_dir=SAMPLE_PROJ_PATH, nodes=nodes, select=["tag:some-tag"]) + + +def test_should_include_node_without_depends_on(selector_config): + node = DbtNode( + unique_id=f"{DbtResourceType.TEST.value}.{SAMPLE_PROJ_PATH.stem}.standalone", + resource_type=DbtResourceType.TEST, + depends_on=None, + tags=[], + config={}, + file_path=SAMPLE_PROJ_PATH / "tests/generic/builtin.sql", + ) + selector = NodeSelector({}, selector_config) + selector.visited_nodes = set() + selector._should_include_node(node.unique_id, node) From 1837803f8fac4adceb97a1439987debde01d43f4 Mon Sep 17 00:00:00 2001 From: Pankaj Koti Date: Fri, 10 May 2024 22:28:52 +0530 Subject: [PATCH 151/223] Correct stale `root_path` in partial parse file (#950) With the introduction of enabling partial parse in PR #904, upon testing the implementation, it is observed that the seeds files were not been able to be located as the partial parse file contained a stale `root_path` from previous command runs. This issue is observed on specific earlier versions of dbt-core like `1.5.4` and `1.6.5`, but not on recent versions of dbt-core `1.5.8`, `1.6.6` and `1.7.0`. I am suspecting that PR https://github.com/dbt-labs/dbt-core/pull/8762 is likely the fix and the fix appears to be backported to later version releases of `1.5.x` and `1.6.x`. However, irrespective of the dbt-core version, this PR attempts to correct the `root_path` in the partial parse file by replacing it with the needed project directory where the project files are located. And thus ensures that the feature runs correctly for older and newer versions of dbt-core. closes: #937 --------- Co-authored-by: Tatiana Al-Chueyr --- .github/workflows/test.yml | 73 +++++++++++++++++++++++++++ cosmos/cache.py | 18 +++++++ docs/requirements.txt | 11 ++-- pyproject.toml | 21 ++++---- scripts/test/integration-dbt-1-5-4.sh | 12 +++++ 5 files changed, 121 insertions(+), 14 deletions(-) create mode 100644 scripts/test/integration-dbt-1-5-4.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f6e3701a87..dc0cfd055d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -292,6 +292,79 @@ jobs: AIRFLOW_CONN_AIRFLOW_DB: postgres://postgres:postgres@0.0.0.0:5432/postgres PYTHONPATH: /home/runner/work/astronomer-cosmos/astronomer-cosmos/:$PYTHONPATH + Run-Integration-Tests-DBT-1-5-4: + needs: Authorize + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [ "3.11" ] + airflow-version: [ "2.7" ] + services: + postgres: + image: postgres + env: + POSTGRES_PASSWORD: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - uses: actions/checkout@v3 + with: + ref: ${{ github.event.pull_request.head.sha || github.ref }} + - uses: actions/cache@v3 + with: + path: | + ~/.cache/pip + .nox + key: integration-dbt-1-5-4-${{ runner.os }}-${{ matrix.python-version }}-${{ matrix.airflow-version }}-${{ hashFiles('pyproject.toml') }}-${{ hashFiles('cosmos/__init__.py') }} + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install packages and dependencies + run: | + python -m pip install hatch + hatch -e tests.py${{ matrix.python-version }}-${{ matrix.airflow-version }} run pip freeze + + - name: Test Cosmos against Airflow ${{ matrix.airflow-version }}, Python ${{ matrix.python-version }} and dbt 1.5.4 + run: | + hatch run tests.py${{ matrix.python-version }}-${{ matrix.airflow-version }}:test-integration-dbt-1-5-4 + env: + AIRFLOW_HOME: /home/runner/work/astronomer-cosmos/astronomer-cosmos/ + AIRFLOW_CONN_AIRFLOW_DB: postgres://postgres:postgres@0.0.0.0:5432/postgres + AIRFLOW__CORE__DAGBAG_IMPORT_TIMEOUT: 90.0 + PYTHONPATH: /home/runner/work/astronomer-cosmos/astronomer-cosmos/:$PYTHONPATH + AIRFLOW_CONN_DATABRICKS_DEFAULT: ${{ secrets.AIRFLOW_CONN_DATABRICKS_DEFAULT }} + DATABRICKS_HOST: ${{ secrets.DATABRICKS_HOST }} + DATABRICKS_TOKEN: ${{ secrets.DATABRICKS_TOKEN }} + DATABRICKS_WAREHOUSE_ID: ${{ secrets.DATABRICKS_WAREHOUSE_ID }} + DATABRICKS_CLUSTER_ID: ${{ secrets.DATABRICKS_CLUSTER_ID }} + COSMOS_CONN_POSTGRES_PASSWORD: ${{ secrets.COSMOS_CONN_POSTGRES_PASSWORD }} + POSTGRES_HOST: localhost + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + POSTGRES_SCHEMA: public + POSTGRES_PORT: 5432 + + - name: Upload coverage to Github + uses: actions/upload-artifact@v2 + with: + name: coverage-integration-dbt-1-5-4-test-${{ matrix.python-version }}-${{ matrix.airflow-version }} + path: .coverage + + env: + AIRFLOW_HOME: /home/runner/work/astronomer-cosmos/astronomer-cosmos/ + AIRFLOW_CONN_AIRFLOW_DB: postgres://postgres:postgres@0.0.0.0:5432/postgres + PYTHONPATH: /home/runner/work/astronomer-cosmos/astronomer-cosmos/:$PYTHONPATH + Run-Performance-Tests: needs: Authorize runs-on: ubuntu-latest diff --git a/cosmos/cache.py b/cosmos/cache.py index 3c2086c7ae..7d136a127d 100644 --- a/cosmos/cache.py +++ b/cosmos/cache.py @@ -3,6 +3,7 @@ import shutil from pathlib import Path +import msgpack from airflow.models.dag import DAG from airflow.utils.task_group import TaskGroup @@ -121,4 +122,21 @@ def _copy_partial_parse_to_project(partial_parse_filepath: Path, project_path: P source_manifest_filepath = partial_parse_filepath.parent / DBT_MANIFEST_FILE_NAME target_manifest_filepath = target_partial_parse_file.parent / DBT_MANIFEST_FILE_NAME shutil.copy(str(partial_parse_filepath), str(target_partial_parse_file)) + + # Update root_path in partial parse file to point to the needed project directory. This is necessary because + # an issue is observed where on specific earlier versions of dbt-core like 1.5.4 and 1.6.5, the commands fail to + # locate project files as they are pointed to a stale directory by the root_path in the partial parse file. + # This issue was not observed on recent versions of dbt-core 1.5.8, 1.6.6, 1.7.0 and 1.8.0 as tested on. + # It is suspected that PR dbt-labs/dbt-core#8762 is likely the fix and the fix appears to be backported to later + # version releases of 1.5.x and 1.6.x. However, the below modification is applied to ensure that the root_path is + # correctly set to the needed project directory and the feature is compatible across all dbt-core versions. + with target_partial_parse_file.open("rb") as f: + data = msgpack.unpack(f) + for node in data["nodes"].values(): + if node.get("root_path"): + node["root_path"] = str(project_path) + with target_partial_parse_file.open("wb") as f: + packed = msgpack.packb(data) + f.write(packed) + shutil.copy(str(source_manifest_filepath), str(target_manifest_filepath)) diff --git a/docs/requirements.txt b/docs/requirements.txt index 430993ff88..81a7084e4a 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,10 +1,11 @@ -google-re2==1.1 aenum -sphinx -pydata-sphinx-theme -sphinx-autobuild -sphinx-autoapi apache-airflow apache-airflow-providers-cncf-kubernetes>=5.1.1 +google-re2==1.1 +msgpack openlineage-airflow pydantic +pydata-sphinx-theme +sphinx +sphinx-autoapi +sphinx-autobuild diff --git a/pyproject.toml b/pyproject.toml index 5f0e5ee0e0..f740f20714 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ dependencies = [ "apache-airflow>=2.3.0", "importlib-metadata; python_version < '3.8'", "Jinja2>=3.0.0", + "msgpack", "pydantic>=1.10.0", "typing-extensions; python_version < '3.8'", "virtualenv", @@ -141,16 +142,17 @@ matrix.airflow.dependencies = [ [tool.hatch.envs.tests.scripts] freeze = "pip freeze" -type-check = "mypy cosmos" test = 'sh scripts/test/unit.sh' test-cov = 'sh scripts/test/unit-cov.sh' -test-integration-setup = 'sh scripts/test/integration-setup.sh' test-integration = 'sh scripts/test/integration.sh' +test-integration-dbt-1-5-4 = 'sh scripts/test/integration-dbt-1-5-4.sh' test-integration-expensive = 'sh scripts/test/integration-expensive.sh' -test-integration-sqlite-setup = 'sh scripts/test/integration-sqlite-setup.sh' +test-integration-setup = 'sh scripts/test/integration-setup.sh' test-integration-sqlite = 'sh scripts/test/integration-sqlite.sh' -test-performance-setup = 'sh scripts/test/performance-setup.sh' +test-integration-sqlite-setup = 'sh scripts/test/integration-sqlite-setup.sh' test-performance = 'sh scripts/test/performance.sh' +test-performance-setup = 'sh scripts/test/performance-setup.sh' +type-check = "mypy cosmos" [tool.pytest.ini_options] filterwarnings = ["ignore::DeprecationWarning"] @@ -164,13 +166,14 @@ markers = ["integration", "sqlite", "perf"] [tool.hatch.envs.docs] dependencies = [ "aenum", - "sphinx", - "pydata-sphinx-theme", - "sphinx-autobuild", - "sphinx-autoapi", - "openlineage-airflow", "apache-airflow-providers-cncf-kubernetes>=5.1.1", + "msgpack", + "openlineage-airflow", "pydantic>=1.10.0", + "pydata-sphinx-theme", + "sphinx", + "sphinx-autoapi", + "sphinx-autobuild", ] [tool.hatch.envs.docs.scripts] diff --git a/scripts/test/integration-dbt-1-5-4.sh b/scripts/test/integration-dbt-1-5-4.sh new file mode 100644 index 0000000000..0875330820 --- /dev/null +++ b/scripts/test/integration-dbt-1-5-4.sh @@ -0,0 +1,12 @@ +pip uninstall dbt-adapters dbt-common dbt-core dbt-extractor dbt-postgres dbt-semantic-interfaces -y +pip install dbt-postgres==1.5.4 dbt-databricks==1.5.4 +rm -rf airflow.*; \ +airflow db init; \ +pytest -vv \ + --cov=cosmos \ + --cov-report=term-missing \ + --cov-report=xml \ + --durations=0 \ + -m integration \ + --ignore=tests/perf \ + -k 'basic_cosmos_task_group' From df85d1cca4870714af3344a0a98f0891b18ff921 Mon Sep 17 00:00:00 2001 From: Pankaj Koti Date: Mon, 13 May 2024 18:28:43 +0530 Subject: [PATCH 152/223] Set default value for `append_env` based on execution modes (#954) This PR sets the default value of dbt argument `append-env` that exposes the operating system environment variables to the `dbt` command based on execution modes. For most execution modes, the default is set to `False`, however, for execution modes `ExecuteMode.LOCAL` and `ExecuteMode.VIRTUALENV`, the default is set to True. This behavior is consistent with the `LoadMode.DBT_LS` command in forwarding the environment variables to the subprocess by default. --------- Co-authored-by: Pankaj Singh <98807258+pankajastro@users.noreply.github.com> --- cosmos/operators/base.py | 8 ++++---- cosmos/operators/local.py | 12 ++++++++++++ docs/configuration/operator-args.rst | 2 +- .../operators/test_azure_container_instance.py | 2 -- tests/operators/test_base.py | 6 ++++++ tests/operators/test_local.py | 16 ++++++++++++++++ tests/operators/test_virtualenv.py | 17 +++++++++++++++++ 7 files changed, 56 insertions(+), 7 deletions(-) diff --git a/cosmos/operators/base.py b/cosmos/operators/base.py index f9f4645b60..b8112a73f7 100644 --- a/cosmos/operators/base.py +++ b/cosmos/operators/base.py @@ -46,10 +46,10 @@ class AbstractDbtBaseOperator(BaseOperator, metaclass=ABCMeta): environment variables for the new process; these are used instead of inheriting the current process environment, which is the default behavior. (templated) - :param append_env: . If True (default), inherits the environment variables - from current passes and then environment variable passed by the user will either update the existing + :param append_env: If True, inherits the environment variables + from current process and then environment variable passed by the user will either update the existing inherited environment variables or the new variables gets appended to it. - If False, only uses the environment variables passed in env params + If False (default), only uses the environment variables passed in env params and does not inherit the current process environment. :param output_encoding: Output encoding of bash command :param skip_exit_code: If task exits with this exit code, leave the task @@ -104,7 +104,7 @@ def __init__( db_name: str | None = None, schema: str | None = None, env: dict[str, Any] | None = None, - append_env: bool = True, + append_env: bool = False, output_encoding: str = "utf-8", skip_exit_code: int = 99, partial_parse: bool = True, diff --git a/cosmos/operators/local.py b/cosmos/operators/local.py index ff4bb8280d..6ecd3cdaff 100644 --- a/cosmos/operators/local.py +++ b/cosmos/operators/local.py @@ -113,6 +113,11 @@ class DbtLocalBaseOperator(AbstractDbtBaseOperator): :param target_name: A name to use for the dbt target. If not provided, and no target is found in your project's dbt_project.yml, "cosmos_target" is used. :param should_store_compiled_sql: If true, store the compiled SQL in the compiled_sql rendered template. + :param append_env: If True(default), inherits the environment variables + from current process and then environment variable passed by the user will either update the existing + inherited environment variables or the new variables gets appended to it. + If False, only uses the environment variables passed in env params + and does not inherit the current process environment. """ template_fields: Sequence[str] = AbstractDbtBaseOperator.template_fields + ("compiled_sql",) # type: ignore[operator] @@ -127,6 +132,7 @@ def __init__( install_deps: bool = False, callback: Callable[[str], None] | None = None, should_store_compiled_sql: bool = True, + append_env: bool = True, **kwargs: Any, ) -> None: self.profile_config = profile_config @@ -144,6 +150,12 @@ def __init__( kwargs.pop("full_refresh", None) # usage of this param should be implemented in child classes super().__init__(**kwargs) + # For local execution mode, we're consistent with the LoadMode.DBT_LS command in forwarding the environment + # variables to the subprocess by default. Although this behavior is designed for ExecuteMode.LOCAL and + # ExecuteMode.VIRTUALENV, it is not desired for the other execution modes to forward the environment variables + # as it can break existing DAGs. + self.append_env = append_env + @cached_property def subprocess_hook(self) -> FullOutputSubprocessHook: """Returns hook for running the bash command.""" diff --git a/docs/configuration/operator-args.rst b/docs/configuration/operator-args.rst index 4e6a40b7fc..d78b6f1b79 100644 --- a/docs/configuration/operator-args.rst +++ b/docs/configuration/operator-args.rst @@ -43,7 +43,7 @@ Summary of Cosmos-specific arguments dbt-related ........... -- ``append_env``: Expose the operating system environment variables to the ``dbt`` command. The default is ``False``. +- ``append_env``: Expose the operating system environment variables to the ``dbt`` command. For most execution modes, the default is ``False``, however, for execution modes ExecuteMode.LOCAL and ExecuteMode.VIRTUALENV, the default is True. This behavior is consistent with the LoadMode.DBT_LS command in forwarding the environment variables to the subprocess by default. - ``dbt_cmd_flags``: List of command flags to pass to ``dbt`` command, added after dbt subcommand - ``dbt_cmd_global_flags``: List of ``dbt`` `global flags `_ to be passed to the ``dbt`` command, before the subcommand - ``dbt_executable_path``: Path to dbt executable. diff --git a/tests/operators/test_azure_container_instance.py b/tests/operators/test_azure_container_instance.py index 84d733ce38..836ca45961 100644 --- a/tests/operators/test_azure_container_instance.py +++ b/tests/operators/test_azure_container_instance.py @@ -53,7 +53,6 @@ def test_dbt_azure_container_instance_operator_get_env(p_context_to_airflow_vars name="my-aci", resource_group="my-rg", project_dir="my/dir", - append_env=False, ) dbt_base_operator.env = { "start_date": "20220101", @@ -91,7 +90,6 @@ def test_dbt_azure_container_instance_operator_check_environment_variables( resource_group="my-rg", project_dir="my/dir", environment_variables={"FOO": "BAR"}, - append_env=False, ) dbt_base_operator.env = { "start_date": "20220101", diff --git a/tests/operators/test_base.py b/tests/operators/test_base.py index 5761d66aa3..70e4059e7e 100644 --- a/tests/operators/test_base.py +++ b/tests/operators/test_base.py @@ -69,3 +69,9 @@ def test_dbt_mixin_add_cmd_flags_run_operator(args, expected_flags): flags = run_operation.add_cmd_flags() assert flags == expected_flags + + +def test_abstract_dbt_base_operator_append_env_is_false_by_default(): + """Tests that the append_env attribute is set to False by default.""" + base_operator = AbstractDbtBaseOperator(task_id="fake_task", project_dir="fake_dir") + assert base_operator.append_env is False diff --git a/tests/operators/test_local.py b/tests/operators/test_local.py index 419be9e107..11652e10dd 100644 --- a/tests/operators/test_local.py +++ b/tests/operators/test_local.py @@ -103,6 +103,22 @@ def test_dbt_base_operator_add_global_flags() -> None: ] +def test_dbt_local_operator_append_env_is_true_by_default() -> None: + dbt_local_operator = ConcreteDbtLocalBaseOperator( + profile_config=profile_config, + task_id="my-task", + project_dir="my/dir", + vars={ + "start_time": "{{ data_interval_start.strftime('%Y%m%d%H%M%S') }}", + "end_time": "{{ data_interval_end.strftime('%Y%m%d%H%M%S') }}", + }, + no_version_check=True, + select=["my_first_model", "my_second_model"], + ) + + assert dbt_local_operator.append_env == True + + def test_dbt_base_operator_add_user_supplied_flags() -> None: dbt_base_operator = ConcreteDbtLocalBaseOperator( profile_config=profile_config, diff --git a/tests/operators/test_virtualenv.py b/tests/operators/test_virtualenv.py index acf3c72af9..deb7151e5d 100644 --- a/tests/operators/test_virtualenv.py +++ b/tests/operators/test_virtualenv.py @@ -73,3 +73,20 @@ def test_run_command( assert dbt_deps["command"][0] == dbt_cmd["command"][0] assert dbt_cmd["command"][1] == "do-something" assert mock_execute.call_count == 2 + + +def test_virtualenv_operator_append_env_is_true_by_default(): + venv_operator = ConcreteDbtVirtualenvBaseOperator( + dag=DAG("sample_dag", start_date=datetime(2024, 4, 16)), + profile_config=profile_config, + task_id="fake_task", + install_deps=True, + project_dir="./dev/dags/dbt/jaffle_shop", + py_system_site_packages=False, + pip_install_options=["--test-flag"], + py_requirements=["dbt-postgres==1.6.0b1"], + emit_datasets=False, + invocation_mode=InvocationMode.SUBPROCESS, + ) + + assert venv_operator.append_env is True From 123b1152b88a7d6e8f2e76feefede39d5509ded1 Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Mon, 13 May 2024 14:28:13 +0100 Subject: [PATCH 153/223] Release 1.4.0 (#934) Features * Add dbt docs natively in Airflow via plugin by @dwreeves in #737 * Add support for ``InvocationMode.DBT_RUNNER`` for local execution mode by @jbandoro in #850 * Support partial parsing to render DAGs faster when using ``ExecutionMode.LOCAL``, ``ExecutionMode.VIRTUALENV`` and ``LoadMode.DBT_LS`` by @dwreeves in #800 * Improve performance by 22-35% or more by caching partial parse artefact by @tatiana in #904 * Add Azure Container Instance as Execution Mode by @danielvdende in #771 * Add dbt build operators by @dylanharper-qz in #795 * Add dbt profile config variables to mapped profile by @ykuc in #794 * Add more template fields to ``DbtBaseOperator`` by @dwreeves in #786 * Add ``pip_install_options`` argument to operators by @octiva in #808 Bug fixes * Make ``PostgresUserPasswordProfileMapping`` schema argument optional by @FouziaTariq in #683 * Fix ``folder_dir`` not showing on logs for ``DbtDocsS3LocalOperator`` by @PrimOox in #856 * Improve ``dbt ls`` parsing resilience to missing tags/config by @tatiana in #859 * Fix ``operator_args`` modified in place in Airflow converter by @jbandoro in #835 * Fix Docker and Kubernetes operators execute method resolution by @jbandoro in #849 * Fix ``TrinoBaseProfileMapping`` required parameter for non method authentication by @AlexandrKhabarov in #921 * Fix global flags for lists by @ms32035 in #863 * Fix ``GoogleCloudServiceAccountDictProfileMapping`` when getting values from the Airflow connection ``extra__`` keys by @glebkrapivin in #923 * Fix using the dag as a keyword argument as ``specific_args_keys`` in DbtTaskGroup by @tboutaour in #916 * Fix ACI integration (``DbtAzureContainerInstanceBaseOperator``) by @danielvdende in #872 * Fix setting dbt project dir to the tmp dir by @dwreeves in #873 * Fix dbt docs operator to not use ``graph.gpickle`` file when ``--no-write-json`` is passed by @dwreeves in #883 * Make Pydantic a required dependency by @pankajkoti in #939 * Gracefully error if users try to ``emit_datasets`` with ``Airflow 2.9.0`` or ``2.9.1`` by @tatiana in #948 * Fix parsing tests that have no parents in #933 by @jlaneve * Correct ``root_path`` in partial parse cache by @pankajkoti in #950 Docs * Fix docs homepage link by @jlaneve in #860 * Fix docs ``ExecutionConfig.dbt_project_path`` by @jbandoro in #847 * Fix typo in MWAA getting started guide by @jlaneve in #846 * Fix typo related to exporting docs to GCS by @tboutaour in #922 * Improve partial parsing docs by @tatiana in #898 * Improve docs for datasets for airflow >= 2.4 by @SiddiqueAhmad in #879 * Improve test behaviour docs to highlight ``warning`` feature in the ``virtualenv`` mode by @mc51 in #910 * Fix docs typo by @SiddiqueAhmad in #917 * Improve Astro docs by @RNHTTR in #951 Others * Add performance integration tests by @jlaneve in #827 * Enable ``append_env`` in ``operator_args`` by default by @tatiana in #899 * Change default ``append_env`` behaviour depending on Cosmos ``ExecutionMode`` by @pankajkoti and @pankajastro in #954 * Expose the ``dbt`` graph in the ``DbtToAirflowConverter`` class by @tommyjxl in #886 * Improve dbt docs plugin rendering padding by @dwreeves in #876 * Add ``connect_retries`` to databricks profile to fix expensive integration failures by @jbandoro in #826 * Add import sorting (isort) to Cosmos by @jbandoro in #866 * Add Python 3.11 to CI/tests by @tatiana and @jbandoro in #821, #824 and #825 * Fix failing ``test_created_pod`` for ``apache-airflow-providers-cncf-kubernetes`` after v8.0.0 update by @jbandoro in #854 * Extend ``DatabricksTokenProfileMapping`` test to include session properties by @tatiana in #858 * Fix broken integration test uncovered from Pytest 8.0 update by @jbandoro in #845 * Add Apache Airflow 2.9 to the test matrix by @tatiana in #940 * Replace deprecated ``DummyOperator`` by ``EmptyOperator`` if Airflow >=2.4.0 by @tatiana in #900 * Improve logs to troubleshoot issue in 1.4.0a2 with astro-cli by @tatiana in #947 * Fix issue when publishing a new release to PyPI by @tatiana in #946 * Pre-commit hook updates in #820, #834, #843 and #852, #890, #896, #901, #905, #908, #919, #931, #941 --- .github/workflows/deploy.yml | 2 +- CHANGELOG.rst | 32 +++++++++++++++++++++++++++++--- cosmos/__init__.py | 2 +- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 283d07d892..be13f34bea 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -37,7 +37,7 @@ jobs: path: dist - name: Push build artifacts to PyPi - uses: pypa/gh-action-pypi-publish@v1.6.4 + uses: pypa/gh-action-pypi-publish@v1.8.14 with: user: __token__ password: ${{ secrets.PYPI_TOKEN }} diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 6434baada0..0c2e21ba36 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,7 +1,7 @@ Changelog ========= -1.4.0a1 (2024-02-29) +1.4.0 (2024-05-13) -------------------- Features @@ -9,10 +9,12 @@ Features * Add dbt docs natively in Airflow via plugin by @dwreeves in #737 * Add support for ``InvocationMode.DBT_RUNNER`` for local execution mode by @jbandoro in #850 * Support partial parsing to render DAGs faster when using ``ExecutionMode.LOCAL``, ``ExecutionMode.VIRTUALENV`` and ``LoadMode.DBT_LS`` by @dwreeves in #800 +* Improve performance by 22-35% or more by caching partial parse artefact by @tatiana in #904 * Add Azure Container Instance as Execution Mode by @danielvdende in #771 * Add dbt build operators by @dylanharper-qz in #795 * Add dbt profile config variables to mapped profile by @ykuc in #794 * Add more template fields to ``DbtBaseOperator`` by @dwreeves in #786 +* Add ``pip_install_options`` argument to operators by @octiva in #808 Bug fixes @@ -21,24 +23,48 @@ Bug fixes * Improve ``dbt ls`` parsing resilience to missing tags/config by @tatiana in #859 * Fix ``operator_args`` modified in place in Airflow converter by @jbandoro in #835 * Fix Docker and Kubernetes operators execute method resolution by @jbandoro in #849 +* Fix ``TrinoBaseProfileMapping`` required parameter for non method authentication by @AlexandrKhabarov in #921 +* Fix global flags for lists by @ms32035 in #863 +* Fix ``GoogleCloudServiceAccountDictProfileMapping`` when getting values from the Airflow connection ``extra__`` keys by @glebkrapivin in #923 +* Fix using the dag as a keyword argument as ``specific_args_keys`` in DbtTaskGroup by @tboutaour in #916 +* Fix ACI integration (``DbtAzureContainerInstanceBaseOperator``) by @danielvdende in #872 +* Fix setting dbt project dir to the tmp dir by @dwreeves in #873 +* Fix dbt docs operator to not use ``graph.gpickle`` file when ``--no-write-json`` is passed by @dwreeves in #883 +* Make Pydantic a required dependency by @pankajkoti in #939 +* Gracefully error if users try to ``emit_datasets`` with ``Airflow 2.9.0`` or ``2.9.1`` by @tatiana in #948 +* Fix parsing tests that have no parents in #933 by @jlaneve +* Correct ``root_path`` in partial parse cache by @pankajkoti in #950 Docs * Fix docs homepage link by @jlaneve in #860 * Fix docs ``ExecutionConfig.dbt_project_path`` by @jbandoro in #847 * Fix typo in MWAA getting started guide by @jlaneve in #846 +* Fix typo related to exporting docs to GCS by @tboutaour in #922 +* Improve partial parsing docs by @tatiana in #898 +* Improve docs for datasets for airflow >= 2.4 by @SiddiqueAhmad in #879 +* Improve test behaviour docs to highlight ``warning`` feature in the ``virtualenv`` mode by @mc51 in #910 +* Fix docs typo by @SiddiqueAhmad in #917 +* Improve Astro docs by @RNHTTR in #951 Others * Add performance integration tests by @jlaneve in #827 +* Enable ``append_env`` in ``operator_args`` by default by @tatiana in #899 +* Change default ``append_env`` behaviour depending on Cosmos ``ExecutionMode`` by @pankajkoti and @pankajastro in #954 +* Expose the ``dbt`` graph in the ``DbtToAirflowConverter`` class by @tommyjxl in #886 +* Improve dbt docs plugin rendering padding by @dwreeves in #876 * Add ``connect_retries`` to databricks profile to fix expensive integration failures by @jbandoro in #826 * Add import sorting (isort) to Cosmos by @jbandoro in #866 * Add Python 3.11 to CI/tests by @tatiana and @jbandoro in #821, #824 and #825 * Fix failing ``test_created_pod`` for ``apache-airflow-providers-cncf-kubernetes`` after v8.0.0 update by @jbandoro in #854 * Extend ``DatabricksTokenProfileMapping`` test to include session properties by @tatiana in #858 * Fix broken integration test uncovered from Pytest 8.0 update by @jbandoro in #845 -* Pre-commit hook updates in #834, #843 and #852 - +* Add Apache Airflow 2.9 to the test matrix by @tatiana in #940 +* Replace deprecated ``DummyOperator`` by ``EmptyOperator`` if Airflow >=2.4.0 by @tatiana in #900 +* Improve logs to troubleshoot issue in 1.4.0a2 with astro-cli by @tatiana in #947 +* Fix issue when publishing a new release to PyPI by @tatiana in #946 +* Pre-commit hook updates in #820, #834, #843 and #852, #890, #896, #901, #905, #908, #919, #931, #941 1.3.2 (2024-01-26) diff --git a/cosmos/__init__.py b/cosmos/__init__.py index c9de9971ee..b84252d0c4 100644 --- a/cosmos/__init__.py +++ b/cosmos/__init__.py @@ -5,7 +5,7 @@ Contains dags, task groups, and operators. """ -__version__ = "1.4.0a1" +__version__ = "1.4.0" from cosmos.airflow.dag import DbtDag From a384611b23b204f611a0a7ed906ec008df710699 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 13 May 2024 23:37:40 +0100 Subject: [PATCH 154/223] =?UTF-8?q?=E2=AC=86=20[pre-commit.ci]=20pre-commi?= =?UTF-8?q?t=20autoupdate=20(#956)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.4.3 → v0.4.4](https://github.com/astral-sh/ruff-pre-commit/compare/v0.4.3...v0.4.4) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1c673abe73..118ba915b0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -54,7 +54,7 @@ repos: - --py37-plus - --keep-runtime-typing - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.4.3 + rev: v0.4.4 hooks: - id: ruff args: From af47ea41540ece1b5b90c6ac9137acd4d9986ca6 Mon Sep 17 00:00:00 2001 From: Pankaj Singh <98807258+pankajastro@users.noreply.github.com> Date: Tue, 14 May 2024 17:40:20 +0530 Subject: [PATCH 155/223] Enable pre-commit run and fix type-check job (#957) closes: https://github.com/astronomer/astronomer-cosmos/issues/952 This PR addresses the following issues: **1. Pre-commit Setup:** - Pre-commit was not running in the CI pipeline because it was neither configured for this repository nor were we using the pre-commit command in CI to run it manually. To fix this: Installed and added pre-commit to this repository. This allows pre-commit to automatically run the pre-commit configuration on every push. **2. Type Check Job:** - Previously, the type-check job was running with mypy directly, and the pre-commit type check command had different configurations. This PR aligns the configurations. **3. Type Check Fixes:** - Ignored the type check for __dataclass_fields__ since mypy seems to not recognize it. - Added a check for None for ti.task --- cosmos/operators/local.py | 2 ++ cosmos/profiles/base.py | 3 ++- pyproject.toml | 4 ++-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/cosmos/operators/local.py b/cosmos/operators/local.py index 6ecd3cdaff..392bd45070 100644 --- a/cosmos/operators/local.py +++ b/cosmos/operators/local.py @@ -238,6 +238,8 @@ def store_compiled_sql(self, tmp_project_dir: str, context: Context, session: Se ti = context["ti"] if isinstance(ti, TaskInstance): # verifies ti is a TaskInstance in order to access and use the "task" field + if TYPE_CHECKING: + assert ti.task is not None ti.task.template_fields = self.template_fields rtif = RenderedTaskInstanceFields(ti, render_templates=False) diff --git a/cosmos/profiles/base.py b/cosmos/profiles/base.py index 308d71a814..d4b44b591d 100755 --- a/cosmos/profiles/base.py +++ b/cosmos/profiles/base.py @@ -42,7 +42,8 @@ class DbtProfileConfigVars: def as_dict(self) -> dict[str, Any] | None: result = { field.name: getattr(self, field.name) - for field in self.__dataclass_fields__.values() + # Look like the __dataclass_fields__ attribute is not recognized by mypy + for field in self.__dataclass_fields__.values() # type: ignore[attr-defined] if getattr(self, field.name) is not None } if result != {}: diff --git a/pyproject.toml b/pyproject.toml index f740f20714..97788aee5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,8 +78,8 @@ tests = [ "pytest-describe", "sqlalchemy-stubs", # Change when sqlalchemy is upgraded https://docs.sqlalchemy.org/en/14/orm/extensions/mypy.html "types-requests", - "mypy", "sqlalchemy-stubs", # Change when sqlalchemy is upgraded https://docs.sqlalchemy.org/en/14/orm/extensions/mypy.html + "pre-commit", ] docker = [ "apache-airflow-providers-docker>=3.5.0", @@ -152,7 +152,7 @@ test-integration-sqlite = 'sh scripts/test/integration-sqlite.sh' test-integration-sqlite-setup = 'sh scripts/test/integration-sqlite-setup.sh' test-performance = 'sh scripts/test/performance.sh' test-performance-setup = 'sh scripts/test/performance-setup.sh' -type-check = "mypy cosmos" +type-check = " pre-commit run mypy --files cosmos/**/*" [tool.pytest.ini_options] filterwarnings = ["ignore::DeprecationWarning"] From 806ef43e0639f547d67cb40b64ae8d38bdcfaad4 Mon Sep 17 00:00:00 2001 From: chris-okorodudu <50151469+chris-okorodudu@users.noreply.github.com> Date: Wed, 15 May 2024 06:17:34 -0400 Subject: [PATCH 156/223] Fix manifest testing behavior (#955) Updating the update_node_dependency method to ensure all relevant test nodes are included in the airflow graph. This fix stems from the test nodes not being included in the filtered_nodes dict after the select_nodes method is used to do filtering by graph operator. Closes: #949 --- cosmos/dbt/graph.py | 3 +- tests/dbt/test_graph.py | 71 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/cosmos/dbt/graph.py b/cosmos/dbt/graph.py index 09c00c6d13..40a44d3a63 100644 --- a/cosmos/dbt/graph.py +++ b/cosmos/dbt/graph.py @@ -478,8 +478,9 @@ def update_node_dependency(self) -> None: Updates in-place: * self.filtered_nodes """ - for _, node in self.filtered_nodes.items(): + for _, node in list(self.nodes.items()): if node.resource_type == DbtResourceType.TEST: for node_id in node.depends_on: if node_id in self.filtered_nodes: self.filtered_nodes[node_id].has_test = True + self.filtered_nodes[node.unique_id] = node diff --git a/tests/dbt/test_graph.py b/tests/dbt/test_graph.py index 22cd5c6178..5f778b763d 100644 --- a/tests/dbt/test_graph.py +++ b/tests/dbt/test_graph.py @@ -110,6 +110,77 @@ def test_load_via_manifest_with_exclude(project_name, manifest_filepath, model_f assert sample_node.file_path == DBT_PROJECTS_ROOT_DIR / f"{project_name}/models/{model_filepath}" +@pytest.mark.parametrize( + "project_name,manifest_filepath,model_filepath", + [(DBT_PROJECT_NAME, SAMPLE_MANIFEST, "customers.sql"), ("jaffle_shop_python", SAMPLE_MANIFEST_PY, "customers.py")], +) +def test_load_via_manifest_with_select(project_name, manifest_filepath, model_filepath): + project_config = ProjectConfig( + dbt_project_path=DBT_PROJECTS_ROOT_DIR / project_name, manifest_path=manifest_filepath + ) + profile_config = ProfileConfig( + profile_name="test", + target_name="test", + profiles_yml_filepath=DBT_PROJECTS_ROOT_DIR / DBT_PROJECT_NAME / "profiles.yml", + ) + render_config = RenderConfig(select=["+customers"]) + execution_config = ExecutionConfig(dbt_project_path=project_config.dbt_project_path) + dbt_graph = DbtGraph( + project=project_config, + execution_config=execution_config, + profile_config=profile_config, + render_config=render_config, + ) + dbt_graph.load_from_dbt_manifest() + + expected_keys = [ + "model.jaffle_shop.customers", + "model.jaffle_shop.orders", + "model.jaffle_shop.stg_customers", + "model.jaffle_shop.stg_orders", + "model.jaffle_shop.stg_payments", + "seed.jaffle_shop.raw_customers", + "seed.jaffle_shop.raw_orders", + "seed.jaffle_shop.raw_payments", + "test.jaffle_shop.accepted_values_orders_status__placed__shipped__completed__return_pending__returned.be6b5b5ec3", + "test.jaffle_shop.accepted_values_stg_orders_status__placed__shipped__completed__return_pending__returned.080fb20aad", + "test.jaffle_shop.accepted_values_stg_payments_payment_method__credit_card__coupon__bank_transfer__gift_card.3c3820f278", + "test.jaffle_shop.not_null_customers_customer_id.5c9bf9911d", + "test.jaffle_shop.not_null_orders_amount.106140f9fd", + "test.jaffle_shop.not_null_orders_bank_transfer_amount.7743500c49", + "test.jaffle_shop.not_null_orders_coupon_amount.ab90c90625", + "test.jaffle_shop.not_null_orders_credit_card_amount.d3ca593b59", + "test.jaffle_shop.not_null_orders_customer_id.c5f02694af", + "test.jaffle_shop.not_null_orders_gift_card_amount.413a0d2d7a", + "test.jaffle_shop.not_null_orders_order_id.cf6c17daed", + "test.jaffle_shop.not_null_stg_customers_customer_id.e2cfb1f9aa", + "test.jaffle_shop.not_null_stg_orders_order_id.81cfe2fe64", + "test.jaffle_shop.not_null_stg_payments_payment_id.c19cc50075", + "test.jaffle_shop.relationships_orders_customer_id__customer_id__ref_customers_.c6ec7f58f2", + "test.jaffle_shop.unique_customers_customer_id.c5af1ff4b1", + "test.jaffle_shop.unique_orders_order_id.fed79b3a6e", + "test.jaffle_shop.unique_stg_customers_customer_id.c7614daada", + "test.jaffle_shop.unique_stg_orders_order_id.e3b841c71a", + "test.jaffle_shop.unique_stg_payments_payment_id.3744510712", + ] + assert sorted(dbt_graph.nodes.keys()) == expected_keys + + assert len(dbt_graph.nodes) == 28 + assert len(dbt_graph.filtered_nodes) == 7 + assert "model.jaffle_shop.orders" not in dbt_graph.filtered_nodes + + sample_node = dbt_graph.nodes["model.jaffle_shop.customers"] + assert sample_node.name == "customers" + assert sample_node.unique_id == "model.jaffle_shop.customers" + assert sample_node.resource_type == DbtResourceType.MODEL + assert sample_node.depends_on == [ + "model.jaffle_shop.stg_customers", + "model.jaffle_shop.stg_orders", + "model.jaffle_shop.stg_payments", + ] + assert sample_node.file_path == DBT_PROJECTS_ROOT_DIR / f"{project_name}/models/{model_filepath}" + + @patch("cosmos.dbt.graph.DbtGraph.load_from_dbt_manifest", return_value=None) def test_load_automatic_manifest_is_available(mock_load_from_dbt_manifest): project_config = ProjectConfig( From b4655eeb6f733076981b352ca55503b3fb7993b0 Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Wed, 15 May 2024 13:33:59 +0100 Subject: [PATCH 157/223] Promote @dwreeves to committer (#960) [Daniel Reeves](https://www.linkedin.com/in/daniel-reeves-27700545/) (@dwreeves ) is an experienced Open-Source Developer currently working as a Data Architect at Battery Ventures. He has significant experience with Apache Airflow, SQL, and Python and has contributed to many [OSS projects](https://github.com/dwreeve). Not only has he been using Cosmos since its early stages, but since January 2023, he has actively contributed to the project: ![Screenshot 2024-05-14 at 10 47 30](https://github.com/astronomer/astronomer-cosmos/assets/272048/57829cb6-7eee-4b02-998b-46cc7746f15a) He has been a critical driver for the Cosmos 1.4 release, and some of his contributions include new features, bug fixes, and documentation improvements, including: * Creation of an Airflow plugin to render dbt docs: https://github.com/astronomer/astronomer-cosmos/pull/737 * Support using dbt partial parsing file: https://github.com/astronomer/astronomer-cosmos/pull/800 * Add more template fields to `DbtBaseOperator`: https://github.com/astronomer/astronomer-cosmos/pull/786 * Add cancel on kill functionality: https://github.com/astronomer/astronomer-cosmos/pull/101 * Make region optional in Snowflake profile mapping: https://github.com/astronomer/astronomer-cosmos/pull/100 * Fix the dbt docs operator to not look for `graph.pickle`: https://github.com/astronomer/astronomer-cosmos/pull/883 He thinks about the project long-term and proposes thorough solutions to problems faced by the community, as can be seen in Github tickets: * Introducing composability in the middle layer of Cosmos's API: https://github.com/astronomer/astronomer-cosmos/issues/895 * Establish a general pattern for uploading artifacts to storage: https://github.com/astronomer/astronomer-cosmos/issues/894 * Support `operator_arguments` injection at a node level: https://github.com/astronomer/astronomer-cosmos/issues/881 One of Daniel's notable traits is his collaborative and supportive approach. He has actively engaged with users in the #airflow-dbt Slack channel, demonstrating his commitment to fostering a supportive community. We want to promote him as a Cosmos committer and maintainer for all these, recognising his constant efforts and achievements towards our community. Thank you very much, @dwreeves ! --- docs/contributors.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/contributors.rst b/docs/contributors.rst index 16f7dba17c..d5ef0eca11 100644 --- a/docs/contributors.rst +++ b/docs/contributors.rst @@ -9,6 +9,7 @@ Committers ---------------------- * Chris Hronek (`@chrishronek `_) +* Daniel Reeves (`@dwreeves `_) * Harel Shein (`@harels `_) * Julian LaNeve (`@jlaneve `_) * Justin Bandoro (`@jbandoro `_) From 996e0f05f557df3027eb4653a573759667e83c3d Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Wed, 15 May 2024 14:08:05 +0100 Subject: [PATCH 158/223] Update emeritus contributors list (#961) There have been people who had a major role in the creation and/or development of Cosmos in previous phases, but for different circumstances, they have not been able to continue contributing to it in more recent times. This is not only from a code perspective, but also from a community perspective: Screenshot 2024-05-14 at 11 38 12 Screenshot 2024-05-14 at 11 38 03 With this change, we recognize their contributions. If, in the future, they start to contribute again to the project, we'll move them to the contributions section again, regaining committer superpowers. More information in the contributors roles can be found at: https://github.com/astronomer/astronomer-cosmos/blob/main/docs/contributors-roles.rst --- docs/contributors.rst | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/contributors.rst b/docs/contributors.rst index d5ef0eca11..c6c89f1ec2 100644 --- a/docs/contributors.rst +++ b/docs/contributors.rst @@ -8,9 +8,7 @@ Learn more about the project contributors roles in :ref:`contributors-roles`. Committers ---------------------- -* Chris Hronek (`@chrishronek `_) * Daniel Reeves (`@dwreeves `_) -* Harel Shein (`@harels `_) * Julian LaNeve (`@jlaneve `_) * Justin Bandoro (`@jbandoro `_) * Tatiana Al-Chueyr (`@tatiana `_) @@ -19,7 +17,7 @@ Committers Emeritus Committers ------------------------------- -(none at the moment) +* Chris Hronek (`@chrishronek `_) Contributors ------------ From 31d61d45a10ceab1a97faf34e4c3f7eb4feef03b Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Wed, 15 May 2024 15:05:27 +0100 Subject: [PATCH 159/223] Update CODEOWNERS (#968) Update CODEOWNERS based on Astronomer organizational changes and new committer. --- .github/CODEOWNERS | 2 +- CODEOWNERS | 1 + docs/contributors.rst | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d9b484b545..b37f167892 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @astronomer/astro-cosmos-admins @astronomer/astro-devex +@astronomer/astro-cosmos-admins @astronomer/astro-cosmos-codeowners diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000000..2ae1cb59dc --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +@astronomer/astro-cosmos-admins @astronomer/astro-cosmos-codeowners @jbandoro @dwreeves diff --git a/docs/contributors.rst b/docs/contributors.rst index c6c89f1ec2..865d648748 100644 --- a/docs/contributors.rst +++ b/docs/contributors.rst @@ -19,6 +19,7 @@ Emeritus Committers * Chris Hronek (`@chrishronek `_) + Contributors ------------ From f7303bfaf8d3e853379427ca0f606d032cbf5949 Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Thu, 16 May 2024 10:40:51 +0100 Subject: [PATCH 160/223] Clean databricks credentials in test/CI (#969) We're changing the credentials for the Databricks cluster used to validate dbt Python models, and I noticed there were a few unnecessary credentials set in the CI. This PR aims to clean them up, making it easier for us to manage them. All the tests passed in: https://github.com/astronomer/astronomer-cosmos/actions/runs/9109622858 The push tests failed because they relied on workflow configuration from the `main` branch. --- .github/workflows/test.yml | 34 +++++++++++----------------------- 1 file changed, 11 insertions(+), 23 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dc0cfd055d..cfd274e28a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -125,17 +125,15 @@ jobs: - name: Test Cosmos against Airflow ${{ matrix.airflow-version }} and Python ${{ matrix.python-version }} run: | hatch run tests.py${{ matrix.python-version }}-${{ matrix.airflow-version }}:test-integration-setup - DATABRICKS_UNIQUE_ID="${{github.run_id}}_${{matrix.python-version}}_${{ matrix.airflow-version }}" hatch run tests.py${{ matrix.python-version }}-${{ matrix.airflow-version }}:test-integration + hatch run tests.py${{ matrix.python-version }}-${{ matrix.airflow-version }}:test-integration env: AIRFLOW_HOME: /home/runner/work/astronomer-cosmos/astronomer-cosmos/ AIRFLOW_CONN_AIRFLOW_DB: postgres://postgres:postgres@0.0.0.0:5432/postgres - AIRFLOW_CONN_DATABRICKS_DEFAULT: ${{ secrets.AIRFLOW_CONN_DATABRICKS_DEFAULT }} + DATABRICKS_HOST: mock + DATABRICKS_WAREHOUSE_ID: mock + DATABRICKS_TOKEN: mock AIRFLOW__CORE__DAGBAG_IMPORT_TIMEOUT: 90.0 PYTHONPATH: /home/runner/work/astronomer-cosmos/astronomer-cosmos/:$PYTHONPATH - DATABRICKS_HOST: ${{ secrets.DATABRICKS_HOST }} - DATABRICKS_TOKEN: ${{ secrets.DATABRICKS_TOKEN }} - DATABRICKS_WAREHOUSE_ID: ${{ secrets.DATABRICKS_WAREHOUSE_ID }} - DATABRICKS_CLUSTER_ID: ${{ secrets.DATABRICKS_CLUSTER_ID }} COSMOS_CONN_POSTGRES_PASSWORD: ${{ secrets.COSMOS_CONN_POSTGRES_PASSWORD }} POSTGRES_HOST: localhost POSTGRES_USER: postgres @@ -201,11 +199,8 @@ jobs: AIRFLOW_CONN_AIRFLOW_DB: postgres://postgres:postgres@0.0.0.0:5432/postgres PYTHONPATH: /home/runner/work/astronomer-cosmos/astronomer-cosmos/:$PYTHONPATH AIRFLOW_CONN_DATABRICKS_DEFAULT: ${{ secrets.AIRFLOW_CONN_DATABRICKS_DEFAULT }} - AIRFLOW__CORE__DAGBAG_IMPORT_TIMEOUT: 90.0 - DATABRICKS_HOST: ${{ secrets.DATABRICKS_HOST }} - DATABRICKS_TOKEN: ${{ secrets.DATABRICKS_TOKEN }} - DATABRICKS_WAREHOUSE_ID: ${{ secrets.DATABRICKS_WAREHOUSE_ID }} DATABRICKS_CLUSTER_ID: ${{ secrets.DATABRICKS_CLUSTER_ID }} + AIRFLOW__CORE__DAGBAG_IMPORT_TIMEOUT: 90.0 COSMOS_CONN_POSTGRES_PASSWORD: ${{ secrets.COSMOS_CONN_POSTGRES_PASSWORD }} POSTGRES_HOST: localhost POSTGRES_USER: postgres @@ -225,9 +220,6 @@ jobs: AIRFLOW_CONN_AIRFLOW_DB: postgres://postgres:postgres@0.0.0.0:5432/postgres PYTHONPATH: /home/runner/work/astronomer-cosmos/astronomer-cosmos/:$PYTHONPATH AIRFLOW_CONN_DATABRICKS_DEFAULT: ${{ secrets.AIRFLOW_CONN_DATABRICKS_DEFAULT }} - DATABRICKS_HOST: ${{ secrets.DATABRICKS_HOST }} - DATABRICKS_TOKEN: ${{ secrets.DATABRICKS_TOKEN }} - DATABRICKS_WAREHOUSE_ID: ${{ secrets.DATABRICKS_WAREHOUSE_ID }} DATABRICKS_CLUSTER_ID: ${{ secrets.DATABRICKS_CLUSTER_ID }} Run-Integration-Tests-Sqlite: @@ -268,12 +260,10 @@ jobs: AIRFLOW_CONN_AIRFLOW_DB: postgres://postgres:postgres@0.0.0.0:5432/postgres AIRFLOW__CORE__DAGBAG_IMPORT_TIMEOUT: 90.0 PYTHONPATH: /home/runner/work/astronomer-cosmos/astronomer-cosmos/:$PYTHONPATH - AIRFLOW_CONN_DATABRICKS_DEFAULT: ${{ secrets.AIRFLOW_CONN_DATABRICKS_DEFAULT }} - DATABRICKS_HOST: ${{ secrets.DATABRICKS_HOST }} - DATABRICKS_TOKEN: ${{ secrets.DATABRICKS_TOKEN }} - DATABRICKS_WAREHOUSE_ID: ${{ secrets.DATABRICKS_WAREHOUSE_ID }} - DATABRICKS_CLUSTER_ID: ${{ secrets.DATABRICKS_CLUSTER_ID }} COSMOS_CONN_POSTGRES_PASSWORD: ${{ secrets.COSMOS_CONN_POSTGRES_PASSWORD }} + DATABRICKS_HOST: mock + DATABRICKS_WAREHOUSE_ID: mock + DATABRICKS_TOKEN: mock POSTGRES_HOST: localhost POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres @@ -341,12 +331,10 @@ jobs: AIRFLOW_CONN_AIRFLOW_DB: postgres://postgres:postgres@0.0.0.0:5432/postgres AIRFLOW__CORE__DAGBAG_IMPORT_TIMEOUT: 90.0 PYTHONPATH: /home/runner/work/astronomer-cosmos/astronomer-cosmos/:$PYTHONPATH - AIRFLOW_CONN_DATABRICKS_DEFAULT: ${{ secrets.AIRFLOW_CONN_DATABRICKS_DEFAULT }} - DATABRICKS_HOST: ${{ secrets.DATABRICKS_HOST }} - DATABRICKS_TOKEN: ${{ secrets.DATABRICKS_TOKEN }} - DATABRICKS_WAREHOUSE_ID: ${{ secrets.DATABRICKS_WAREHOUSE_ID }} - DATABRICKS_CLUSTER_ID: ${{ secrets.DATABRICKS_CLUSTER_ID }} COSMOS_CONN_POSTGRES_PASSWORD: ${{ secrets.COSMOS_CONN_POSTGRES_PASSWORD }} + DATABRICKS_HOST: mock + DATABRICKS_WAREHOUSE_ID: mock + DATABRICKS_TOKEN: mock POSTGRES_HOST: localhost POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres From 7587fd6380c91ceda2988d1c7b5cfe977300b3c4 Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Thu, 16 May 2024 11:51:16 +0100 Subject: [PATCH 161/223] Fix CI databricks issue As seen in: https://github.com/astronomer/astronomer-cosmos/actions/runs/9110057011/job/25044265602 --- .github/workflows/test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cfd274e28a..499365f1fa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -132,6 +132,7 @@ jobs: DATABRICKS_HOST: mock DATABRICKS_WAREHOUSE_ID: mock DATABRICKS_TOKEN: mock + DATABRICKS_CLUSTER_ID: mock AIRFLOW__CORE__DAGBAG_IMPORT_TIMEOUT: 90.0 PYTHONPATH: /home/runner/work/astronomer-cosmos/astronomer-cosmos/:$PYTHONPATH COSMOS_CONN_POSTGRES_PASSWORD: ${{ secrets.COSMOS_CONN_POSTGRES_PASSWORD }} From 560cec8a9c80bb0f6c038d05fbd2b4a51c9d6c3f Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Thu, 16 May 2024 11:57:33 +0100 Subject: [PATCH 162/223] Fix CI --- .github/workflows/test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 499365f1fa..b57488f6f0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -262,6 +262,7 @@ jobs: AIRFLOW__CORE__DAGBAG_IMPORT_TIMEOUT: 90.0 PYTHONPATH: /home/runner/work/astronomer-cosmos/astronomer-cosmos/:$PYTHONPATH COSMOS_CONN_POSTGRES_PASSWORD: ${{ secrets.COSMOS_CONN_POSTGRES_PASSWORD }} + DATABRICKS_CLUSTER_ID: mock DATABRICKS_HOST: mock DATABRICKS_WAREHOUSE_ID: mock DATABRICKS_TOKEN: mock @@ -333,6 +334,7 @@ jobs: AIRFLOW__CORE__DAGBAG_IMPORT_TIMEOUT: 90.0 PYTHONPATH: /home/runner/work/astronomer-cosmos/astronomer-cosmos/:$PYTHONPATH COSMOS_CONN_POSTGRES_PASSWORD: ${{ secrets.COSMOS_CONN_POSTGRES_PASSWORD }} + DATABRICKS_CLUSTER_ID: mock DATABRICKS_HOST: mock DATABRICKS_WAREHOUSE_ID: mock DATABRICKS_TOKEN: mock From 2fc6d93a603ca039cd2ab196dffe711ee2cb51bb Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Thu, 16 May 2024 11:40:00 +0100 Subject: [PATCH 163/223] Handle ValueError when unpacking partial_parse.msgpack Closes: #971 After upgrading to Cosmos v1.4, I've been getting the follow error intermittently on some tasks. Rerunning the task succeeds, but the error continues on subsequent DAG runs. File /home/airflow/.local/lib/python3.8/site-packages/cosmos/cache.py, line 134, in _copy_partial_parse_to_project data = msgpack.unpack(f) File /home/airflow/.local/lib/python3.8/site-packages/msgpack/__init__.py, line 47, in unpack return unpackb(data, **kwargs) File msgpack/_unpacker.pyx, line 205, in msgpack._cmsgpack.unpackb ValueError: Unpack failed: incomplete input --- cosmos/cache.py | 64 +++++++++++++++++++++++++++++++++------------ tests/test_cache.py | 22 +++++++++++++++- 2 files changed, 68 insertions(+), 18 deletions(-) diff --git a/cosmos/cache.py b/cosmos/cache.py index 7d136a127d..2574a2b7b8 100644 --- a/cosmos/cache.py +++ b/cosmos/cache.py @@ -10,6 +10,9 @@ from cosmos import settings from cosmos.constants import DBT_MANIFEST_FILE_NAME, DBT_TARGET_DIR_NAME from cosmos.dbt.project import get_partial_parse_path +from cosmos.log import get_logger + +logger = get_logger() # It was considered to create a cache identifier based on the dbt project path, as opposed @@ -108,6 +111,46 @@ def _update_partial_parse_cache(latest_partial_parse_filepath: Path, cache_dir: shutil.copy(str(latest_manifest_filepath), str(manifest_path)) +def patch_partial_parse_content(partial_parse_filepath: Path, project_path: Path) -> bool: + """ + Update, if needed, the root_path references in partial_parse.msgpack to an existing project directory. + This is necessary because an issue is observed where on specific earlier versions of dbt-core like 1.5.4 and 1.6.5, + the commands fail to locate project files as they are pointed to a stale directory by the root_path in the partial + parse file. + + This issue was not observed on recent versions of dbt-core 1.5.8, 1.6.6, 1.7.0 and 1.8.0 as tested on. + It is suspected that PR dbt-labs/dbt-core#8762 is likely the fix and the fix appears to be backported to later + version releases of 1.5.x and 1.6.x. However, the below modification is applied to ensure that the root_path is + correctly set to the needed project directory and the feature is compatible across all dbt-core versions. + + :param partial_parse_filepath: Path to the most up-to-date partial parse file + :param project_path: Path to the target dbt project directory + """ + + should_patch_partial_parse_content = True + try: + with partial_parse_filepath.open("rb") as f: + # Issue reported: https://github.com/astronomer/astronomer-cosmos/issues/971 + # it may be due a race condition of multiple processes trying to read/write this file + data = msgpack.unpack(f) + except ValueError as e: + should_patch_partial_parse_content = False + logger.info("Unable to patch the partial_parse.msgpack file due to %s" % repr(e)) + else: + for node in data["nodes"].values(): + expected_filepath = node.get("root_path") + if not Path(expected_filepath).exists(): + node["root_path"] = str(project_path) + else: + should_patch_partial_parse_content = False + break + if should_patch_partial_parse_content: + with partial_parse_filepath.open("wb") as f: + packed = msgpack.packb(data) + f.write(packed) + return should_patch_partial_parse_content + + def _copy_partial_parse_to_project(partial_parse_filepath: Path, project_path: Path) -> None: """ Update target dbt project directory to have the latest partial parse file contents. @@ -123,20 +166,7 @@ def _copy_partial_parse_to_project(partial_parse_filepath: Path, project_path: P target_manifest_filepath = target_partial_parse_file.parent / DBT_MANIFEST_FILE_NAME shutil.copy(str(partial_parse_filepath), str(target_partial_parse_file)) - # Update root_path in partial parse file to point to the needed project directory. This is necessary because - # an issue is observed where on specific earlier versions of dbt-core like 1.5.4 and 1.6.5, the commands fail to - # locate project files as they are pointed to a stale directory by the root_path in the partial parse file. - # This issue was not observed on recent versions of dbt-core 1.5.8, 1.6.6, 1.7.0 and 1.8.0 as tested on. - # It is suspected that PR dbt-labs/dbt-core#8762 is likely the fix and the fix appears to be backported to later - # version releases of 1.5.x and 1.6.x. However, the below modification is applied to ensure that the root_path is - # correctly set to the needed project directory and the feature is compatible across all dbt-core versions. - with target_partial_parse_file.open("rb") as f: - data = msgpack.unpack(f) - for node in data["nodes"].values(): - if node.get("root_path"): - node["root_path"] = str(project_path) - with target_partial_parse_file.open("wb") as f: - packed = msgpack.packb(data) - f.write(packed) - - shutil.copy(str(source_manifest_filepath), str(target_manifest_filepath)) + patch_partial_parse_content(target_partial_parse_file, project_path) + + if source_manifest_filepath.exists(): + shutil.copy(str(source_manifest_filepath), str(target_manifest_filepath)) diff --git a/tests/test_cache.py b/tests/test_cache.py index 2898475d09..d6203ee861 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -1,15 +1,20 @@ +import shutil +import tempfile import time from datetime import datetime +from pathlib import Path +from unittest.mock import patch import pytest from airflow import DAG from airflow.utils.task_group import TaskGroup -from cosmos.cache import _create_cache_identifier, _get_latest_partial_parse +from cosmos.cache import _copy_partial_parse_to_project, _create_cache_identifier, _get_latest_partial_parse from cosmos.constants import DBT_PARTIAL_PARSE_FILE_NAME, DBT_TARGET_DIR_NAME START_DATE = datetime(2024, 4, 16) example_dag = DAG("dag", start_date=START_DATE) +SAMPLE_PARTIAL_PARSE_FILEPATH = Path(__file__).parent / "sample/partial_parse.msgpack" @pytest.mark.parametrize( @@ -64,3 +69,18 @@ def test_get_latest_partial_parse(tmp_path): assert _get_latest_partial_parse(old_tmp_dir, tmp_path) == old_partial_parse_filepath assert _get_latest_partial_parse(tmp_path, old_tmp_dir) == old_partial_parse_filepath assert _get_latest_partial_parse(tmp_path, tmp_path) is None + + +@patch("cosmos.cache.msgpack.unpack", side_effect=ValueError) +def test__copy_partial_parse_to_project_msg_fails_msgpack(mock_unpack, tmp_path, caplog): + # setup + source_dir = tmp_path / DBT_TARGET_DIR_NAME + source_dir.mkdir() + partial_parse_filepath = source_dir / DBT_PARTIAL_PARSE_FILE_NAME + shutil.copy(str(SAMPLE_PARTIAL_PARSE_FILEPATH), str(partial_parse_filepath)) + + # actual test + with tempfile.TemporaryDirectory() as tmp_dir: + _copy_partial_parse_to_project(partial_parse_filepath, Path(tmp_dir)) + + assert "Unable to patch the partial_parse.msgpack file due to ValueError()" in caplog.text From e3025b577919e8f560acf7fadfbf4757525a67ea Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Thu, 16 May 2024 11:45:44 +0100 Subject: [PATCH 164/223] Fix missing test fixture --- tests/sample/partial_parse.msgpack | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/sample/partial_parse.msgpack diff --git a/tests/sample/partial_parse.msgpack b/tests/sample/partial_parse.msgpack new file mode 100644 index 0000000000..e69de29bb2 From 9232e2b6b822af4f4973b26159c7c4d82f3c4f97 Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Thu, 16 May 2024 12:05:32 +0100 Subject: [PATCH 165/223] Fix issue with performance tests https://github.com/astronomer/astronomer-cosmos/actions/runs/9111059879/job/25047405043\?pr\=972 --- cosmos/cache.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cosmos/cache.py b/cosmos/cache.py index 2574a2b7b8..1ec76732be 100644 --- a/cosmos/cache.py +++ b/cosmos/cache.py @@ -127,7 +127,7 @@ def patch_partial_parse_content(partial_parse_filepath: Path, project_path: Path :param project_path: Path to the target dbt project directory """ - should_patch_partial_parse_content = True + should_patch_partial_parse_content = bool(partial_parse_filepath) and partial_parse_filepath.exists() try: with partial_parse_filepath.open("rb") as f: # Issue reported: https://github.com/astronomer/astronomer-cosmos/issues/971 From 6ea6f362c8225b929a1d813d22750eba5e46db01 Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Thu, 16 May 2024 12:32:20 +0100 Subject: [PATCH 166/223] Fix broken test --- cosmos/cache.py | 9 +++++---- tests/test_cache.py | 2 ++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/cosmos/cache.py b/cosmos/cache.py index 1ec76732be..1e0b341f07 100644 --- a/cosmos/cache.py +++ b/cosmos/cache.py @@ -126,23 +126,24 @@ def patch_partial_parse_content(partial_parse_filepath: Path, project_path: Path :param partial_parse_filepath: Path to the most up-to-date partial parse file :param project_path: Path to the target dbt project directory """ + should_patch_partial_parse_content = False - should_patch_partial_parse_content = bool(partial_parse_filepath) and partial_parse_filepath.exists() try: with partial_parse_filepath.open("rb") as f: # Issue reported: https://github.com/astronomer/astronomer-cosmos/issues/971 # it may be due a race condition of multiple processes trying to read/write this file data = msgpack.unpack(f) except ValueError as e: - should_patch_partial_parse_content = False logger.info("Unable to patch the partial_parse.msgpack file due to %s" % repr(e)) else: for node in data["nodes"].values(): expected_filepath = node.get("root_path") - if not Path(expected_filepath).exists(): + if expected_filepath is None: + continue + elif expected_filepath and not Path(expected_filepath).exists(): node["root_path"] = str(project_path) + should_patch_partial_parse_content = True else: - should_patch_partial_parse_content = False break if should_patch_partial_parse_content: with partial_parse_filepath.open("wb") as f: diff --git a/tests/test_cache.py b/tests/test_cache.py index d6203ee861..d75bc439b8 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -1,3 +1,4 @@ +import logging import shutil import tempfile import time @@ -74,6 +75,7 @@ def test_get_latest_partial_parse(tmp_path): @patch("cosmos.cache.msgpack.unpack", side_effect=ValueError) def test__copy_partial_parse_to_project_msg_fails_msgpack(mock_unpack, tmp_path, caplog): # setup + caplog.set_level(logging.INFO) source_dir = tmp_path / DBT_TARGET_DIR_NAME source_dir.mkdir() partial_parse_filepath = source_dir / DBT_PARTIAL_PARSE_FILE_NAME From 4da4ec08127fe0762bc762435aa96fb8bc6ec064 Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Thu, 16 May 2024 15:01:15 +0100 Subject: [PATCH 167/223] Release 1.4.1rc1 --- CHANGELOG.rst | 18 ++++++++++++++++++ cosmos/__init__.py | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0c2e21ba36..a974d813e7 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,24 @@ Changelog ========= +1.4.1rc1 (2024-05-16) +-------------------- + +Bug fixes + +* Fix manifest testing behavior in #955 by @chris-okorodudu +* Handle ValueError when unpacking partial_parse.msgpack in #972 by @tatiana + +Others + +* Enable pre-commit run and fix type-check job by @pankajastro in #957 +* Clean databricks credentials in test/CI in #969 by @tatiana +* Update CODEOWNERS in #969 by @tatiana +* Update emeritus contributors list in #961 by @tatiana +* Promote @dwreeves to committer in #960 by @tatiana +* Pre-commit hook updates in #956 + + 1.4.0 (2024-05-13) -------------------- diff --git a/cosmos/__init__.py b/cosmos/__init__.py index b84252d0c4..7720abce7d 100644 --- a/cosmos/__init__.py +++ b/cosmos/__init__.py @@ -5,7 +5,7 @@ Contains dags, task groups, and operators. """ -__version__ = "1.4.0" +__version__ = "1.4.1rc1" from cosmos.airflow.dag import DbtDag From dd788d31f8d391cdf297a4705ee86c30dbdf0443 Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Fri, 17 May 2024 12:30:03 +0100 Subject: [PATCH 168/223] Change from RC to stable release --- CHANGELOG.rst | 2 +- cosmos/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a974d813e7..2ef66782c1 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,7 +1,7 @@ Changelog ========= -1.4.1rc1 (2024-05-16) +1.4.1 (2024-05-17) -------------------- Bug fixes diff --git a/cosmos/__init__.py b/cosmos/__init__.py index 7720abce7d..5d88d35d3c 100644 --- a/cosmos/__init__.py +++ b/cosmos/__init__.py @@ -5,7 +5,7 @@ Contains dags, task groups, and operators. """ -__version__ = "1.4.1rc1" +__version__ = "1.4.1" from cosmos.airflow.dag import DbtDag From e643aeae862daaa5efa33d9a76334feaa8673f08 Mon Sep 17 00:00:00 2001 From: Pankaj Singh <98807258+pankajastro@users.noreply.github.com> Date: Mon, 20 May 2024 01:13:23 +0530 Subject: [PATCH 169/223] Enable ruff F linting (#985) I've noticed there are a few unused imports in this library. This PR aims to activate "[F](https://docs.astral.sh/ruff/rules/#pyflakes-f)" to cleanup things. --- cosmos/dbt/project.py | 1 - cosmos/operators/base.py | 2 -- cosmos/operators/kubernetes.py | 2 +- cosmos/operators/local.py | 6 ++---- pyproject.toml | 3 ++- tests/dbt/test_project.py | 2 -- tests/operators/test_local.py | 3 --- tests/plugin/test_plugin.py | 1 - 8 files changed, 5 insertions(+), 15 deletions(-) diff --git a/cosmos/dbt/project.py b/cosmos/dbt/project.py index 4a3b036b36..c1c7aa080f 100644 --- a/cosmos/dbt/project.py +++ b/cosmos/dbt/project.py @@ -1,7 +1,6 @@ from __future__ import annotations import os -import shutil from contextlib import contextmanager from pathlib import Path from typing import Generator diff --git a/cosmos/operators/base.py b/cosmos/operators/base.py index b8112a73f7..e22703fb5b 100644 --- a/cosmos/operators/base.py +++ b/cosmos/operators/base.py @@ -2,7 +2,6 @@ import os from abc import ABCMeta, abstractmethod -from functools import cached_property from pathlib import Path from typing import Any, Sequence, Tuple @@ -12,7 +11,6 @@ from airflow.utils.operator_helpers import context_to_airflow_vars from airflow.utils.strings import to_boolean -from cosmos import cache from cosmos.dbt.executable import get_system_dbt from cosmos.log import get_logger diff --git a/cosmos/operators/kubernetes.py b/cosmos/operators/kubernetes.py index 14bcbcb84c..f842191998 100644 --- a/cosmos/operators/kubernetes.py +++ b/cosmos/operators/kubernetes.py @@ -36,7 +36,7 @@ try: # apache-airflow-providers-cncf-kubernetes < 7.4.0 from airflow.providers.cncf.kubernetes.operators.kubernetes_pod import KubernetesPodOperator - except ImportError as error: + except ImportError: raise ImportError( "Could not import KubernetesPodOperator. Ensure you've installed the Kubernetes provider " "separately or with with `pip install astronomer-cosmos[...,kubernetes]`." diff --git a/cosmos/operators/local.py b/cosmos/operators/local.py index 392bd45070..760f482901 100644 --- a/cosmos/operators/local.py +++ b/cosmos/operators/local.py @@ -41,8 +41,6 @@ from cosmos.config import ProfileConfig from cosmos.constants import ( - DBT_PARTIAL_PARSE_FILE_NAME, - DBT_TARGET_DIR_NAME, DEFAULT_OPENLINEAGE_NAMESPACE, OPENLINEAGE_PRODUCER, ) @@ -176,7 +174,7 @@ def _discover_invocation_mode(self) -> None: This method is called at runtime to work in the environment where the operator is running. """ try: - from dbt.cli.main import dbtRunner + from dbt.cli.main import dbtRunner # noqa except ImportError: self.invocation_mode = InvocationMode.SUBPROCESS logger.info("Could not import dbtRunner. Falling back to subprocess for invoking dbt.") @@ -426,7 +424,7 @@ def get_datasets(self, source: Literal["inputs", "outputs"]) -> list[Dataset]: datasets = [] try: datasets = [Dataset(uri) for uri in uris] - except ValueError as e: + except ValueError: raise AirflowCompatibilityError( """ Apache Airflow 2.9.0 & 2.9.1 introduced a breaking change in Dataset URIs, to be fixed in newer versions: diff --git a/pyproject.toml b/pyproject.toml index 97788aee5a..8c29c68272 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -199,7 +199,8 @@ no_warn_unused_ignores = true [tool.ruff] line-length = 120 [tool.ruff.lint] -select = ["C901", "I"] +select = ["C901", "I", "F"] +ignore = ["F541"] [tool.ruff.lint.mccabe] max-complexity = 10 diff --git a/tests/dbt/test_project.py b/tests/dbt/test_project.py index 09ab1a7358..f55525a439 100644 --- a/tests/dbt/test_project.py +++ b/tests/dbt/test_project.py @@ -2,8 +2,6 @@ from pathlib import Path from unittest.mock import patch -import pytest - from cosmos.dbt.project import change_working_directory, create_symlinks, environ DBT_PROJECTS_ROOT_DIR = Path(__file__).parent.parent.parent / "dev/dags/dbt" diff --git a/tests/operators/test_local.py b/tests/operators/test_local.py index 11652e10dd..0f35705b6a 100644 --- a/tests/operators/test_local.py +++ b/tests/operators/test_local.py @@ -3,7 +3,6 @@ import shutil import sys import tempfile -from datetime import datetime from pathlib import Path from unittest.mock import MagicMock, call, patch @@ -451,7 +450,6 @@ def test_run_operator_dataset_inlets_and_outlets(caplog): ) @pytest.mark.integration def test_run_operator_dataset_emission_fails(caplog): - from airflow.datasets import Dataset with DAG("test-id-1", start_date=datetime(2022, 1, 1)) as dag: seed_operator = DbtSeedLocalOperator( @@ -494,7 +492,6 @@ def test_run_operator_dataset_emission_fails(caplog): ) @pytest.mark.integration def test_run_operator_dataset_emission_is_skipped(caplog): - from airflow.datasets import Dataset with DAG("test-id-1", start_date=datetime(2022, 1, 1)) as dag: seed_operator = DbtSeedLocalOperator( diff --git a/tests/plugin/test_plugin.py b/tests/plugin/test_plugin.py index 8d0e3742fd..25fcacbdab 100644 --- a/tests/plugin/test_plugin.py +++ b/tests/plugin/test_plugin.py @@ -18,7 +18,6 @@ import pytest from airflow.configuration import conf -from airflow.exceptions import AirflowConfigException from airflow.utils.db import initdb, resetdb from airflow.www.app import cached_app from airflow.www.extensions.init_appbuilder import AirflowAppBuilder From 398df075a76dc60ad61baa0f95cc0c4f5124762d Mon Sep 17 00:00:00 2001 From: Pankaj Singh <98807258+pankajastro@users.noreply.github.com> Date: Mon, 20 May 2024 21:26:08 +0530 Subject: [PATCH 170/223] Move airflow conf fetch call to setting.py (#975) ## Description - Centralizing environment or configuration fetching by moving the Airflow configuration call to the Cosmos settings.py file. - Add documentation for cosmos config sections Sample HTML page Screenshot 2024-05-18 at 1 04 13 AM ## Related Issue(s) closes: https://github.com/astronomer/astronomer-cosmos/issues/928 ## Breaking Change? No ## Checklist - [ ] I have made corresponding changes to the documentation (if required) - [ ] I have added tests that prove my fix is effective or that my feature works --- cosmos/log.py | 8 ++- cosmos/operators/local.py | 10 +--- cosmos/plugin/__init__.py | 30 +++++------- cosmos/settings.py | 12 ++++- docs/configuration/cosmos-conf.rst | 79 ++++++++++++++++++++++++++++++ docs/configuration/index.rst | 1 + tests/plugin/test_plugin.py | 3 ++ tests/test_log.py | 8 +-- 8 files changed, 113 insertions(+), 38 deletions(-) create mode 100644 docs/configuration/cosmos-conf.rst diff --git a/cosmos/log.py b/cosmos/log.py index f7c512f17e..3294ac5b7c 100644 --- a/cosmos/log.py +++ b/cosmos/log.py @@ -2,9 +2,10 @@ import logging -from airflow.configuration import conf from airflow.utils.log.colored_log import CustomTTYColoredFormatter +from cosmos.settings import propagate_logs + LOG_FORMAT: str = ( "[%(blue)s%(asctime)s%(reset)s] " "{%(blue)s%(filename)s:%(reset)s%(lineno)d} " @@ -24,13 +25,10 @@ def get_logger(name: str | None = None) -> logging.Logger: By using this logger, we introduce a (yellow) astronomer-cosmos string into the project's log messages: [2023-08-09T14:20:55.532+0100] {subprocess.py:94} INFO - (astronomer-cosmos) - 13:20:55 Completed successfully """ - propagateLogs: bool = True - if conf.has_option("cosmos", "propagate_logs"): - propagateLogs = conf.getboolean("cosmos", "propagate_logs") logger = logging.getLogger(name) formatter: logging.Formatter = CustomTTYColoredFormatter(fmt=LOG_FORMAT) # type: ignore handler = logging.StreamHandler() handler.setFormatter(formatter) logger.addHandler(handler) - logger.propagate = propagateLogs + logger.propagate = propagate_logs return logger diff --git a/cosmos/operators/local.py b/cosmos/operators/local.py index 760f482901..ca255c7d83 100644 --- a/cosmos/operators/local.py +++ b/cosmos/operators/local.py @@ -8,10 +8,8 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Callable, Literal, Sequence -import airflow import jinja2 from airflow import DAG -from airflow.configuration import conf from airflow.exceptions import AirflowException, AirflowSkipException from airflow.models.taskinstance import TaskInstance from airflow.utils.context import Context @@ -22,6 +20,7 @@ from cosmos.constants import InvocationMode from cosmos.dbt.project import get_partial_parse_path from cosmos.exceptions import AirflowCompatibilityError +from cosmos.settings import LINEAGE_NAMESPACE try: from airflow.datasets import Dataset @@ -41,7 +40,6 @@ from cosmos.config import ProfileConfig from cosmos.constants import ( - DEFAULT_OPENLINEAGE_NAMESPACE, OPENLINEAGE_PRODUCER, ) from cosmos.dbt.parser.output import ( @@ -92,12 +90,6 @@ class OperatorLineage: # type: ignore job_facets: dict[str, str] = dict() -try: - LINEAGE_NAMESPACE = conf.get("openlineage", "namespace") -except airflow.exceptions.AirflowConfigException: - LINEAGE_NAMESPACE = os.getenv("OPENLINEAGE_NAMESPACE", DEFAULT_OPENLINEAGE_NAMESPACE) - - class DbtLocalBaseOperator(AbstractDbtBaseOperator): """ Executes a dbt core cli command locally. diff --git a/cosmos/plugin/__init__.py b/cosmos/plugin/__init__.py index b82b29f5c8..d05e15dd6f 100644 --- a/cosmos/plugin/__init__.py +++ b/cosmos/plugin/__init__.py @@ -2,7 +2,6 @@ from typing import Any, Dict, Optional, Tuple from urllib.parse import urlsplit -from airflow.configuration import conf from airflow.plugins_manager import AirflowPlugin from airflow.security import permissions from airflow.www.auth import has_access @@ -10,6 +9,8 @@ from flask import abort, url_for from flask_appbuilder import AppBuilder, expose +from cosmos.settings import dbt_docs_conn_id, dbt_docs_dir + def bucket_and_key(path: str) -> Tuple[str, str]: parsed_url = urlsplit(path) @@ -69,16 +70,14 @@ def open_http_file(conn_id: Optional[str], path: str) -> str: def open_file(path: str) -> str: """Retrieve a file from http, https, gs, s3, or wasb.""" - conn_id: Optional[str] = conf.get("cosmos", "dbt_docs_conn_id", fallback=None) - if path.strip().startswith("s3://"): - return open_s3_file(conn_id=conn_id, path=path) + return open_s3_file(conn_id=dbt_docs_conn_id, path=path) elif path.strip().startswith("gs://"): - return open_gcs_file(conn_id=conn_id, path=path) + return open_gcs_file(conn_id=dbt_docs_conn_id, path=path) elif path.strip().startswith("wasb://"): - return open_azure_file(conn_id=conn_id, path=path) + return open_azure_file(conn_id=dbt_docs_conn_id, path=path) elif path.strip().startswith("http://") or path.strip().startswith("https://"): - return open_http_file(conn_id=conn_id, path=path) + return open_http_file(conn_id=dbt_docs_conn_id, path=path) else: with open(path) as f: content = f.read() @@ -159,17 +158,16 @@ def create_blueprint( @expose("/dbt_docs") # type: ignore[misc] @has_access([(permissions.ACTION_CAN_READ, permissions.RESOURCE_WEBSITE)]) def dbt_docs(self) -> str: - if conf.get("cosmos", "dbt_docs_dir", fallback=None) is None: + if dbt_docs_dir is None: return self.render_template("dbt_docs_not_set_up.html") # type: ignore[no-any-return,no-untyped-call] return self.render_template("dbt_docs.html") # type: ignore[no-any-return,no-untyped-call] @expose("/dbt_docs_index.html") # type: ignore[misc] @has_access([(permissions.ACTION_CAN_READ, permissions.RESOURCE_WEBSITE)]) def dbt_docs_index(self) -> str: - docs_dir = conf.get("cosmos", "dbt_docs_dir", fallback=None) - if docs_dir is None: + if dbt_docs_dir is None: abort(404) - html = open_file(op.join(docs_dir, "index.html")) + html = open_file(op.join(dbt_docs_dir, "index.html")) # Hack the dbt docs to render properly in an iframe iframe_resizer_url = url_for(".static", filename="iframeResizer.contentWindow.min.js") html = html.replace("", f'{iframe_script}', 1) @@ -178,19 +176,17 @@ def dbt_docs_index(self) -> str: @expose("/catalog.json") # type: ignore[misc] @has_access([(permissions.ACTION_CAN_READ, permissions.RESOURCE_WEBSITE)]) def catalog(self) -> Tuple[str, int, Dict[str, Any]]: - docs_dir = conf.get("cosmos", "dbt_docs_dir", fallback=None) - if docs_dir is None: + if dbt_docs_dir is None: abort(404) - data = open_file(op.join(docs_dir, "catalog.json")) + data = open_file(op.join(dbt_docs_dir, "catalog.json")) return data, 200, {"Content-Type": "application/json"} @expose("/manifest.json") # type: ignore[misc] @has_access([(permissions.ACTION_CAN_READ, permissions.RESOURCE_WEBSITE)]) def manifest(self) -> Tuple[str, int, Dict[str, Any]]: - docs_dir = conf.get("cosmos", "dbt_docs_dir", fallback=None) - if docs_dir is None: + if dbt_docs_dir is None: abort(404) - data = open_file(op.join(docs_dir, "manifest.json")) + data = open_file(op.join(dbt_docs_dir, "manifest.json")) return data, 200, {"Content-Type": "application/json"} diff --git a/cosmos/settings.py b/cosmos/settings.py index 35e235edc1..44a08fd486 100644 --- a/cosmos/settings.py +++ b/cosmos/settings.py @@ -1,11 +1,21 @@ +import os import tempfile from pathlib import Path +import airflow from airflow.configuration import conf -from cosmos.constants import DEFAULT_COSMOS_CACHE_DIR_NAME +from cosmos.constants import DEFAULT_COSMOS_CACHE_DIR_NAME, DEFAULT_OPENLINEAGE_NAMESPACE # In MacOS users may want to set the envvar `TMPDIR` if they do not want the value of the temp directory to change DEFAULT_CACHE_DIR = Path(tempfile.gettempdir(), DEFAULT_COSMOS_CACHE_DIR_NAME) cache_dir = Path(conf.get("cosmos", "cache_dir", fallback=DEFAULT_CACHE_DIR) or DEFAULT_CACHE_DIR) enable_cache = conf.get("cosmos", "enable_cache", fallback=True) +propagate_logs = conf.getboolean("cosmos", "propagate_logs", fallback=True) +dbt_docs_dir = conf.get("cosmos", "dbt_docs_dir", fallback=None) +dbt_docs_conn_id = conf.get("cosmos", "dbt_docs_conn_id", fallback=None) + +try: + LINEAGE_NAMESPACE = conf.get("openlineage", "namespace") +except airflow.exceptions.AirflowConfigException: + LINEAGE_NAMESPACE = os.getenv("OPENLINEAGE_NAMESPACE", DEFAULT_OPENLINEAGE_NAMESPACE) diff --git a/docs/configuration/cosmos-conf.rst b/docs/configuration/cosmos-conf.rst new file mode 100644 index 0000000000..1d334884fa --- /dev/null +++ b/docs/configuration/cosmos-conf.rst @@ -0,0 +1,79 @@ +Cosmos Config +============= + +This page lists all available Airflow configurations that affect ``astronomer-cosmos`` Astronomer Cosmos behavior. They can be set in the ``airflow.cfg file`` or using environment variables. + +.. note:: + For more information, see `Setting Configuration Options `_. + +**Sections:** + +- [cosmos] +- [openlineage] + +[cosmos] +~~~~~~~~ + +.. _cache_dir: + +`cache_dir`_: + The directory used for caching Cosmos data. + + - Default: ``{TMPDIR}/cosmos_cache`` (where ``{TMPDIR}`` is the system temporary directory) + - Environment Variable: ``AIRFLOW__COSMOS__CACHE_DIR`` + +.. _enable_cache: + +`enable_cache`_: + Enable or disable caching of Cosmos data. + + - Default: ``True`` + - Environment Variable: ``AIRFLOW__COSMOS__ENABLE_CACHE`` + +.. _propagate_logs: + +`propagate_logs`_: + Whether to propagate logs in the Cosmos module. + + - Default: ``True`` + - Environment Variable: ``AIRFLOW__COSMOS__PROPAGATE_LOGS`` + +.. _dbt_docs_dir: + +`dbt_docs_dir`_: + The directory path for dbt documentation. + + - Default: ``None`` + - Environment Variable: ``AIRFLOW__COSMOS__DBT_DOCS_DIR`` + +.. _dbt_docs_conn_id: + +`dbt_docs_conn_id`_: + The connection ID for dbt documentation. + + - Default: ``None`` + - Environment Variable: ``AIRFLOW__COSMOS__DBT_DOCS_CONN_ID`` + +[openlineage] +~~~~~~~~~~~~~ + +.. _namespace: + +`namespace`_: + The OpenLineage namespace for tracking lineage. + + - Default: If not configured in Airflow configuration, it falls back to the environment variable ``OPENLINEAGE_NAMESPACE``, otherwise it uses ``DEFAULT_OPENLINEAGE_NAMESPACE``. + - Environment Variable: ``AIRFLOW__OPENLINEAGE__NAMESPACE`` + +.. note:: + For more information, see `Openlieage Configuration Options `_. + +Environment Variables +~~~~~~~~~~~~~~~~~~~~~ + +.. _LINEAGE_NAMESPACE: + +`LINEAGE_NAMESPACE`_: + The OpenLineage namespace for tracking lineage. + + - Default: If not configured in Airflow configuration, it falls back to the environment variable ``OPENLINEAGE_NAMESPACE``, otherwise it uses ``DEFAULT_OPENLINEAGE_NAMESPACE``. diff --git a/docs/configuration/index.rst b/docs/configuration/index.rst index ec69c1f528..fc34b993e0 100644 --- a/docs/configuration/index.rst +++ b/docs/configuration/index.rst @@ -14,6 +14,7 @@ Cosmos offers a number of configuration options to customize its behavior. For m Render Config Parsing Methods + Configuring in Airflow Configuring Lineage Generating Docs Hosting Docs diff --git a/tests/plugin/test_plugin.py b/tests/plugin/test_plugin.py index 25fcacbdab..796bfff8d0 100644 --- a/tests/plugin/test_plugin.py +++ b/tests/plugin/test_plugin.py @@ -71,6 +71,7 @@ def conf_get(section, key, *args, **kwargs): return original_conf_get(section, key, *args, **kwargs) monkeypatch.setattr(conf, "get", conf_get) + monkeypatch.setattr("cosmos.plugin.dbt_docs_dir", "path/to/docs/dir") response = app.get("/cosmos/dbt_docs") @@ -96,6 +97,7 @@ def conf_get(section, key, *args, **kwargs): return original_conf_get(section, key, *args, **kwargs) monkeypatch.setattr(conf, "get", conf_get) + monkeypatch.setattr("cosmos.plugin.dbt_docs_dir", "path/to/docs/dir") if artifact == "dbt_docs_index.html": mock_open_file.return_value = "" @@ -134,6 +136,7 @@ def conf_get(section, key, *args, **kwargs): return original_conf_get(section, key, *args, **kwargs) monkeypatch.setattr(conf, "get", conf_get) + monkeypatch.setattr("cosmos.plugin.dbt_docs_conn_id", "mock_conn_id") with patch.object(cosmos.plugin, open_file_callback) as mock_callback: mock_callback.return_value = "mock file contents" diff --git a/tests/test_log.py b/tests/test_log.py index c110c19181..75676d1ec6 100644 --- a/tests/test_log.py +++ b/tests/test_log.py @@ -1,7 +1,5 @@ import logging -from airflow.configuration import conf - from cosmos import get_provider_info from cosmos.log import get_logger @@ -17,10 +15,8 @@ def test_get_logger(): assert custom_string in custom_logger.handlers[0].formatter._fmt -def test_propagate_logs_conf(): - if not conf.has_section("cosmos"): - conf.add_section("cosmos") - conf.set("cosmos", "propagate_logs", "False") +def test_propagate_logs_conf(monkeypatch): + monkeypatch.setattr("cosmos.log.propagate_logs", False) custom_logger = get_logger("cosmos-log") assert custom_logger.propagate is False From 69221f1d14ac2366fd40961675c9797c1eadd75a Mon Sep 17 00:00:00 2001 From: Ryan Hatter <25823361+RNHTTR@users.noreply.github.com> Date: Tue, 21 May 2024 03:46:54 -0400 Subject: [PATCH 171/223] Update Dockerfile -- use latest Astro Runtime (#989) ## Description ## Related Issue(s) ## Breaking Change? ## Checklist - [ ] I have made corresponding changes to the documentation (if required) - [ ] I have added tests that prove my fix is effective or that my feature works --- dev/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev/Dockerfile b/dev/Dockerfile index b929be8b1c..07c46d3f28 100644 --- a/dev/Dockerfile +++ b/dev/Dockerfile @@ -1,4 +1,4 @@ -FROM quay.io/astronomer/astro-runtime:10.0.0-base +FROM quay.io/astronomer/astro-runtime:11.3.0-base USER root From 54cc66f86a4b993ba3e01342ec3e21aa731167d8 Mon Sep 17 00:00:00 2001 From: Ryan Hatter <25823361+RNHTTR@users.noreply.github.com> Date: Tue, 21 May 2024 03:56:49 -0400 Subject: [PATCH 172/223] Update astro.rst -- use latest Astro Runtime (#988) ## Description ## Related Issue(s) ## Breaking Change? ## Checklist - [ ] I have made corresponding changes to the documentation (if required) - [ ] I have added tests that prove my fix is effective or that my feature works --- docs/getting_started/astro.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/getting_started/astro.rst b/docs/getting_started/astro.rst index 816c6622a0..f7e9942bc5 100644 --- a/docs/getting_started/astro.rst +++ b/docs/getting_started/astro.rst @@ -25,7 +25,7 @@ Create a virtual environment in your ``Dockerfile`` using the sample below. Be s .. code-block:: docker - FROM quay.io/astronomer/astro-runtime:10.0.0 + FROM quay.io/astronomer/astro-runtime:11.3.0 # install dbt into a virtual environment RUN python -m venv dbt_venv && source dbt_venv/bin/activate && \ From 9dd4bd75dec9c2043a9fa9701ea4bc4944bf7ced Mon Sep 17 00:00:00 2001 From: Pankaj Koti Date: Wed, 22 May 2024 14:33:00 +0530 Subject: [PATCH 173/223] Drop support for Airflow 2.3 (#994) Airflow 2.3 was released more than 2 years ago on Apr 30, 2022 and few of the apache-airflow providers are known to no longer support it. We've observed cosmos users are not on Airflow 2.3 based on the analysis in https://github.com/astronomer/astronomer-cosmos/issues/963#issuecomment-2121165722 and hence, to avoid maintenance & support efforts for an older version, this PR drops Cosmos support for Airflow 2.3 --- .github/workflows/test.yml | 8 ++------ pyproject.toml | 4 ++-- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b57488f6f0..264baa93b8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -39,10 +39,8 @@ jobs: strategy: matrix: python-version: ["3.8", "3.9", "3.10", "3.11"] - airflow-version: ["2.3", "2.4", "2.5", "2.6", "2.7", "2.8", "2.9"] + airflow-version: ["2.4", "2.5", "2.6", "2.7", "2.8", "2.9"] exclude: - - python-version: "3.11" - airflow-version: "2.3" - python-version: "3.11" airflow-version: "2.4" steps: @@ -83,10 +81,8 @@ jobs: strategy: matrix: python-version: ["3.8", "3.9", "3.10", "3.11"] - airflow-version: ["2.3", "2.4", "2.5", "2.6", "2.7", "2.8", "2.9"] + airflow-version: ["2.4", "2.5", "2.6", "2.7", "2.8", "2.9"] exclude: - - python-version: "3.11" - airflow-version: "2.3" - python-version: "3.11" airflow-version: "2.4" services: diff --git a/pyproject.toml b/pyproject.toml index 8c29c68272..8044162fc6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ classifiers = [ dependencies = [ "aenum", "attrs", - "apache-airflow>=2.3.0", + "apache-airflow>=2.4.0", "importlib-metadata; python_version < '3.8'", "Jinja2>=3.0.0", "msgpack", @@ -133,7 +133,7 @@ pre-install-commands = ["sh scripts/test/pre-install-airflow.sh {matrix:airflow} [[tool.hatch.envs.tests.matrix]] python = ["3.8", "3.9", "3.10", "3.11"] -airflow = ["2.3", "2.4", "2.5", "2.6", "2.7", "2.8", "2.9"] +airflow = ["2.4", "2.5", "2.6", "2.7", "2.8", "2.9"] [tool.hatch.envs.tests.overrides] matrix.airflow.dependencies = [ From 5f46b4cef0d05fad412775fe56d995469aaf4e43 Mon Sep 17 00:00:00 2001 From: Volker Schiewe Date: Wed, 22 May 2024 15:57:32 +0200 Subject: [PATCH 174/223] Support for running dbt tasks in AWS EKS (#944) ## Description We are using MWAA in combination with EKS so that all our dags in airflow are running in our EKS. We would like to use the same setup with cosmos. ### What changes? - New AwsEksOperator classes (inheriting from KubernetesOperators) - Based on the original [EksOperator](https://github.com/apache/airflow/blob/main/airflow/providers/amazon/aws/operators/eks.py#L995) - Tests - Adjusted documentation ## Related Issue(s) - ## Breaking Change? No - only an additional feature ## Checklist - [x] I have made corresponding changes to the documentation (if required) - [x] I have added tests that prove my fix is effective or that my feature works --------- Co-authored-by: Pankaj Koti --- cosmos/constants.py | 1 + cosmos/converter.py | 1 + cosmos/operators/aws_eks.py | 131 +++++++++++++++++++++++ docs/getting_started/execution-modes.rst | 39 ++++++- pyproject.toml | 6 +- tests/operators/test_aws_eks.py | 97 +++++++++++++++++ 6 files changed, 273 insertions(+), 2 deletions(-) create mode 100644 cosmos/operators/aws_eks.py create mode 100644 tests/operators/test_aws_eks.py diff --git a/cosmos/constants.py b/cosmos/constants.py index 847820ff20..b356d5542e 100644 --- a/cosmos/constants.py +++ b/cosmos/constants.py @@ -56,6 +56,7 @@ class ExecutionMode(Enum): LOCAL = "local" DOCKER = "docker" KUBERNETES = "kubernetes" + AWS_EKS = "aws_eks" VIRTUALENV = "virtualenv" AZURE_CONTAINER_INSTANCE = "azure_container_instance" diff --git a/cosmos/converter.py b/cosmos/converter.py index 08a44b6766..5e415486ee 100644 --- a/cosmos/converter.py +++ b/cosmos/converter.py @@ -115,6 +115,7 @@ def validate_initial_user_config( """ if profile_config is None and execution_config.execution_mode not in ( ExecutionMode.KUBERNETES, + ExecutionMode.AWS_EKS, ExecutionMode.DOCKER, ): raise CosmosValueError(f"The profile_config is mandatory when using {execution_config.execution_mode}") diff --git a/cosmos/operators/aws_eks.py b/cosmos/operators/aws_eks.py new file mode 100644 index 0000000000..1800283783 --- /dev/null +++ b/cosmos/operators/aws_eks.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +from typing import Any, Sequence + +from airflow.exceptions import AirflowException +from airflow.providers.amazon.aws.hooks.eks import EksHook +from airflow.utils.context import Context + +from cosmos.operators.kubernetes import ( + DbtBuildKubernetesOperator, + DbtKubernetesBaseOperator, + DbtLSKubernetesOperator, + DbtRunKubernetesOperator, + DbtRunOperationKubernetesOperator, + DbtSeedKubernetesOperator, + DbtSnapshotKubernetesOperator, + DbtTestKubernetesOperator, +) + +DEFAULT_CONN_ID = "aws_default" +DEFAULT_NAMESPACE = "default" + + +class DbtAwsEksBaseOperator(DbtKubernetesBaseOperator): + template_fields: Sequence[str] = tuple( + { + "cluster_name", + "in_cluster", + "namespace", + "pod_name", + "aws_conn_id", + "region", + } + | set(DbtKubernetesBaseOperator.template_fields) + ) + + def __init__( + self, + cluster_name: str, + pod_name: str | None = None, + namespace: str | None = DEFAULT_NAMESPACE, + aws_conn_id: str = DEFAULT_CONN_ID, + region: str | None = None, + **kwargs: Any, + ) -> None: + self.cluster_name = cluster_name + self.pod_name = pod_name + self.namespace = namespace + self.aws_conn_id = aws_conn_id + self.region = region + super().__init__( + name=self.pod_name, + namespace=self.namespace, + **kwargs, + ) + # There is no need to manage the kube_config file, as it will be generated automatically. + # All Kubernetes parameters (except config_file) are also valid for the EksPodOperator. + if self.config_file: + raise AirflowException("The config_file is not an allowed parameter for the EksPodOperator.") + + def execute(self, context: Context) -> Any | None: # type: ignore + eks_hook = EksHook( + aws_conn_id=self.aws_conn_id, + region_name=self.region, + ) + with eks_hook.generate_config_file( + eks_cluster_name=self.cluster_name, pod_namespace=self.namespace + ) as self.config_file: + return super().execute(context) + + +class DbtBuildAwsEksOperator(DbtAwsEksBaseOperator, DbtBuildKubernetesOperator): + """ + Executes a dbt core build command. + """ + + template_fields: Sequence[str] = ( + DbtAwsEksBaseOperator.template_fields + DbtBuildKubernetesOperator.template_fields # type: ignore[operator] + ) + + +class DbtLSAwsEksOperator(DbtAwsEksBaseOperator, DbtLSKubernetesOperator): + """ + Executes a dbt core ls command. + """ + + +class DbtSeedAwsEksOperator(DbtAwsEksBaseOperator, DbtSeedKubernetesOperator): + """ + Executes a dbt core seed command. + """ + + template_fields: Sequence[str] = ( + DbtAwsEksBaseOperator.template_fields + DbtSeedKubernetesOperator.template_fields # type: ignore[operator] + ) + + +class DbtSnapshotAwsEksOperator(DbtAwsEksBaseOperator, DbtSnapshotKubernetesOperator): + """ + Executes a dbt core snapshot command. + """ + + +class DbtRunAwsEksOperator(DbtAwsEksBaseOperator, DbtRunKubernetesOperator): + """ + Executes a dbt core run command. + """ + + template_fields: Sequence[str] = ( + DbtAwsEksBaseOperator.template_fields + DbtRunKubernetesOperator.template_fields # type: ignore[operator] + ) + + +class DbtTestAwsEksOperator(DbtAwsEksBaseOperator, DbtTestKubernetesOperator): + """ + Executes a dbt core test command. + """ + + template_fields: Sequence[str] = ( + DbtAwsEksBaseOperator.template_fields + DbtTestKubernetesOperator.template_fields # type: ignore[operator] + ) + + +class DbtRunOperationAwsEksOperator(DbtAwsEksBaseOperator, DbtRunOperationKubernetesOperator): + """ + Executes a dbt core run-operation command. + """ + + template_fields: Sequence[str] = ( + DbtAwsEksBaseOperator.template_fields + DbtRunOperationKubernetesOperator.template_fields # type: ignore[operator] + ) diff --git a/docs/getting_started/execution-modes.rst b/docs/getting_started/execution-modes.rst index 1765144d99..1b1a35cb92 100644 --- a/docs/getting_started/execution-modes.rst +++ b/docs/getting_started/execution-modes.rst @@ -9,7 +9,8 @@ Cosmos can run ``dbt`` commands using five different approaches, called ``execut 2. **virtualenv**: Run ``dbt`` commands from Python virtual environments managed by Cosmos 3. **docker**: Run ``dbt`` commands from Docker containers managed by Cosmos (requires a pre-existing Docker image) 4. **kubernetes**: Run ``dbt`` commands from Kubernetes Pods managed by Cosmos (requires a pre-existing Docker image) -5. **azure_container_instance**: Run ``dbt`` commands from Azure Container Instances managed by Cosmos (requires a pre-existing Docker image) +5. **aws_eks**: Run ``dbt`` commands from AWS EKS Pods managed by Cosmos (requires a pre-existing Docker image) +6. **azure_container_instance**: Run ``dbt`` commands from Azure Container Instances managed by Cosmos (requires a pre-existing Docker image) The choice of the ``execution mode`` can vary based on each user's needs and concerns. For more details, check each execution mode described below. @@ -38,6 +39,10 @@ The choice of the ``execution mode`` can vary based on each user's needs and con - Slow - High - No + * - AWS_EKS + - Slow + - High + - No * - Azure Container Instance - Slow - High @@ -159,6 +164,38 @@ Example DAG: "secrets": [postgres_password_secret], }, ) +AWS_EKS +---------- + +The ``aws_eks`` approach is very similar to the ``kubernetes`` approach, but it is specifically designed to run on AWS EKS clusters. +It uses the `EKSPodOperator `_ +to run the dbt commands. You need to provide the ``cluster_name`` in your operator_args to connect to the AWS EKS cluster. + + +Example DAG: + +.. code-block:: python + + postgres_password_secret = Secret( + deploy_type="env", + deploy_target="POSTGRES_PASSWORD", + secret="postgres-secrets", + key="password", + ) + + docker_cosmos_dag = DbtDag( + # ... + execution_config=ExecutionConfig( + execution_mode=ExecutionMode.AWS_EKS, + ), + operator_args={ + "image": "dbt-jaffle-shop:1.0.0", + "cluster_name": CLUSTER_NAME, + "get_logs": True, + "is_delete_operator_pod": False, + "secrets": [postgres_password_secret], + }, + ) Azure Container Instance ------------------------ diff --git a/pyproject.toml b/pyproject.toml index 8044162fc6..cb2530a5a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,6 +87,9 @@ docker = [ kubernetes = [ "apache-airflow-providers-cncf-kubernetes>=5.1.1", ] +aws_eks = [ + "apache-airflow-providers-amazon>=8.0.0,<8.20.0", # https://github.com/apache/airflow/issues/39103 +] azure-container-instance = [ "apache-airflow-providers-microsoft-azure>=8.4.0", ] @@ -120,6 +123,7 @@ dependencies = [ "astronomer-cosmos[tests]", "apache-airflow-providers-postgres", "apache-airflow-providers-cncf-kubernetes>=5.1.1", + "apache-airflow-providers-amazon>=3.0.0,<8.20.0", # https://github.com/apache/airflow/issues/39103 "apache-airflow-providers-docker>=3.5.0", "apache-airflow-providers-microsoft-azure", "types-PyYAML", @@ -137,7 +141,7 @@ airflow = ["2.4", "2.5", "2.6", "2.7", "2.8", "2.9"] [tool.hatch.envs.tests.overrides] matrix.airflow.dependencies = [ - { value = "typing_extensions<4.6", if = ["2.6"] }, + { value = "typing_extensions<4.6", if = ["2.6"] } ] [tool.hatch.envs.tests.scripts] diff --git a/tests/operators/test_aws_eks.py b/tests/operators/test_aws_eks.py new file mode 100644 index 0000000000..35717a0617 --- /dev/null +++ b/tests/operators/test_aws_eks.py @@ -0,0 +1,97 @@ +from unittest.mock import MagicMock, patch + +import pytest +from airflow.exceptions import AirflowException + +from cosmos.operators.aws_eks import ( + DbtBuildAwsEksOperator, + DbtLSAwsEksOperator, + DbtRunAwsEksOperator, + DbtSeedAwsEksOperator, + DbtTestAwsEksOperator, +) + + +@pytest.fixture() +def mock_kubernetes_execute(): + with patch("cosmos.operators.kubernetes.KubernetesPodOperator.execute") as mock_execute: + yield mock_execute + + +base_kwargs = { + "conn_id": "my_airflow_connection", + "cluster_name": "my-cluster", + "task_id": "my-task", + "image": "my_image", + "project_dir": "my/dir", + "vars": { + "start_time": "{{ data_interval_start.strftime('%Y%m%d%H%M%S') }}", + "end_time": "{{ data_interval_end.strftime('%Y%m%d%H%M%S') }}", + }, + "no_version_check": True, +} + + +def test_dbt_kubernetes_build_command(): + """ + Since we know that the KubernetesOperator is tested, we can just test that the + command is built correctly and added to the "arguments" parameter. + """ + + result_map = { + "ls": DbtLSAwsEksOperator(**base_kwargs), + "run": DbtRunAwsEksOperator(**base_kwargs), + "test": DbtTestAwsEksOperator(**base_kwargs), + "build": DbtBuildAwsEksOperator(**base_kwargs), + "seed": DbtSeedAwsEksOperator(**base_kwargs), + } + + for command_name, command_operator in result_map.items(): + command_operator.build_kube_args(context=MagicMock(), cmd_flags=MagicMock()) + assert command_operator.arguments == [ + "dbt", + command_name, + "--vars", + "end_time: '{{ data_interval_end.strftime(''%Y%m%d%H%M%S'') }}'\n" + "start_time: '{{ data_interval_start.strftime(''%Y%m%d%H%M%S'') }}'\n", + "--no-version-check", + "--project-dir", + "my/dir", + ] + + +@patch("cosmos.operators.kubernetes.DbtKubernetesBaseOperator.build_kube_args") +@patch("cosmos.operators.aws_eks.EksHook.generate_config_file") +def test_dbt_kubernetes_operator_execute(mock_generate_config_file, mock_build_kube_args, mock_kubernetes_execute): + """Tests that the execute method call results in both the build_kube_args method and the kubernetes execute method being called.""" + operator = DbtLSAwsEksOperator( + conn_id="my_airflow_connection", + cluster_name="my-cluster", + task_id="my-task", + image="my_image", + project_dir="my/dir", + ) + operator.execute(context={}) + # Assert that the build_kube_args method was called in the execution + mock_build_kube_args.assert_called_once() + + # Assert that the generate_config_file method was called in the execution to create the kubeconfig for eks + mock_generate_config_file.assert_called_once_with(eks_cluster_name="my-cluster", pod_namespace="default") + + # Assert that the kubernetes execute method was called in the execution + mock_kubernetes_execute.assert_called_once() + assert mock_kubernetes_execute.call_args.args[-1] == {} + + +def test_provided_config_file_fails(): + """Tests that the constructor fails if it is called with a config_file.""" + with pytest.raises(AirflowException) as err_context: + DbtLSAwsEksOperator( + conn_id="my_airflow_connection", + cluster_name="my-cluster", + task_id="my-task", + image="my_image", + project_dir="my/dir", + config_file="my/config", + ) + assert "The config_file is not an allowed parameter for the EksPodOperator." in str(err_context.value) From 9b48443f9be12326fbe4ba0b486c7a5d1f89dbdf Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Wed, 29 May 2024 09:56:20 +0100 Subject: [PATCH 175/223] Fix CI issues (#1005) Fix three problems observed while running the tests in the CI recently: 1. Static check Fix spelling issue captured by static check (#1000) 2. Integration test that's no longer needed The issue we were trying to capture for no longer happens in the latest version of the Apache Airflow provider `apache-airflow-providers-postgres==5.11.1rc1`: https://github.com/apache/airflow/issues/39842 3. Skip a buggy version of OL There was a breaking change between `openlineage-integration-common==1.14.0` and `openlineage-integration-common==1.15.0` . It may have been an unintended side-effect of https://github.com/OpenLineage/OpenLineage/pull/2693. This is an example of how Cosmos was using `DbtLocalArtifactProcessor` - something that had been agreed upon in the past: ``` openlineage_processor = DbtLocalArtifactProcessor( producer=OPENLINEAGE_PRODUCER, job_namespace=LINEAGE_NAMESPACE, project_dir=project_dir, profile_name=self.profile_config.profile_name, target=self.profile_config.target_name, ) events = openlineage_processor.parse() for completed in events.completes: for output in getattr(completed, source): dataset_uri = output.namespace + "/" + output.name uris.append(dataset_uri) ``` In `openlineage-integration-common==1.14.0` and earlier versions, this would create URIs in the format: ``` postgres://0.0.0.0:5432/postgres.public.stg_customers ``` Since openlineage-integration-common==1.15.0 , this leads to URIs being created in the format: ``` postgres.public.stg_customers/postgres://0.0.0.0:5432 ``` This was fixed in https://github.com/OpenLineage/OpenLineage/pull/2735 and released as part of OL 1.16: https://github.com/OpenLineage/OpenLineage/releases/tag/1.16.0 --- CODE_OF_CONDUCT.md | 2 +- pyproject.toml | 4 ++-- tests/operators/test_local.py | 43 ----------------------------------- 3 files changed, 3 insertions(+), 46 deletions(-) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index aad2b30712..b3378c60a2 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -5,7 +5,7 @@ We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender -identity and expression, level of experience, education, socio-economic status, +identity and expression, level of experience, education, socioeconomic status, nationality, personal appearance, race, religion, or sexual identity and orientation. diff --git a/pyproject.toml b/pyproject.toml index cb2530a5a5..8cf43a98e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,7 +59,7 @@ dbt-redshift = ["dbt-redshift"] dbt-snowflake = ["dbt-snowflake"] dbt-spark = ["dbt-spark"] dbt-vertica = ["dbt-vertica<=1.5.4"] -openlineage = ["openlineage-integration-common", "openlineage-airflow"] +openlineage = ["openlineage-integration-common!=1.15.0", "openlineage-airflow"] all = ["astronomer-cosmos[dbt-all]", "astronomer-cosmos[openlineage]"] docs = [ "sphinx", @@ -121,11 +121,11 @@ packages = ["/cosmos"] [tool.hatch.envs.tests] dependencies = [ "astronomer-cosmos[tests]", - "apache-airflow-providers-postgres", "apache-airflow-providers-cncf-kubernetes>=5.1.1", "apache-airflow-providers-amazon>=3.0.0,<8.20.0", # https://github.com/apache/airflow/issues/39103 "apache-airflow-providers-docker>=3.5.0", "apache-airflow-providers-microsoft-azure", + "apache-airflow-providers-postgres", "types-PyYAML", "types-attrs", "types-requests", diff --git a/tests/operators/test_local.py b/tests/operators/test_local.py index 0f35705b6a..14ed66027d 100644 --- a/tests/operators/test_local.py +++ b/tests/operators/test_local.py @@ -23,7 +23,6 @@ parse_number_of_warnings_dbt_runner, parse_number_of_warnings_subprocess, ) -from cosmos.exceptions import AirflowCompatibilityError from cosmos.operators.local import ( DbtBuildLocalOperator, DbtDocsAzureStorageLocalOperator, @@ -443,48 +442,6 @@ def test_run_operator_dataset_inlets_and_outlets(caplog): assert test_operator.outlets == [] -@pytest.mark.skipif( - version.parse(airflow_version) not in PARTIALLY_SUPPORTED_AIRFLOW_VERSIONS, - reason="Airflow 2.9.0 and 2.9.1 have a breaking change in Dataset URIs", - # https://github.com/apache/airflow/issues/39486 -) -@pytest.mark.integration -def test_run_operator_dataset_emission_fails(caplog): - - with DAG("test-id-1", start_date=datetime(2022, 1, 1)) as dag: - seed_operator = DbtSeedLocalOperator( - profile_config=real_profile_config, - project_dir=DBT_PROJ_DIR, - task_id="seed", - dbt_cmd_flags=["--select", "raw_customers"], - install_deps=True, - append_env=True, - ) - run_operator = DbtRunLocalOperator( - profile_config=real_profile_config, - project_dir=DBT_PROJ_DIR, - task_id="run", - dbt_cmd_flags=["--models", "stg_customers"], - install_deps=True, - append_env=True, - ) - - seed_operator >> run_operator - - with pytest.raises(AirflowCompatibilityError) as exc: - run_test_dag(dag) - - err_msg = str(exc.value) - assert ( - "Apache Airflow 2.9.0 & 2.9.1 introduced a breaking change in Dataset URIs, to be fixed in newer versions" - in err_msg - ) - assert ( - "If you want to use Cosmos with one of these Airflow versions, you will have to disable emission of Datasets" - in err_msg - ) - - @pytest.mark.skipif( version.parse(airflow_version) not in PARTIALLY_SUPPORTED_AIRFLOW_VERSIONS, reason="Airflow 2.9.0 and 2.9.1 have a breaking change in Dataset URIs", From f6dcdc9cade5e06c1a7217568606572c8e8b23ce Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 29 May 2024 10:43:59 +0100 Subject: [PATCH 176/223] =?UTF-8?q?=E2=AC=86=20[pre-commit.ci]=20pre-commi?= =?UTF-8?q?t=20autoupdate=20(#1000)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/codespell-project/codespell: v2.2.6 → v2.3.0](https://github.com/codespell-project/codespell/compare/v2.2.6...v2.3.0) - [github.com/astral-sh/ruff-pre-commit: v0.4.4 → v0.4.5](https://github.com/astral-sh/ruff-pre-commit/compare/v0.4.4...v0.4.5) Additionally, given the latest `apache-airflow-providers-postgres==5.11.1rc1`, mentioned in https://github.com/apache/airflow/issues/39842, it no longer validates the Dataset URI as it used to - so the test case we had in Cosmos is no longer relevant. Co-authored-by: Tatiana Al-Chueyr --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 118ba915b0..18c22f889f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -25,7 +25,7 @@ repos: args: ["--autofix"] - id: trailing-whitespace - repo: https://github.com/codespell-project/codespell - rev: v2.2.6 + rev: v2.3.0 hooks: - id: codespell name: Run codespell to check for common misspellings in files @@ -54,7 +54,7 @@ repos: - --py37-plus - --keep-runtime-typing - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.4.4 + rev: v0.4.5 hooks: - id: ruff args: From ef2df7bde5b2f3831cb21472b77ec06a66c70ba3 Mon Sep 17 00:00:00 2001 From: Daniel Reeves <31971762+dwreeves@users.noreply.github.com> Date: Wed, 29 May 2024 05:53:39 -0400 Subject: [PATCH 177/223] Fix `dev/Dockerfile` + Add `uv pip install` for faster build time (#997) ## Fix dev Dockerfile The Dockerfile was not building because `dbt-postgres==1.8.0` pinned `psycopg2`. To resolve this, you need to apt-get install `build-essential` and `libpq-dev`. I added a `RUN` directive that does that. This is placed near the top as it makes the most sense for layering purposes. ## Add uv While I was at it, I added uv: https://github.com/astral-sh/uv tldr: this makes the Dockerfile build a little faster. This speed is especially important due to how the Dockerfile will typically be used: if a user makes a change to the `cosmos/` directory and then wants to test their code, due to how the layers are stacked, the pip install will need to be re-run and resolved on over 100 Python packages on each code change. --- dev/Dockerfile | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/dev/Dockerfile b/dev/Dockerfile index 07c46d3f28..cc6307f36a 100644 --- a/dev/Dockerfile +++ b/dev/Dockerfile @@ -2,12 +2,22 @@ FROM quay.io/astronomer/astro-runtime:11.3.0-base USER root + +# dbt-postgres 1.8.0 requires building psycopg2 from source +RUN /bin/sh -c set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends build-essential libpq-dev; \ + apt-get clean; \ + rm -rf /var/lib/apt/lists/* + +RUN pip install -U uv + COPY ./pyproject.toml ${AIRFLOW_HOME}/astronomer_cosmos/ COPY ./README.rst ${AIRFLOW_HOME}/astronomer_cosmos/ COPY ./cosmos/ ${AIRFLOW_HOME}/astronomer_cosmos/cosmos/ # install the package in editable mode -RUN pip install -e "${AIRFLOW_HOME}/astronomer_cosmos"[dbt-postgres,dbt-databricks] +RUN uv pip install --system -e "${AIRFLOW_HOME}/astronomer_cosmos"[dbt-postgres,dbt-databricks] # make sure astro user owns the package RUN chown -R astro:astro ${AIRFLOW_HOME}/astronomer_cosmos From 58087e4a5108174b730f3d5fc78ee8af5f067f57 Mon Sep 17 00:00:00 2001 From: Pankaj Koti Date: Fri, 31 May 2024 14:50:07 +0530 Subject: [PATCH 178/223] Add GitHub issue templates for bug reports and feature requests (#1009) We currently do not have GitHub issue templates when the community creates issues. Having issue templates would aid us in catching relevant information that would help in faster resolution of those issues reported. With this PR, we're are adding issue templates for bug reports and feature requests. closes: #977 --- .github/ISSUE_TEMPLATE/01-bug.yml | 145 ++++++++++++++++++++++++++ .github/ISSUE_TEMPLATE/02-feature.yml | 33 ++++++ 2 files changed, 178 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/01-bug.yml create mode 100644 .github/ISSUE_TEMPLATE/02-feature.yml diff --git a/.github/ISSUE_TEMPLATE/01-bug.yml b/.github/ISSUE_TEMPLATE/01-bug.yml new file mode 100644 index 0000000000..c8d6492fab --- /dev/null +++ b/.github/ISSUE_TEMPLATE/01-bug.yml @@ -0,0 +1,145 @@ +--- +name: Bug Report +description: File a bug report. +title: "[Bug]: " +labels: ["bug", "triage-needed"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! + - type: dropdown + id: cosmos-version + attributes: + label: Astronomer Cosmos Version + description: What version of Astronomer Cosmos are you running? If you do not see your version in the list, please (ideally) test on + the latest release or main to see if the issue is fixed before reporting it. + options: + - "1.4.1" + - "main (development)" + - "Other Astronomer Cosmos version (please specify below)" + multiple: false + validations: + required: true + - type: input + attributes: + label: If "Other Astronomer Cosmos version" selected, which one? + # yamllint disable rule:line-length + description: > + On what version of Astronomer Cosmos are you currently experiencing the issue? Remember, you are encouraged to + test with the latest release or on the main branch to verify your issue still exists. + - type: input + id: dbt-core-version + attributes: + label: dbt-core version + description: What version of dbt-core are you running? + placeholder: ex. 1.8.0 + validations: + required: true + - type: textarea + attributes: + label: Versions of dbt adapters + description: What dbt adapter versions are you using? + placeholder: You can use `pip freeze | grep dbt` (you can leave only relevant ones) + - type: input + id: airflow-version + attributes: + label: airflow version + description: What version of Apache Airflow are you running? + placeholder: ex. 2.9.0 + validations: + required: true + - type: input + attributes: + label: Operating System + description: What Operating System are you using? + placeholder: "You can get it via `cat /etc/os-release` for example" + validations: + required: true + - type: dropdown + id: browsers + attributes: + label: If a you think it's an UI issue, what browsers are you seeing the problem on? + multiple: true + options: + - Firefox + - Chrome + - Safari + - Microsoft Edge + - type: dropdown + attributes: + label: Deployment + description: > + What kind of deployment do you have? + multiple: false + options: + - "Official Apache Airflow Helm Chart" + - "Other 3rd-party Helm chart" + - "Docker-Compose" + - "Other Docker-based deployment" + - "Virtualenv installation" + - "Astronomer" + - "Google Cloud Composer" + - "Amazon (AWS) MWAA" + - "Microsoft ADF Managed Airflow" + - "Other" + validations: + required: true + - type: textarea + attributes: + label: Deployment details + description: Additional description of your deployment. + placeholder: > + Enter any relevant details of your deployment. Especially version of your tools, + software (docker-compose, helm, k8s, etc.), any customisation and configuration you added. + - type: textarea + id: what-happened + attributes: + label: What happened? + description: Also tell us, what did you expect to happen? + placeholder: Tell us what you see! + value: "A bug happened!" + validations: + required: true + - type: textarea + id: logs + attributes: + label: Relevant log output + description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. + render: shell + - type: textarea + attributes: + label: How to reproduce + description: What should we do to reproduce the problem? + placeholder: > + Please make sure you provide a reproducible step-by-step case of how to reproduce the problem + as minimally and precisely as possible. Keep in mind we do not have access to your cluster or DAGs. + Remember that non-reproducible issues make it hard for us to help you or resolve the issue! + validations: + required: true + - type: textarea + attributes: + label: Anything else :)? + description: Anything else we need to know? + placeholder: > + How often does this problem occur? (Once? Every time? Only when certain conditions are met?) + - type: checkboxes + attributes: + label: Are you willing to submit PR? + description: > + This is absolutely not required, but we are happy to guide you in the contribution process + especially if you already have a good understanding of how to implement the fix. We love to bring new + contributors in. + options: + - label: Yes I am willing to submit a PR! + - type: input + id: contact + attributes: + label: Contact Details + description: (Optional) How can we get in touch with you if we need more info? + placeholder: ex. email@example.com + validations: + required: false + - type: markdown + attributes: + value: "Thanks for completing our form!" diff --git a/.github/ISSUE_TEMPLATE/02-feature.yml b/.github/ISSUE_TEMPLATE/02-feature.yml new file mode 100644 index 0000000000..e179d357db --- /dev/null +++ b/.github/ISSUE_TEMPLATE/02-feature.yml @@ -0,0 +1,33 @@ +--- +name: Feature request +description: Suggest an idea for this project +labels: ["enhancement", "needs-triage"] +body: + - type: markdown + attributes: + # yamllint disable rule:line-length + value: " + Thank you for finding the time to propose new feature! + + We really appreciate the community efforts to improve Cosmos." + # yamllint enable rule:line-length + - type: textarea + attributes: + label: Description + description: A short description of your feature + - type: textarea + attributes: + label: Use case/motivation + description: What would you like to happen? + - type: textarea + attributes: + label: Related issues + description: Is there currently another issue associated with this? + - type: checkboxes + attributes: + label: Are you willing to submit a PR? + options: + - label: Yes, I am willing to submit a PR! + - type: markdown + attributes: + value: "Thanks for completing our form!" From 7f9d1c1dd322f295e8fda503be2def870ec4769e Mon Sep 17 00:00:00 2001 From: davidsteinar Date: Fri, 31 May 2024 14:22:40 +0200 Subject: [PATCH 179/223] Improve error logging in DbtLocalBaseOperator (#1004) Improve error logging when the `dbt` command returns a non-zero exit code. Instead of raising an `AirflowException` with the full output, log the output using the logger and then raise the exception with a concise error message. This makes the dbt output more readable and not in a single line as AirflowException logs message in a single line, and it can get very long. closes #1003 --------- Co-authored-by: David Steinar Asgrimsson Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- cosmos/operators/local.py | 6 ++---- tests/operators/test_local.py | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/cosmos/operators/local.py b/cosmos/operators/local.py index ca255c7d83..703b1fb1ee 100644 --- a/cosmos/operators/local.py +++ b/cosmos/operators/local.py @@ -179,10 +179,8 @@ def handle_exception_subprocess(self, result: FullOutputSubprocessResult) -> Non if self.skip_exit_code is not None and result.exit_code == self.skip_exit_code: raise AirflowSkipException(f"dbt command returned exit code {self.skip_exit_code}. Skipping.") elif result.exit_code != 0: - raise AirflowException( - f"dbt command failed. The command returned a non-zero exit code {result.exit_code}. Details: ", - *result.full_output, - ) + logger.error("\n".join(result.full_output)) + raise AirflowException(f"dbt command failed. The command returned a non-zero exit code {result.exit_code}.") def handle_exception_dbt_runner(self, result: dbtRunnerResult) -> None: """dbtRunnerResult has an attribute `success` that is False if the command failed.""" diff --git a/tests/operators/test_local.py b/tests/operators/test_local.py index 14ed66027d..5513b1c4b2 100644 --- a/tests/operators/test_local.py +++ b/tests/operators/test_local.py @@ -23,6 +23,7 @@ parse_number_of_warnings_dbt_runner, parse_number_of_warnings_subprocess, ) +from cosmos.hooks.subprocess import FullOutputSubprocessResult from cosmos.operators.local import ( DbtBuildLocalOperator, DbtDocsAzureStorageLocalOperator, @@ -914,3 +915,22 @@ def test_dbt_local_operator_on_kill_sigterm(mock_send_sigterm) -> None: dbt_base_operator.on_kill() mock_send_sigterm.assert_called_once() + + +def test_handle_exception_subprocess(caplog): + """ + Test the handle_exception_subprocess method of the DbtLocalBaseOperator class for non-zero dbt exit code. + """ + operator = ConcreteDbtLocalBaseOperator( + profile_config=None, + task_id="my-task", + project_dir="my/dir", + ) + result = FullOutputSubprocessResult(exit_code=1, output="test", full_output=["n" * n for n in range(1, 1000)]) + + caplog.set_level(logging.ERROR) + # Test when exit_code is non-zero + with pytest.raises(AirflowException) as err_context: + operator.handle_exception_subprocess(result) + assert len(str(err_context.value)) < 100 # Ensure the error message is not too long + assert len(caplog.text) > 1000 # Ensure the log message is not truncated From c7df647595626b624b799229813cf91c5ff9dd0f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 4 Jun 2024 02:28:31 +0530 Subject: [PATCH 180/223] =?UTF-8?q?=E2=AC=86=20[pre-commit.ci]=20pre-commi?= =?UTF-8?q?t=20autoupdate=20(#1019)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.4.5 → v0.4.7](https://github.com/astral-sh/ruff-pre-commit/compare/v0.4.5...v0.4.7) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 18c22f889f..ad4bb2c650 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -54,7 +54,7 @@ repos: - --py37-plus - --keep-runtime-typing - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.4.5 + rev: v0.4.7 hooks: - id: ruff args: From 3fbe6312bfd72ab31866a950f1fad73e70f0501d Mon Sep 17 00:00:00 2001 From: Marco Yuen <33394715+marco9663@users.noreply.github.com> Date: Tue, 4 Jun 2024 18:46:54 -0400 Subject: [PATCH 181/223] Allow setting invocation mode when using `ExecutionMode.VIRTUALENV` (#1023) - To allow setting the invocation mode to `InvocationMode.SUBPROCESS` when using `ExecutionMode.VIRTUALENV`. This should unblock - Automatically setting the invocation mode to `InvocationMode.SUBPROCESS` if user does not specify the invocation mode when using `ExecutionMode.VIRTUALENV` because it is the only supported invocation mode. Closes: 998 --- cosmos/config.py | 16 +++++++++-- docs/getting_started/execution-modes.rst | 1 + tests/test_config.py | 35 ++++++++++++++++++------ 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/cosmos/config.py b/cosmos/config.py index 64a7acd089..820833e6c9 100644 --- a/cosmos/config.py +++ b/cosmos/config.py @@ -319,6 +319,18 @@ class ExecutionConfig: project_path: Path | None = field(init=False) def __post_init__(self, dbt_project_path: str | Path | None) -> None: - if self.invocation_mode and self.execution_mode != ExecutionMode.LOCAL: - raise CosmosValueError("ExecutionConfig.invocation_mode is only configurable for ExecutionMode.LOCAL.") + if self.invocation_mode and self.execution_mode not in (ExecutionMode.LOCAL, ExecutionMode.VIRTUALENV): + raise CosmosValueError( + "ExecutionConfig.invocation_mode is only configurable for ExecutionMode.LOCAL and ExecutionMode.VIRTUALENV." + ) + if self.execution_mode == ExecutionMode.VIRTUALENV: + if self.invocation_mode == InvocationMode.DBT_RUNNER: + raise CosmosValueError( + "InvocationMode.DBT_RUNNER has not been implemented for ExecutionMode.VIRTUALENV" + ) + elif self.invocation_mode is None: + logger.debug( + "Defaulting to InvocationMode.SUBPROCESS as it is the only supported invocation mode for ExecutionMode.VIRTUALENV" + ) + self.invocation_mode = InvocationMode.SUBPROCESS self.project_path = Path(dbt_project_path) if dbt_project_path else None diff --git a/docs/getting_started/execution-modes.rst b/docs/getting_started/execution-modes.rst index 1b1a35cb92..266b40f323 100644 --- a/docs/getting_started/execution-modes.rst +++ b/docs/getting_started/execution-modes.rst @@ -92,6 +92,7 @@ Some drawbacks of this approach: - It is slower than ``local`` because it creates a new Python virtual environment for each Cosmos dbt task run. - If dbt is unavailable in the Airflow scheduler, the default ``LoadMode.DBT_LS`` will not work. In this scenario, users must use a `parsing method `_ that does not rely on dbt, such as ``LoadMode.MANIFEST``. +- Only ``InvocationMode.SUBPROCESS`` is supported currently, attempt to use ``InvocationMode.DBT_RUNNER`` will raise error. Example of how to use: diff --git a/tests/test_config.py b/tests/test_config.py index acca546beb..d7dc24cbe6 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -199,15 +199,34 @@ def test_render_config_env_vars_deprecated(): @pytest.mark.parametrize( - "execution_mode, expectation", + "execution_mode, invocation_mode, expectation", [ - (ExecutionMode.LOCAL, does_not_raise()), - (ExecutionMode.VIRTUALENV, pytest.raises(CosmosValueError)), - (ExecutionMode.KUBERNETES, pytest.raises(CosmosValueError)), - (ExecutionMode.DOCKER, pytest.raises(CosmosValueError)), - (ExecutionMode.AZURE_CONTAINER_INSTANCE, pytest.raises(CosmosValueError)), + (ExecutionMode.LOCAL, InvocationMode.DBT_RUNNER, does_not_raise()), + (ExecutionMode.LOCAL, InvocationMode.SUBPROCESS, does_not_raise()), + (ExecutionMode.LOCAL, None, does_not_raise()), + (ExecutionMode.VIRTUALENV, InvocationMode.DBT_RUNNER, pytest.raises(CosmosValueError)), + (ExecutionMode.VIRTUALENV, InvocationMode.SUBPROCESS, does_not_raise()), + (ExecutionMode.VIRTUALENV, None, does_not_raise()), + (ExecutionMode.KUBERNETES, InvocationMode.DBT_RUNNER, pytest.raises(CosmosValueError)), + (ExecutionMode.DOCKER, InvocationMode.DBT_RUNNER, pytest.raises(CosmosValueError)), + (ExecutionMode.AZURE_CONTAINER_INSTANCE, InvocationMode.DBT_RUNNER, pytest.raises(CosmosValueError)), ], ) -def test_execution_config_with_invocation_option(execution_mode, expectation): +def test_execution_config_with_invocation_option(execution_mode, invocation_mode, expectation): with expectation: - ExecutionConfig(execution_mode=execution_mode, invocation_mode=InvocationMode.DBT_RUNNER) + ExecutionConfig(execution_mode=execution_mode, invocation_mode=invocation_mode) + + +@pytest.mark.parametrize( + "execution_mode, expected_invocation_mode", + [ + (ExecutionMode.LOCAL, None), + (ExecutionMode.VIRTUALENV, InvocationMode.SUBPROCESS), + (ExecutionMode.KUBERNETES, None), + (ExecutionMode.DOCKER, None), + (ExecutionMode.AZURE_CONTAINER_INSTANCE, None), + ], +) +def test_execution_config_default_config(execution_mode, expected_invocation_mode): + execution_config = ExecutionConfig(execution_mode=execution_mode) + assert execution_config.invocation_mode == expected_invocation_mode From fe572b7e99a76906ba2a39f43515c050326a0d1f Mon Sep 17 00:00:00 2001 From: Pankaj Singh <98807258+pankajastro@users.noreply.github.com> Date: Wed, 5 Jun 2024 12:21:56 +0530 Subject: [PATCH 182/223] Enable unit tests for Python3.12 (#1018) ## Description This PR enable CI to run unit tests for Python3.12 CI Job: https://github.com/astronomer/astronomer-cosmos/actions/runs/9357004387/job/25755741232?pr=1018 ## Related Issue(s) closes: https://github.com/astronomer/astronomer-cosmos/issues/964 ## Breaking Change? ## Checklist - [ ] I have made corresponding changes to the documentation (if required) - [ ] I have added tests that prove my fix is effective or that my feature works --- .github/workflows/test.yml | 16 +++++++++++++++- pyproject.toml | 3 ++- tests/operators/test_base.py | 19 +++++++++++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 264baa93b8..e38cd71570 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,11 +38,25 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] airflow-version: ["2.4", "2.5", "2.6", "2.7", "2.8", "2.9"] exclude: - python-version: "3.11" airflow-version: "2.4" + # Apache Airflow versions prior to 2.9.0 have not been tested with Python 3.12. + # Official support for Python 3.12 and the corresponding constraints.txt are available only for Apache Airflow >= 2.9.0. + # See: https://github.com/apache/airflow/tree/2.9.0?tab=readme-ov-file#requirements + # See: https://github.com/apache/airflow/tree/2.8.4?tab=readme-ov-file#requirements + - python-version: "3.12" + airflow-version: "2.4" + - python-version: "3.12" + airflow-version: "2.5" + - python-version: "3.12" + airflow-version: "2.6" + - python-version: "3.12" + airflow-version: "2.7" + - python-version: "3.12" + airflow-version: "2.8" steps: - uses: actions/checkout@v3 with: diff --git a/pyproject.toml b/pyproject.toml index 8cf43a98e6..238b877e44 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ classifiers = [ "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", ] dependencies = [ "aenum", @@ -136,7 +137,7 @@ dependencies = [ pre-install-commands = ["sh scripts/test/pre-install-airflow.sh {matrix:airflow} {matrix:python}"] [[tool.hatch.envs.tests.matrix]] -python = ["3.8", "3.9", "3.10", "3.11"] +python = ["3.8", "3.9", "3.10", "3.11", "3.12"] airflow = ["2.4", "2.5", "2.6", "2.7", "2.8", "2.9"] [tool.hatch.envs.tests.overrides] diff --git a/tests/operators/test_base.py b/tests/operators/test_base.py index 70e4059e7e..3d39d43a7c 100644 --- a/tests/operators/test_base.py +++ b/tests/operators/test_base.py @@ -1,3 +1,4 @@ +import sys from unittest.mock import patch import pytest @@ -14,6 +15,10 @@ ) +@pytest.mark.skipif( + (sys.version_info.major, sys.version_info.minor) == (3, 12), + reason="The error message for the abstract class instantiation seems to have changed between Python 3.11 and 3.12", +) def test_dbt_base_operator_is_abstract(): """Tests that the abstract base operator cannot be instantiated since the base_cmd is not defined.""" expected_error = ( @@ -23,6 +28,20 @@ def test_dbt_base_operator_is_abstract(): AbstractDbtBaseOperator() +@pytest.mark.skipif( + (sys.version_info.major, sys.version_info.minor) != (3, 12), + reason="The error message for the abstract class instantiation seems to have changed between Python 3.11 and 3.12", +) +def test_dbt_base_operator_is_abstract_py12(): + """Tests that the abstract base operator cannot be instantiated since the base_cmd is not defined.""" + expected_error = ( + "Can't instantiate abstract class AbstractDbtBaseOperator without an implementation for abstract methods " + "'base_cmd', 'build_and_run_cmd'" + ) + with pytest.raises(TypeError, match=expected_error): + AbstractDbtBaseOperator() + + @pytest.mark.parametrize("cmd_flags", [["--some-flag"], []]) @patch("cosmos.operators.base.AbstractDbtBaseOperator.build_and_run_cmd") def test_dbt_base_operator_execute(mock_build_and_run_cmd, cmd_flags, monkeypatch): From 52d56c4ca99884c4d0127272be5eac4697607f30 Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Wed, 5 Jun 2024 11:33:43 +0100 Subject: [PATCH 183/223] Update dbt and Airflow conflicts matrix (#1026) Include the following in the dbt & Airflow dependencies compatibility matrix - dbt 1.8 - Airflow 2.9 --- .../execution-modes-local-conflicts.rst | 43 ++++++++++--------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/docs/getting_started/execution-modes-local-conflicts.rst b/docs/getting_started/execution-modes-local-conflicts.rst index 96921b6f7f..3f537baba9 100644 --- a/docs/getting_started/execution-modes-local-conflicts.rst +++ b/docs/getting_started/execution-modes-local-conflicts.rst @@ -10,24 +10,25 @@ If you find errors, we recommend users look into using `alternative execution mo In the following table, ``x`` represents combinations that lead to conflicts (vanilla ``apache-airflow`` and ``dbt-core`` packages): -+---------------+-----+-----+-----+-----+-----+-----+-----+-----+ -| Airflow / DBT | 1.0 | 1.1 | 1.2 | 1.3 | 1.4 | 1.5 | 1.6 | 1.7 | -+===============+=====+=====+=====+=====+=====+=====+=====+=====+ -| 2.2 | | | | x | x | x | x | x | -+---------------+-----+-----+-----+-----+-----+-----+-----+-----+ -| 2.3 | x | x | | x | x | x | x | x | -+---------------+-----+-----+-----+-----+-----+-----+-----+-----+ -| 2.4 | x | x | x | | | | | | -+---------------+-----+-----+-----+-----+-----+-----+-----+-----+ -| 2.5 | x | x | x | | | | | | -+---------------+-----+-----+-----+-----+-----+-----+-----+-----+ -| 2.6 | x | x | x | x | x | | | | -+---------------+-----+-----+-----+-----+-----+-----+-----+-----+ -| 2.7 | x | x | x | x | x | | | | -+---------------+-----+-----+-----+-----+-----+-----+-----+-----+ -| 2.8 | x | x | x | x | x | | x | x | -+---------------+-----+-----+-----+-----+-----+-----+-----+-----+ - ++---------------+-----+-----+-----+-----+-----+-----+-----+-----+-----+ +| Airflow / DBT | 1.0 | 1.1 | 1.2 | 1.3 | 1.4 | 1.5 | 1.6 | 1.7 | 1.8 | ++===============+=====+=====+=====+=====+=====+=====+=====+=====+=====+ +| 2.2 | | | | x | x | x | x | x | x | ++---------------+-----+-----+-----+-----+-----+-----+-----+-----+-----+ +| 2.3 | x | x | | x | x | x | x | x | X | ++---------------+-----+-----+-----+-----+-----+-----+-----+-----+-----+ +| 2.4 | x | x | x | | | | | | | ++---------------+-----+-----+-----+-----+-----+-----+-----+-----+-----+ +| 2.5 | x | x | x | | | | | | | ++---------------+-----+-----+-----+-----+-----+-----+-----+-----+-----+ +| 2.6 | x | x | x | x | x | | | | | ++---------------+-----+-----+-----+-----+-----+-----+-----+-----+-----+ +| 2.7 | x | x | x | x | x | | | | | ++---------------+-----+-----+-----+-----+-----+-----+-----+-----+-----+ +| 2.8 | x | x | x | x | x | | x | | | ++---------------+-----+-----+-----+-----+-----+-----+-----+-----+-----+ +| 2.9 | x | x | x | x | x | | | | | ++---------------+-----+-----+-----+-----+-----+-----+-----+-----+-----+ Examples of errors ----------------------------------- @@ -92,9 +93,11 @@ The table was created by running `nox `__ wi @nox.session(python=["3.10"]) @nox.parametrize( - "dbt_version", ["1.0", "1.1", "1.2", "1.3", "1.4", "1.5", "1.6", "1.7"] + "dbt_version", ["1.0", "1.1", "1.2", "1.3", "1.4", "1.5", "1.6", "1.7", "1.8"] + ) + @nox.parametrize( + "airflow_version", ["2.2.4", "2.3", "2.4", "2.5", "2.6", "2.7", "2.8", "2.9"] ) - @nox.parametrize("airflow_version", ["2.2.4", "2.3", "2.4", "2.5", "2.6", "2.7", "2.8"]) def compatibility(session: nox.Session, airflow_version, dbt_version) -> None: """Run both unit and integration tests.""" session.run( From 9c636e65b1f4271e42942bd3727dfbe39ed23e14 Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Wed, 5 Jun 2024 11:40:31 +0100 Subject: [PATCH 184/223] Fix Cosmos enable_cache setting (#1025) As of Cosmos 1.4.1, users are not able to disable the cache in Cosmos using `AIRFLOW__COSMOS__ENABLE_CACHE=0`. During a recent refactoring #975, the `enable_cache` was changed to a non-boolean config. This PR fixes this issue. --- cosmos/settings.py | 2 +- tests/test_settings.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 tests/test_settings.py diff --git a/cosmos/settings.py b/cosmos/settings.py index 44a08fd486..369913b932 100644 --- a/cosmos/settings.py +++ b/cosmos/settings.py @@ -10,7 +10,7 @@ # In MacOS users may want to set the envvar `TMPDIR` if they do not want the value of the temp directory to change DEFAULT_CACHE_DIR = Path(tempfile.gettempdir(), DEFAULT_COSMOS_CACHE_DIR_NAME) cache_dir = Path(conf.get("cosmos", "cache_dir", fallback=DEFAULT_CACHE_DIR) or DEFAULT_CACHE_DIR) -enable_cache = conf.get("cosmos", "enable_cache", fallback=True) +enable_cache = conf.getboolean("cosmos", "enable_cache", fallback=True) propagate_logs = conf.getboolean("cosmos", "propagate_logs", fallback=True) dbt_docs_dir = conf.get("cosmos", "dbt_docs_dir", fallback=None) dbt_docs_conn_id = conf.get("cosmos", "dbt_docs_conn_id", fallback=None) diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000000..d9f5e0f6e7 --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,11 @@ +import os +from importlib import reload +from unittest.mock import patch + +from cosmos import settings + + +@patch.dict(os.environ, {"AIRFLOW__COSMOS__ENABLE_CACHE": "False"}, clear=True) +def test_enable_cache_env_var(): + reload(settings) + assert settings.enable_cache is False From 07ebfe3c2117e1599ba9164c0d0d247e70c8e6c9 Mon Sep 17 00:00:00 2001 From: Pankaj Singh <98807258+pankajastro@users.noreply.github.com> Date: Wed, 5 Jun 2024 19:45:22 +0530 Subject: [PATCH 185/223] Athena profile mapping set aws_session_token in profile only if it exist (#1022) Set `aws_session_token` in the Athena profile only if it exists to avoid passing an empty string as a token to the AWS API. Closes: #962 --- cosmos/profiles/athena/access_key.py | 4 +- .../profiles/athena/test_athena_access_key.py | 51 ++++++++++++++++--- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/cosmos/profiles/athena/access_key.py b/cosmos/profiles/athena/access_key.py index 02de2be247..8dc14f8399 100644 --- a/cosmos/profiles/athena/access_key.py +++ b/cosmos/profiles/athena/access_key.py @@ -66,9 +66,11 @@ def profile(self) -> dict[str, Any | None]: **self.profile_args, "aws_access_key_id": self.temporary_credentials.access_key, "aws_secret_access_key": self.get_env_var_format("aws_secret_access_key"), - "aws_session_token": self.get_env_var_format("aws_session_token"), } + if self.temporary_credentials.token: + profile["aws_session_token"] = self.get_env_var_format("aws_session_token") + return self.filter_null(profile) @property diff --git a/tests/profiles/athena/test_athena_access_key.py b/tests/profiles/athena/test_athena_access_key.py index 71ba1eb05d..c0a25b7e95 100644 --- a/tests/profiles/athena/test_athena_access_key.py +++ b/tests/profiles/athena/test_athena_access_key.py @@ -1,8 +1,10 @@ "Tests for the Athena profile." +from __future__ import annotations import json import sys from collections import namedtuple +from unittest import mock from unittest.mock import MagicMock, patch import pytest @@ -39,12 +41,7 @@ def get_credentials(self) -> Credentials: yield mock_aws_hook -@pytest.fixture() -def mock_athena_conn(): # type: ignore - """ - Sets the connection as an environment variable. - """ - +def mock_conn_value(token: str | None = None) -> Connection: conn = Connection( conn_id="my_athena_connection", conn_type="aws", @@ -52,7 +49,7 @@ def mock_athena_conn(): # type: ignore password="my_aws_secret_key", extra=json.dumps( { - "aws_session_token": "token123", + "aws_session_token": token, "database": "my_database", "region_name": "us-east-1", "s3_staging_dir": "s3://my_bucket/dbt/", @@ -60,7 +57,25 @@ def mock_athena_conn(): # type: ignore } ), ) + return conn + +@pytest.fixture() +def mock_athena_conn(): # type: ignore + """ + Sets the connection as an environment variable. + """ + conn = mock_conn_value(token="token123") + with patch("airflow.hooks.base.BaseHook.get_connection", return_value=conn): + yield conn + + +@pytest.fixture() +def mock_athena_conn_without_token(): # type: ignore + """ + Sets the connection as an environment variable. + """ + conn = mock_conn_value(token=None) with patch("airflow.hooks.base.BaseHook.get_connection", return_value=conn): yield conn @@ -151,6 +166,28 @@ def test_athena_profile_args( } +@mock.patch("cosmos.profiles.athena.access_key.AthenaAccessKeyProfileMapping._get_temporary_credentials") +def test_athena_profile_args_without_token(mock_temp_cred, mock_athena_conn_without_token: Connection) -> None: + """ + Tests that the profile values get set correctly for Athena. + """ + ReadOnlyCredentials = namedtuple("ReadOnlyCredentials", ["access_key", "secret_key", "token"]) + credentials = ReadOnlyCredentials(access_key="my_aws_access_key", secret_key="my_aws_secret_key", token=None) + mock_temp_cred.return_value = credentials + + profile_mapping = get_automatic_profile_mapping(mock_athena_conn_without_token.conn_id) + + assert profile_mapping.profile == { + "type": "athena", + "aws_access_key_id": "my_aws_access_key", + "aws_secret_access_key": "{{ env_var('COSMOS_CONN_AWS_AWS_SECRET_ACCESS_KEY') }}", + "database": mock_athena_conn_without_token.extra_dejson.get("database"), + "region_name": mock_athena_conn_without_token.extra_dejson.get("region_name"), + "s3_staging_dir": mock_athena_conn_without_token.extra_dejson.get("s3_staging_dir"), + "schema": mock_athena_conn_without_token.extra_dejson.get("schema"), + } + + def test_athena_profile_args_overrides( mock_athena_conn: Connection, ) -> None: From 40de74ae18db37da715de8defd8205889a109b73 Mon Sep 17 00:00:00 2001 From: Pankaj Singh <98807258+pankajastro@users.noreply.github.com> Date: Wed, 5 Jun 2024 19:53:42 +0530 Subject: [PATCH 186/223] =?UTF-8?q?Make=20GoogleCloudServiceAccountDictPro?= =?UTF-8?q?fileMapping=20dataset=20profile=20argu=E2=80=A6=20(#1017)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR follows the methodology in https://github.com/astronomer/astronomer-cosmos/pull/683/ by modifying the GCP `GoogleCloudServiceAccountDictProfileMapping()` `profile_args` not require a dataset as a required argument. DAG RUN Screenshot 2024-06-05 at 6 50 04 PM Co-authored-by: "Ollie Ma" Original PR by @oliverrmaa: https://github.com/astronomer/astronomer-cosmos/pull/839 Closes #837 --- cosmos/profiles/bigquery/service_account_keyfile_dict.py | 5 +++-- .../bigquery/test_bq_service_account_keyfile_dict.py | 5 ++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cosmos/profiles/bigquery/service_account_keyfile_dict.py b/cosmos/profiles/bigquery/service_account_keyfile_dict.py index 038b34153d..17858d7bb2 100644 --- a/cosmos/profiles/bigquery/service_account_keyfile_dict.py +++ b/cosmos/profiles/bigquery/service_account_keyfile_dict.py @@ -22,7 +22,6 @@ class GoogleCloudServiceAccountDictProfileMapping(BaseProfileMapping): required_fields = [ "project", - "dataset", "keyfile_json", ] @@ -45,12 +44,14 @@ def profile(self) -> dict[str, Any | None]: Even though the Airflow connection contains hard-coded Service account credentials, we generate a temporary file and the DBT profile uses it. """ - return { + profile_dict = { **self.mapped_params, "threads": 1, **self.profile_args, } + return self.filter_null(profile_dict) + @property def mock_profile(self) -> dict[str, Any | None]: "Generates mock profile. Defaults `threads` to 1." diff --git a/tests/profiles/bigquery/test_bq_service_account_keyfile_dict.py b/tests/profiles/bigquery/test_bq_service_account_keyfile_dict.py index 4e56f5ba13..6f0d60b8dc 100755 --- a/tests/profiles/bigquery/test_bq_service_account_keyfile_dict.py +++ b/tests/profiles/bigquery/test_bq_service_account_keyfile_dict.py @@ -64,8 +64,8 @@ def test_connection_claiming_succeeds(mock_bigquery_conn_with_dict: Connection): def test_connection_claiming_fails(mock_bigquery_conn_with_dict: Connection): - # Remove the `dataset` key, which is mandatory - mock_bigquery_conn_with_dict.extra = json.dumps({"project": "my_project", "keyfile_dict": sample_keyfile_dict}) + # Remove the `project` key, which is mandatory + mock_bigquery_conn_with_dict.extra = json.dumps({"dataset": "my_dataset", "keyfile_dict": sample_keyfile_dict}) profile_mapping = GoogleCloudServiceAccountDictProfileMapping(mock_bigquery_conn_with_dict, {}) assert not profile_mapping.can_claim_connection() @@ -96,7 +96,6 @@ def test_mock_profile(mock_bigquery_conn_with_dict: Connection): "type": "bigquery", "method": "service-account-json", "project": "mock_value", - "dataset": "mock_value", "threads": 1, "keyfile_json": None, } From ddf6a9c0eebc1447cfe15df771de4bcf85d16c08 Mon Sep 17 00:00:00 2001 From: Pankaj Singh <98807258+pankajastro@users.noreply.github.com> Date: Thu, 6 Jun 2024 14:28:21 +0530 Subject: [PATCH 187/223] Add Clickhouse profile mapping (#1016) This PR adds Clickhouse profile mapping using a `generic` connection type. To prevent cosmos from attaching all generic connections, it uses a required field named `clickhouse` mapped to `extra.clickhouse`. To ensure the profile is claimed, users must add the following JSON to the extra field in the connection: ```JSON { "clickhouse": "True" } ``` Co-authored-by: Yaniv Rodenski Original PR by @roadan: https://github.com/astronomer/astronomer-cosmos/pull/353 Closes #95 --- cosmos/profiles/__init__.py | 2 + cosmos/profiles/clickhouse/__init__.py | 5 + cosmos/profiles/clickhouse/user_pass.py | 70 +++++++++++ pyproject.toml | 2 + tests/profiles/clickhouse/__init__.py | 0 .../clickhouse/test_clickhouse_userpass.py | 117 ++++++++++++++++++ 6 files changed, 196 insertions(+) create mode 100644 cosmos/profiles/clickhouse/__init__.py create mode 100644 cosmos/profiles/clickhouse/user_pass.py create mode 100644 tests/profiles/clickhouse/__init__.py create mode 100644 tests/profiles/clickhouse/test_clickhouse_userpass.py diff --git a/cosmos/profiles/__init__.py b/cosmos/profiles/__init__.py index fa8e5c370e..5cc3109cc3 100644 --- a/cosmos/profiles/__init__.py +++ b/cosmos/profiles/__init__.py @@ -9,6 +9,7 @@ from .bigquery.oauth import GoogleCloudOauthProfileMapping from .bigquery.service_account_file import GoogleCloudServiceAccountFileProfileMapping from .bigquery.service_account_keyfile_dict import GoogleCloudServiceAccountDictProfileMapping +from .clickhouse.user_pass import ClickhouseUserPasswordProfileMapping from .databricks.token import DatabricksTokenProfileMapping from .exasol.user_pass import ExasolUserPasswordProfileMapping from .postgres.user_pass import PostgresUserPasswordProfileMapping @@ -25,6 +26,7 @@ profile_mappings: list[Type[BaseProfileMapping]] = [ AthenaAccessKeyProfileMapping, + ClickhouseUserPasswordProfileMapping, GoogleCloudServiceAccountFileProfileMapping, GoogleCloudServiceAccountDictProfileMapping, GoogleCloudOauthProfileMapping, diff --git a/cosmos/profiles/clickhouse/__init__.py b/cosmos/profiles/clickhouse/__init__.py new file mode 100644 index 0000000000..bd94af5fec --- /dev/null +++ b/cosmos/profiles/clickhouse/__init__.py @@ -0,0 +1,5 @@ +"""Generic Airflow connection -> dbt profile mappings""" + +from .user_pass import ClickhouseUserPasswordProfileMapping + +__all__ = ["ClickhouseUserPasswordProfileMapping"] diff --git a/cosmos/profiles/clickhouse/user_pass.py b/cosmos/profiles/clickhouse/user_pass.py new file mode 100644 index 0000000000..7d168895a2 --- /dev/null +++ b/cosmos/profiles/clickhouse/user_pass.py @@ -0,0 +1,70 @@ +"""Maps Airflow Postgres connections using user + password authentication to dbt profiles.""" + +from __future__ import annotations + +from typing import Any + +from ..base import BaseProfileMapping + + +class ClickhouseUserPasswordProfileMapping(BaseProfileMapping): + """ + Maps Airflow generic connections using user + password authentication to dbt Clickhouse profiles. + https://docs.getdbt.com/docs/core/connect-data-platform/clickhouse-setup + """ + + airflow_connection_type: str = "generic" + dbt_profile_type: str = "clickhouse" + default_port = 9000 + is_community = True + + required_fields = [ + "host", + "login", + "schema", + "clickhouse", + ] + secret_fields = [ + "password", + ] + airflow_param_mapping = { + "host": "host", + "login": "login", + "password": "password", + "port": "port", + "schema": "schema", + "clickhouse": "extra.clickhouse", + } + + def _set_default_param(self, profile_dict: dict[str, Any]) -> dict[str, Any]: + if not profile_dict.get("driver"): + profile_dict["driver"] = "native" + + if not profile_dict.get("port"): + profile_dict["port"] = self.default_port + + if not profile_dict.get("secure"): + profile_dict["secure"] = False + return profile_dict + + @property + def profile(self) -> dict[str, Any | None]: + """Gets profile. The password is stored in an environment variable.""" + profile_dict = { + **self.mapped_params, + **self.profile_args, + # password should always get set as env var + "password": self.get_env_var_format("password"), + } + + return self.filter_null(self._set_default_param(profile_dict)) + + @property + def mock_profile(self) -> dict[str, Any | None]: + """Gets mock profile.""" + + profile_dict = { + **super().mock_profile, + } + + return self._set_default_param(profile_dict) diff --git a/pyproject.toml b/pyproject.toml index 238b877e44..ea97a9c0c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,7 @@ dependencies = [ dbt-all = [ "dbt-athena", "dbt-bigquery", + "dbt-clickhouse", "dbt-databricks", "dbt-exasol", "dbt-postgres", @@ -53,6 +54,7 @@ dbt-all = [ ] dbt-athena = ["dbt-athena-community", "apache-airflow-providers-amazon>=8.0.0"] dbt-bigquery = ["dbt-bigquery"] +dbt-clickhouse = ["dbt-clickhouse"] dbt-databricks = ["dbt-databricks"] dbt-exasol = ["dbt-exasol"] dbt-postgres = ["dbt-postgres"] diff --git a/tests/profiles/clickhouse/__init__.py b/tests/profiles/clickhouse/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/profiles/clickhouse/test_clickhouse_userpass.py b/tests/profiles/clickhouse/test_clickhouse_userpass.py new file mode 100644 index 0000000000..1f623c8030 --- /dev/null +++ b/tests/profiles/clickhouse/test_clickhouse_userpass.py @@ -0,0 +1,117 @@ +"""Tests for the clickhouse profile.""" + +from unittest.mock import patch + +import pytest +from airflow.models.connection import Connection + +from cosmos.profiles import get_automatic_profile_mapping +from cosmos.profiles.clickhouse.user_pass import ( + ClickhouseUserPasswordProfileMapping, +) + + +@pytest.fixture() +def mock_clickhouse_conn(): # type: ignore + """Sets the connection as an environment variable.""" + conn = Connection( + conn_id="clickhouse_connection", + conn_type="generic", + host="my_host", + login="my_user", + password="my_password", + schema="my_database", + extra='{"clickhouse": "True"}', + ) + + with patch("airflow.hooks.base.BaseHook.get_connection", return_value=conn): + yield conn + + +def test_connection_claiming1() -> None: + """ + Tests that the clickhouse profile mapping claims the correct connection type. + + should only claim when: + - conn_type == generic + And the following exist: + - host + - login + - password + - schema + - extra.clickhouse + """ + required_values = { + "conn_type": "generic", + "host": "my_host", + "login": "my_user", + "schema": "my_database", + "extra": '{"clickhouse": "True"}', + } + + def can_claim_with_missing_key(missing_key: str) -> bool: + values = required_values.copy() + del values[missing_key] + conn = Connection(**values) # type: ignore + with patch("airflow.hooks.base.BaseHook.get_connection", return_value=conn): + profile_mapping = ClickhouseUserPasswordProfileMapping(conn, {}) + return profile_mapping.can_claim_connection() + + # if we're missing any of the required values, it shouldn't claim + for key in required_values: + assert not can_claim_with_missing_key(key), f"Failed when missing {key}" + + # if we have all the required values, it should claim + conn = Connection(**required_values) # type: ignore + with patch("airflow.hooks.base.BaseHook.get_connection", return_value=conn): + profile_mapping = ClickhouseUserPasswordProfileMapping(conn, {}) + assert profile_mapping.can_claim_connection() + + +def test_profile_mapping_selected( + mock_clickhouse_conn: Connection, +) -> None: + """Tests that the correct profile mapping is selected.""" + profile_mapping = get_automatic_profile_mapping(mock_clickhouse_conn.conn_id, {}) + assert isinstance(profile_mapping, ClickhouseUserPasswordProfileMapping) + + +def test_profile_args(mock_clickhouse_conn: Connection) -> None: + """Tests that the profile values get set correctly.""" + profile_mapping = get_automatic_profile_mapping(mock_clickhouse_conn.conn_id, profile_args={}) + + assert profile_mapping.profile == { + "type": "clickhouse", + "schema": mock_clickhouse_conn.schema, + "login": mock_clickhouse_conn.login, + "password": "{{ env_var('COSMOS_CONN_GENERIC_PASSWORD') }}", + "driver": "native", + "port": 9000, + "host": mock_clickhouse_conn.host, + "secure": False, + "clickhouse": "True", + } + + +def test_mock_profile() -> None: + """Tests that the mock_profile values get set correctly.""" + profile_mapping = ClickhouseUserPasswordProfileMapping( + "conn_id" + ) # get_automatic_profile_mapping("mock_clickhouse_conn.conn_id", profile_args={}) + + assert profile_mapping.mock_profile == { + "type": "clickhouse", + "schema": "mock_value", + "login": "mock_value", + "driver": "native", + "port": 9000, + "host": "mock_value", + "secure": False, + "clickhouse": "mock_value", + } + + +def test_profile_env_vars(mock_clickhouse_conn: Connection) -> None: + """Tests that the environment variables get set correctly.""" + profile_mapping = get_automatic_profile_mapping(mock_clickhouse_conn.conn_id, profile_args={}) + assert profile_mapping.env_vars == {"COSMOS_CONN_GENERIC_PASSWORD": mock_clickhouse_conn.password} From 7fa38b2580cee38de5d54aa3505966a5d9f9286d Mon Sep 17 00:00:00 2001 From: Pankaj Koti Date: Thu, 6 Jun 2024 17:19:08 +0530 Subject: [PATCH 188/223] Add more fields in bug template to reduce turnaround in issue triaging (#1027) Add LoadMode, ExecutionMode and InvocationMode values to be supplied in the bug report template to get upfront clarity on the setup and reduce follow-ups on the issue for those questions. --- .github/ISSUE_TEMPLATE/01-bug.yml | 38 +++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/01-bug.yml b/.github/ISSUE_TEMPLATE/01-bug.yml index c8d6492fab..658d0b9cb6 100644 --- a/.github/ISSUE_TEMPLATE/01-bug.yml +++ b/.github/ISSUE_TEMPLATE/01-bug.yml @@ -41,6 +41,44 @@ body: label: Versions of dbt adapters description: What dbt adapter versions are you using? placeholder: You can use `pip freeze | grep dbt` (you can leave only relevant ones) + - type: dropdown + id: load-mode + attributes: + label: LoadMode + description: Which LoadMode are you using? + options: + - "AUTOMATIC" + - "CUSTOM" + - "DBT_LS" + - "DBT_LS_FILE" + - "DBT_LS_MANIFEST" + multiple: false + validations: + required: true + - type: dropdown + id: execution-mode + attributes: + label: ExecutionMode + description: Which ExecutionMode are you using? + options: + - "AWS_EKS" + - "AZURE_CONTAINER_INSTANCE" + - "DOCKER" + - "KUBERNETES" + - "LOCAL" + - "VIRTUALENV" + multiple: false + validations: + required: true + - type: dropdown + id: invocation-mode + attributes: + label: InvocationMode + description: Which InvocationMode are you using? + options: + - "DBT_RUNNER" + - "SUBPROCESS" + multiple: false - type: input id: airflow-version attributes: From 59b574edda9ad9aae535d78617fb0127ede64af5 Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Thu, 6 Jun 2024 13:54:49 +0100 Subject: [PATCH 189/223] Release 1.4.2 (#1028) Bug fixes * Fix the invocation mode for ``ExecutionMode.VIRTUALENV`` by @marco9663 in #1023 * Fix Cosmos ``enable_cache`` setting by @tatiana in #1025 * Make ``GoogleCloudServiceAccountDictProfileMapping`` dataset profile arg optional by @oliverrmaa and @pankajastro in #839 and #1017 * Athena profile mapping set ``aws_session_token`` in profile only if it exists by @pankajastro in #1022 Others * Update dbt and Airflow conflicts matrix by @tatiana in #1026 * Enable Python 3.12 unittest by @pankajastro in #1018 * Improve error logging in ``DbtLocalBaseOperator`` by @davidsteinar in #1004 * Add GitHub issue templates for bug reports and feature request by @pankajkoti in #1009 * Add more fields in bug template to reduce turnaround in issue triaging by @pankajkoti in #1027 * Fix ``dev/Dockerfile`` + Add ``uv pip install`` for faster build time by @dwreeves in #997 * Drop support for Airflow 2.3 by @pankajkoti in #994 * Update Astro Runtime image by @RNHTTR in #988 and #989 * Enable ruff F linting by @pankajastro in #985 * Move Cosmos Airflow configuration to settings.py by @pankajastro in #975 * Fix CI Issues by @tatiana in #1005 * Pre-commit hook updates in #1000, #1019 --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Reeves <31971762+dwreeves@users.noreply.github.com> Co-authored-by: Pankaj Koti Co-authored-by: davidsteinar Co-authored-by: David Steinar Asgrimsson Co-authored-by: Marco Yuen <33394715+marco9663@users.noreply.github.com> Co-authored-by: Pankaj Singh <98807258+pankajastro@users.noreply.github.com> Co-authored-by: Ollie Ma --- CHANGELOG.rst | 40 +++++++++++++++++++++++++++++++++------- cosmos/__init__.py | 2 +- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 2ef66782c1..2e6331edad 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,21 +1,47 @@ Changelog ========= +1.4.2 (2024-06-06) +------------------ + +Bug fixes + +* Fix the invocation mode for ``ExecutionMode.VIRTUALENV`` by @marco9663 in #1023 +* Fix Cosmos ``enable_cache`` setting by @tatiana in #1025 +* Make ``GoogleCloudServiceAccountDictProfileMapping`` dataset profile arg optional by @oliverrmaa and @pankajastro in #839 and #1017 +* Athena profile mapping set ``aws_session_token`` in profile only if it exists by @pankajastro in #1022 + +Others + +* Update dbt and Airflow conflicts matrix by @tatiana in #1026 +* Enable Python 3.12 unittest by @pankajastro in #1018 +* Improve error logging in ``DbtLocalBaseOperator`` by @davidsteinar in #1004 +* Add GitHub issue templates for bug reports and feature request by @pankajkoti in #1009 +* Add more fields in bug template to reduce turnaround in issue triaging by @pankajkoti in #1027 +* Fix ``dev/Dockerfile`` + Add ``uv pip install`` for faster build time by @dwreeves in #997 +* Drop support for Airflow 2.3 by @pankajkoti in #994 +* Update Astro Runtime image by @RNHTTR in #988 and #989 +* Enable ruff F linting by @pankajastro in #985 +* Move Cosmos Airflow configuration to settings.py by @pankajastro in #975 +* Fix CI Issues by @tatiana in #1005 +* Pre-commit hook updates in #1000, #1019 + + 1.4.1 (2024-05-17) --------------------- +------------------ Bug fixes -* Fix manifest testing behavior in #955 by @chris-okorodudu -* Handle ValueError when unpacking partial_parse.msgpack in #972 by @tatiana +* Fix manifest testing behavior by @chris-okorodudu in #955 +* Handle ValueError when unpacking partial_parse.msgpack by @tatiana in #972 Others * Enable pre-commit run and fix type-check job by @pankajastro in #957 -* Clean databricks credentials in test/CI in #969 by @tatiana -* Update CODEOWNERS in #969 by @tatiana -* Update emeritus contributors list in #961 by @tatiana -* Promote @dwreeves to committer in #960 by @tatiana +* Clean databricks credentials in test/CI by @tatiana in #969 +* Update CODEOWNERS by @tatiana in #969 x +* Update emeritus contributors list by @tatiana in #961 +* Promote @dwreeves to committer by @tatiana in #960 * Pre-commit hook updates in #956 diff --git a/cosmos/__init__.py b/cosmos/__init__.py index 5d88d35d3c..7a73e722ef 100644 --- a/cosmos/__init__.py +++ b/cosmos/__init__.py @@ -5,7 +5,7 @@ Contains dags, task groups, and operators. """ -__version__ = "1.4.1" +__version__ = "1.4.2" from cosmos.airflow.dag import DbtDag From 7b5dff80028f9872d3e6cb598e445b6f7cf725b1 Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Fri, 7 Jun 2024 11:12:48 +0100 Subject: [PATCH 190/223] Only run `dbt deps` when there are dependencies (#1030) As of Cosmos 1.4, Cosmos will attempt to run dbt deps even if there are no `dependencies.yml` or `packages.yml` in the dbt project directory. This causes an unnecessary overhead in creating subprocesses without any benefit. This problem was initially spotted by @AlgirdasDubickas, who created a pull request proposing a solution to the problem: https://github.com/astronomer/astronomer-cosmos/pull/893/ Despite the original PR becoming stale, the problem it addresses remains relevant. This PR proposes a different implementation to solve the same problem. It addresses the issue from a rendering perspective (converting a dbt project into an Airflow DAG using `LoadMode.DBT_LS`) and an execution perspective (when Airflow worker nodes run/trigger dbt commands to be run when using `ExecutionMode.LOCAL` or `ExecutionMode.VIRTUALENV`). Co-authored-by: AlgirdasDubickas <123624084+AlgirdasDubickas@users.noreply.github.com> --- cosmos/constants.py | 1 + cosmos/dbt/graph.py | 6 ++++-- cosmos/dbt/project.py | 30 +++++++++++++++++++++++++++++- cosmos/operators/local.py | 6 ++++-- tests/dbt/test_project.py | 22 +++++++++++++++++++++- tests/operators/test_local.py | 7 +++++++ 6 files changed, 66 insertions(+), 6 deletions(-) diff --git a/cosmos/constants.py b/cosmos/constants.py index b356d5542e..92bf883b2e 100644 --- a/cosmos/constants.py +++ b/cosmos/constants.py @@ -15,6 +15,7 @@ DBT_TARGET_DIR_NAME = "target" DBT_PARTIAL_PARSE_FILE_NAME = "partial_parse.msgpack" DBT_MANIFEST_FILE_NAME = "manifest.json" +DBT_DEPENDENCIES_FILE_NAMES = {"packages.yml", "dependencies.yml"} DBT_LOG_FILENAME = "dbt.log" DBT_BINARY_NAME = "dbt" diff --git a/cosmos/dbt/graph.py b/cosmos/dbt/graph.py index 40a44d3a63..9ad8caaee0 100644 --- a/cosmos/dbt/graph.py +++ b/cosmos/dbt/graph.py @@ -24,7 +24,7 @@ LoadMode, ) from cosmos.dbt.parser.project import LegacyDbtProject -from cosmos.dbt.project import create_symlinks, environ, get_partial_parse_path +from cosmos.dbt.project import create_symlinks, environ, get_partial_parse_path, has_non_empty_dependencies_file from cosmos.dbt.selector import select_nodes from cosmos.log import get_logger @@ -320,7 +320,9 @@ def load_via_dbt_ls(self) -> None: env[DBT_LOG_PATH_ENVVAR] = str(self.log_dir) env[DBT_TARGET_PATH_ENVVAR] = str(self.target_dir) - if self.render_config.dbt_deps: + if self.render_config.dbt_deps and has_non_empty_dependencies_file( + Path(self.render_config.project_path) + ): deps_command = [dbt_cmd, "deps"] deps_command.extend(self.local_flags) stdout = run_command(deps_command, tmpdir_path, env) diff --git a/cosmos/dbt/project.py b/cosmos/dbt/project.py index c1c7aa080f..d8750cd442 100644 --- a/cosmos/dbt/project.py +++ b/cosmos/dbt/project.py @@ -5,7 +5,35 @@ from pathlib import Path from typing import Generator -from cosmos.constants import DBT_LOG_DIR_NAME, DBT_PARTIAL_PARSE_FILE_NAME, DBT_TARGET_DIR_NAME +from cosmos.constants import ( + DBT_DEPENDENCIES_FILE_NAMES, + DBT_LOG_DIR_NAME, + DBT_PARTIAL_PARSE_FILE_NAME, + DBT_TARGET_DIR_NAME, +) +from cosmos.log import get_logger + +logger = get_logger() + + +def has_non_empty_dependencies_file(project_path: Path) -> bool: + """ + Check if the dbt project has dependencies.yml or packages.yml. + + :param project_path: Path to the project + :returns: True or False + """ + project_dir = Path(project_path) + has_deps = False + for filename in DBT_DEPENDENCIES_FILE_NAMES: + filepath = project_dir / filename + if filepath.exists() and filepath.stat().st_size > 0: + has_deps = True + break + + if not has_deps: + logger.info(f"Project {project_path} does not have {DBT_DEPENDENCIES_FILE_NAMES}") + return has_deps def create_symlinks(project_path: Path, tmp_dir: Path, ignore_dbt_packages: bool) -> None: diff --git a/cosmos/operators/local.py b/cosmos/operators/local.py index 703b1fb1ee..232a6680d1 100644 --- a/cosmos/operators/local.py +++ b/cosmos/operators/local.py @@ -18,7 +18,7 @@ from cosmos import cache from cosmos.constants import InvocationMode -from cosmos.dbt.project import get_partial_parse_path +from cosmos.dbt.project import get_partial_parse_path, has_non_empty_dependencies_file from cosmos.exceptions import AirflowCompatibilityError from cosmos.settings import LINEAGE_NAMESPACE @@ -126,7 +126,6 @@ def __init__( **kwargs: Any, ) -> None: self.profile_config = profile_config - self.install_deps = install_deps self.callback = callback self.compiled_sql = "" self.should_store_compiled_sql = should_store_compiled_sql @@ -146,6 +145,9 @@ def __init__( # as it can break existing DAGs. self.append_env = append_env + # We should not spend time trying to install deps if the project doesn't have any dependencies + self.install_deps = install_deps and has_non_empty_dependencies_file(Path(self.project_dir)) + @cached_property def subprocess_hook(self) -> FullOutputSubprocessHook: """Returns hook for running the bash command.""" diff --git a/tests/dbt/test_project.py b/tests/dbt/test_project.py index f55525a439..df625182fc 100644 --- a/tests/dbt/test_project.py +++ b/tests/dbt/test_project.py @@ -2,7 +2,9 @@ from pathlib import Path from unittest.mock import patch -from cosmos.dbt.project import change_working_directory, create_symlinks, environ +import pytest + +from cosmos.dbt.project import change_working_directory, create_symlinks, environ, has_non_empty_dependencies_file DBT_PROJECTS_ROOT_DIR = Path(__file__).parent.parent.parent / "dev/dags/dbt" @@ -49,3 +51,21 @@ def test_change_working_directory(mock_chdir): # Check if os.chdir is called with the previous working directory mock_chdir.assert_called_with(os.getcwd()) + + +@pytest.mark.parametrize("filename", ["packages.yml", "dependencies.yml"]) +def test_has_non_empty_dependencies_file_is_true(tmpdir, filename): + filepath = Path(tmpdir) / filename + filepath.write_text("content") + assert has_non_empty_dependencies_file(tmpdir) + + +@pytest.mark.parametrize("filename", ["packages.yml", "dependencies.yml"]) +def test_has_non_empty_dependencies_file_is_false(tmpdir, filename): + filepath = Path(tmpdir) / filename + filepath.touch() + assert not has_non_empty_dependencies_file(tmpdir) + + +def test_has_non_empty_dependencies_file_is_false_in_empty_dir(tmpdir): + assert not has_non_empty_dependencies_file(tmpdir) diff --git a/tests/operators/test_local.py b/tests/operators/test_local.py index 5513b1c4b2..f90237082c 100644 --- a/tests/operators/test_local.py +++ b/tests/operators/test_local.py @@ -80,6 +80,13 @@ class ConcreteDbtLocalBaseOperator(DbtLocalBaseOperator): base_cmd = ["cmd"] +def test_install_deps_in_empty_dir_becomes_false(tmpdir): + dbt_base_operator = ConcreteDbtLocalBaseOperator( + profile_config=profile_config, task_id="my-task", project_dir=tmpdir, install_deps=True + ) + assert not dbt_base_operator.install_deps + + def test_dbt_base_operator_add_global_flags() -> None: dbt_base_operator = ConcreteDbtLocalBaseOperator( profile_config=profile_config, From a09cd3ea44c610ab0112f1d1963ebc880607614a Mon Sep 17 00:00:00 2001 From: Pankaj Koti Date: Fri, 7 Jun 2024 18:10:36 +0530 Subject: [PATCH 191/223] Bring back `dataset` as a required field for BigQuery profile (#1033) In PR #1017, we attempted to remove `dataset` from the required fields list for the BigQuery profile. However, we realised that this is failing BiqQuery dbt operations as it indeed is a required field. Hence, bring back the same as a required field. This is also necessary for building the mock profile where we construct the profile by taking in consideration only the required fields. Closes: #1031 --- cosmos/profiles/bigquery/service_account_keyfile_dict.py | 3 +++ .../profiles/bigquery/test_bq_service_account_keyfile_dict.py | 1 + 2 files changed, 4 insertions(+) diff --git a/cosmos/profiles/bigquery/service_account_keyfile_dict.py b/cosmos/profiles/bigquery/service_account_keyfile_dict.py index 17858d7bb2..480db669b9 100644 --- a/cosmos/profiles/bigquery/service_account_keyfile_dict.py +++ b/cosmos/profiles/bigquery/service_account_keyfile_dict.py @@ -20,8 +20,11 @@ class GoogleCloudServiceAccountDictProfileMapping(BaseProfileMapping): dbt_profile_type: str = "bigquery" dbt_profile_method: str = "service-account-json" + # Do not remove dataset as a required field form the below list. Although it's observed that it's not a required + # field for some databases like Postgres, it's required for BigQuery. required_fields = [ "project", + "dataset", "keyfile_json", ] diff --git a/tests/profiles/bigquery/test_bq_service_account_keyfile_dict.py b/tests/profiles/bigquery/test_bq_service_account_keyfile_dict.py index 6f0d60b8dc..d30c900216 100755 --- a/tests/profiles/bigquery/test_bq_service_account_keyfile_dict.py +++ b/tests/profiles/bigquery/test_bq_service_account_keyfile_dict.py @@ -96,6 +96,7 @@ def test_mock_profile(mock_bigquery_conn_with_dict: Connection): "type": "bigquery", "method": "service-account-json", "project": "mock_value", + "dataset": "mock_value", "threads": 1, "keyfile_json": None, } From 7b6e00b76443fefb79153e908c6ba38a6e520208 Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Fri, 7 Jun 2024 14:25:00 +0100 Subject: [PATCH 192/223] Fix docs so it does not reference non-existing `get_dbt_dataset` (#1034) [The documentation](https://astronomer.github.io/astronomer-cosmos/configuration/scheduling.html) was outdated. The method `get_dbt_dataset` no longer exists. It used to exist in older versions of Cosmos (before 1.1) when the URIs respected the format: `Dataset(f"DBT://{connection_id.upper()}/{project_name.upper()}/{model_name.upper()}")` More information on why we changed this: https://github.com/astronomer/astronomer-cosmos/issues/305 Closes: #1032 --- .github/ISSUE_TEMPLATE/01-bug.yml | 2 +- .github/ISSUE_TEMPLATE/02-feature.yml | 3 ++- docs/configuration/scheduling.rst | 19 ++++++++++++------- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/01-bug.yml b/.github/ISSUE_TEMPLATE/01-bug.yml index 658d0b9cb6..4a5517338b 100644 --- a/.github/ISSUE_TEMPLATE/01-bug.yml +++ b/.github/ISSUE_TEMPLATE/01-bug.yml @@ -1,7 +1,7 @@ --- name: Bug Report description: File a bug report. -title: "[Bug]: " +title: "[Bug] " labels: ["bug", "triage-needed"] body: - type: markdown diff --git a/.github/ISSUE_TEMPLATE/02-feature.yml b/.github/ISSUE_TEMPLATE/02-feature.yml index e179d357db..f8cd9e24da 100644 --- a/.github/ISSUE_TEMPLATE/02-feature.yml +++ b/.github/ISSUE_TEMPLATE/02-feature.yml @@ -1,7 +1,8 @@ --- name: Feature request description: Suggest an idea for this project -labels: ["enhancement", "needs-triage"] +title: "[Feature] " +labels: ["enhancement", "triage-needed"] body: - type: markdown attributes: diff --git a/docs/configuration/scheduling.rst b/docs/configuration/scheduling.rst index a1275ee190..d96930395e 100644 --- a/docs/configuration/scheduling.rst +++ b/docs/configuration/scheduling.rst @@ -24,11 +24,17 @@ To schedule a dbt project on a time-based schedule, you can use Airflow's schedu Data-Aware Scheduling --------------------- -By default, Cosmos emits `Airflow Datasets `_ when running dbt projects. This allows you to use Airflow's data-aware scheduling capabilities to schedule your dbt projects. Cosmos emits datasets in the following format: +Apache Airflow 2.4 introduced the concept of `scheduling based on Datasets `_. + +By default, if Airflow 2.4 or higher is used, Cosmos emits `Airflow Datasets `_ when running dbt projects. This allows you to use Airflow's data-aware scheduling capabilities to schedule your dbt projects. Cosmos emits datasets using the OpenLineage URI format, as detailed in the `OpenLineage Naming Convention `_. + +Cosmos calculates these URIs during the task execution, by using the library `OpenLineage Integration Common `_. + +This block illustrates a Cosmos-generated dataset for Postgres: .. code-block:: python - Dataset("DBT://{connection_id}/{project_name}/{model_name}") + Dataset("postgres://host:5432/database.schema.table") For example, let's say you have: @@ -36,11 +42,13 @@ For example, let's say you have: - A dbt project (``project_one``) with a model called ``my_model`` that runs daily - A second dbt project (``project_two``) with a model called ``my_other_model`` that you want to run immediately after ``my_model`` +We are assuming that the Database used is Postgres, the host is ``host``, the database is ``database`` and the schema is ``schema``. + Then, you can use Airflow's data-aware scheduling capabilities to schedule ``my_other_model`` to run after ``my_model``. For example, you can use the following DAGs: .. code-block:: python - from cosmos import DbtDag, get_dbt_dataset + from cosmos import DbtDag project_one = DbtDag( # ... @@ -49,10 +57,7 @@ Then, you can use Airflow's data-aware scheduling capabilities to schedule ``my_ ) project_two = DbtDag( - # for airflow <=2.3 - # schedule=[get_dbt_dataset("my_conn", "project_one", "my_model")], - # for airflow > 2.3 - schedule=[get_dbt_dataset("my_conn", "project_one", "my_model")], + schedule=[Dataset("postgres://host:5432/database.schema.my_model")], dbt_project_name="project_two", ) From 83802f45701338eb769cdf9b1158f74fd80acb97 Mon Sep 17 00:00:00 2001 From: Pankaj Koti Date: Fri, 7 Jun 2024 19:26:37 +0530 Subject: [PATCH 193/223] Release 1.4.3 (#1035) Bug fixes * Bring back ``dataset`` as a required field for BigQuery profile by @pankajkoti in #1033 Enhancements * Only run ``dbt deps`` when there are dependencies by @tatiana in #1030 Docs * Fix docs so it does not reference non-existing ``get_dbt_dataset`` by @tatiana in #1034 --------- Co-authored-by: Tatiana Al-Chueyr --- CHANGELOG.rst | 16 ++++++++++++++++ cosmos/__init__.py | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 2e6331edad..7757d5fb58 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,22 @@ Changelog ========= +1.4.3 (2024-06-07) +----------------- + +Bug fixes + +* Bring back ``dataset`` as a required field for BigQuery profile by @pankajkoti in #1033 + +Enhancements + +* Only run ``dbt deps`` when there are dependencies by @tatiana in #1030 + +Docs + +* Fix docs so it does not reference non-existing ``get_dbt_dataset`` by @tatiana in #1034 + + 1.4.2 (2024-06-06) ------------------ diff --git a/cosmos/__init__.py b/cosmos/__init__.py index 7a73e722ef..100649bfbe 100644 --- a/cosmos/__init__.py +++ b/cosmos/__init__.py @@ -5,7 +5,7 @@ Contains dags, task groups, and operators. """ -__version__ = "1.4.2" +__version__ = "1.4.3" from cosmos.airflow.dag import DbtDag From d77d957bd465843716dd4074e26c1ab30b4f4bb3 Mon Sep 17 00:00:00 2001 From: Daniel Reeves <31971762+dwreeves@users.noreply.github.com> Date: Mon, 10 Jun 2024 04:37:25 -0400 Subject: [PATCH 194/223] Use uv in CI (#1013) The CI takes a really long time to run sometimes: image Seems to run a a little bit faster (shaves about 30 seconds off each run), but Hatch (not uv) is still getting stuck on resolving the Airflow 2.7 dependencies. --- .github/workflows/test.yml | 18 ++++++++++++------ scripts/test/pre-install-airflow.sh | 15 +++++++++++++-- 2 files changed, 25 insertions(+), 8 deletions(-) mode change 100644 => 100755 scripts/test/pre-install-airflow.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e38cd71570..dcac01496c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -76,7 +76,8 @@ jobs: - name: Install packages and dependencies run: | - python -m pip install hatch + python -m pip install uv + uv pip install --system hatch hatch -e tests.py${{ matrix.python-version }}-${{ matrix.airflow-version }} run pip freeze - name: Test Cosmos against Airflow ${{ matrix.airflow-version }} and Python ${{ matrix.python-version }} @@ -129,7 +130,8 @@ jobs: - name: Install packages and dependencies run: | - python -m pip install hatch + python -m pip install uv + uv pip install --system hatch hatch -e tests.py${{ matrix.python-version }}-${{ matrix.airflow-version }} run pip freeze - name: Test Cosmos against Airflow ${{ matrix.airflow-version }} and Python ${{ matrix.python-version }} @@ -198,7 +200,8 @@ jobs: - name: Install packages and dependencies run: | - python -m pip install hatch + python -m pip install uv + uv pip install --system hatch hatch -e tests.py${{ matrix.python-version }}-${{ matrix.airflow-version }} run pip freeze - name: Test Cosmos against Airflow ${{ matrix.airflow-version }} and Python ${{ matrix.python-version }} @@ -259,7 +262,8 @@ jobs: - name: Install packages and dependencies run: | - python -m pip install hatch + python -m pip install uv + uv pip install --system hatch hatch -e tests.py${{ matrix.python-version }}-${{ matrix.airflow-version }} run pip freeze - name: Test Cosmos against Airflow ${{ matrix.airflow-version }} and Python ${{ matrix.python-version }} @@ -332,7 +336,8 @@ jobs: - name: Install packages and dependencies run: | - python -m pip install hatch + python -m pip install uv + uv pip install --system hatch hatch -e tests.py${{ matrix.python-version }}-${{ matrix.airflow-version }} run pip freeze - name: Test Cosmos against Airflow ${{ matrix.airflow-version }}, Python ${{ matrix.python-version }} and dbt 1.5.4 @@ -404,7 +409,8 @@ jobs: - name: Install packages and dependencies run: | - python -m pip install hatch + python -m pip install uv + uv pip install --system hatch hatch -e tests.py${{ matrix.python-version }}-${{ matrix.airflow-version }} run pip freeze - name: Run performance tests against against Airflow ${{ matrix.airflow-version }} and Python ${{ matrix.python-version }} diff --git a/scripts/test/pre-install-airflow.sh b/scripts/test/pre-install-airflow.sh old mode 100644 new mode 100755 index de29703df2..08dcf042d6 --- a/scripts/test/pre-install-airflow.sh +++ b/scripts/test/pre-install-airflow.sh @@ -3,12 +3,23 @@ AIRFLOW_VERSION="$1" PYTHON_VERSION="$2" +# Use this to set the appropriate Python environment in Github Actions, +# while also not assuming --system when running locally. +if [ "$GITHUB_ACTIONS" = "true" ] && [ -z "${VIRTUAL_ENV}" ]; then + py_path=$(which python) + virtual_env_dir=$(dirname "$(dirname "$py_path")") + export VIRTUAL_ENV="$virtual_env_dir" +fi + +echo "${VIRTUAL_ENV}" + CONSTRAINT_URL="https://raw.githubusercontent.com/apache/airflow/constraints-$AIRFLOW_VERSION.0/constraints-$PYTHON_VERSION.txt" curl -sSL $CONSTRAINT_URL -o /tmp/constraint.txt # Workaround to remove PyYAML constraint that will work on both Linux and MacOS sed '/PyYAML==/d' /tmp/constraint.txt > /tmp/constraint.txt.tmp mv /tmp/constraint.txt.tmp /tmp/constraint.txt # Install Airflow with constraints -pip install apache-airflow==$AIRFLOW_VERSION --constraint /tmp/constraint.txt -pip install pydantic --constraint /tmp/constraint.txt +pip install uv +uv pip install "apache-airflow==$AIRFLOW_VERSION" --constraint /tmp/constraint.txt +uv pip install pydantic --constraint /tmp/constraint.txt rm /tmp/constraint.txt From 7f011cd1bc692b6860eb62f503e8e94c283121e4 Mon Sep 17 00:00:00 2001 From: Daniel Reeves <31971762+dwreeves@users.noreply.github.com> Date: Mon, 10 Jun 2024 04:38:35 -0400 Subject: [PATCH 195/223] support static_index.html docs (#999) - Support `static_index.html` for dbt docs. - Refactor remote filesystem access functions in anticipation of moving them out of `cosmos/plugins/__init__.py`. Refactoring is designed to make them behave a little more predictably and to make them look a little more like Airflow 2.8+'s `ObjectStoragePath` class. Of course, this is far, far from complete. # Related Issue(s) - Main: #986 - Related: #927 --- cosmos/plugin/__init__.py | 86 ++++++++++++----- cosmos/settings.py | 1 + docs/configuration/hosting-docs.rst | 8 ++ pyproject.toml | 2 + tests/plugin/test_plugin.py | 143 +++++++++++++++++++++------- 5 files changed, 181 insertions(+), 59 deletions(-) diff --git a/cosmos/plugin/__init__.py b/cosmos/plugin/__init__.py index d05e15dd6f..a40d07c7f7 100644 --- a/cosmos/plugin/__init__.py +++ b/cosmos/plugin/__init__.py @@ -9,7 +9,7 @@ from flask import abort, url_for from flask_appbuilder import AppBuilder, expose -from cosmos.settings import dbt_docs_conn_id, dbt_docs_dir +from cosmos.settings import dbt_docs_conn_id, dbt_docs_dir, dbt_docs_index_file_name def bucket_and_key(path: str) -> Tuple[str, str]: @@ -19,32 +19,43 @@ def bucket_and_key(path: str) -> Tuple[str, str]: return bucket, key -def open_s3_file(conn_id: Optional[str], path: str) -> str: +def open_s3_file(path: str, conn_id: Optional[str]) -> str: from airflow.providers.amazon.aws.hooks.s3 import S3Hook + from botocore.exceptions import ClientError if conn_id is None: conn_id = S3Hook.default_conn_name hook = S3Hook(aws_conn_id=conn_id) bucket, key = bucket_and_key(path) - content = hook.read_key(key=key, bucket_name=bucket) + try: + content = hook.read_key(key=key, bucket_name=bucket) + except ClientError as e: + if e.response.get("Error", {}).get("Code", "") == "NoSuchKey": + raise FileNotFoundError(f"{path} does not exist") + raise e return content # type: ignore[no-any-return] -def open_gcs_file(conn_id: Optional[str], path: str) -> str: +def open_gcs_file(path: str, conn_id: Optional[str]) -> str: from airflow.providers.google.cloud.hooks.gcs import GCSHook + from google.cloud.exceptions import NotFound if conn_id is None: conn_id = GCSHook.default_conn_name hook = GCSHook(gcp_conn_id=conn_id) bucket, blob = bucket_and_key(path) - content = hook.download(bucket_name=bucket, object_name=blob) + try: + content = hook.download(bucket_name=bucket, object_name=blob) + except NotFound: + raise FileNotFoundError(f"{path} does not exist") return content.decode("utf-8") # type: ignore[no-any-return] -def open_azure_file(conn_id: Optional[str], path: str) -> str: +def open_azure_file(path: str, conn_id: Optional[str]) -> str: from airflow.providers.microsoft.azure.hooks.wasb import WasbHook + from azure.core.exceptions import ResourceNotFoundError if conn_id is None: conn_id = WasbHook.default_conn_name @@ -52,32 +63,45 @@ def open_azure_file(conn_id: Optional[str], path: str) -> str: hook = WasbHook(wasb_conn_id=conn_id) container, blob = bucket_and_key(path) - content = hook.read_file(container_name=container, blob_name=blob) + try: + content = hook.read_file(container_name=container, blob_name=blob) + except ResourceNotFoundError: + raise FileNotFoundError(f"{path} does not exist") return content # type: ignore[no-any-return] -def open_http_file(conn_id: Optional[str], path: str) -> str: +def open_http_file(path: str, conn_id: Optional[str]) -> str: from airflow.providers.http.hooks.http import HttpHook + from requests.exceptions import HTTPError if conn_id is None: conn_id = "" hook = HttpHook(method="GET", http_conn_id=conn_id) - res = hook.run(endpoint=path) - hook.check_response(res) + try: + res = hook.run(endpoint=path) + hook.check_response(res) + except HTTPError as e: + if str(e).startswith("404"): + raise FileNotFoundError(f"{path} does not exist") + raise e return res.text # type: ignore[no-any-return] -def open_file(path: str) -> str: - """Retrieve a file from http, https, gs, s3, or wasb.""" +def open_file(path: str, conn_id: Optional[str] = None) -> str: + """ + Retrieve a file from http, https, gs, s3, or wasb. + + Raise a (base Python) FileNotFoundError if the file is not found. + """ if path.strip().startswith("s3://"): - return open_s3_file(conn_id=dbt_docs_conn_id, path=path) + return open_s3_file(path, conn_id=conn_id) elif path.strip().startswith("gs://"): - return open_gcs_file(conn_id=dbt_docs_conn_id, path=path) + return open_gcs_file(path, conn_id=conn_id) elif path.strip().startswith("wasb://"): - return open_azure_file(conn_id=dbt_docs_conn_id, path=path) + return open_azure_file(path, conn_id=conn_id) elif path.strip().startswith("http://") or path.strip().startswith("https://"): - return open_http_file(conn_id=dbt_docs_conn_id, path=path) + return open_http_file(path, conn_id=conn_id) else: with open(path) as f: content = f.read() @@ -167,27 +191,39 @@ def dbt_docs(self) -> str: def dbt_docs_index(self) -> str: if dbt_docs_dir is None: abort(404) - html = open_file(op.join(dbt_docs_dir, "index.html")) - # Hack the dbt docs to render properly in an iframe - iframe_resizer_url = url_for(".static", filename="iframeResizer.contentWindow.min.js") - html = html.replace("", f'{iframe_script}', 1) - return html + try: + html = open_file(op.join(dbt_docs_dir, dbt_docs_index_file_name), conn_id=dbt_docs_conn_id) + except FileNotFoundError: + abort(404) + else: + # Hack the dbt docs to render properly in an iframe + iframe_resizer_url = url_for(".static", filename="iframeResizer.contentWindow.min.js") + html = html.replace("", f'{iframe_script}', 1) + return html @expose("/catalog.json") # type: ignore[misc] @has_access([(permissions.ACTION_CAN_READ, permissions.RESOURCE_WEBSITE)]) def catalog(self) -> Tuple[str, int, Dict[str, Any]]: if dbt_docs_dir is None: abort(404) - data = open_file(op.join(dbt_docs_dir, "catalog.json")) - return data, 200, {"Content-Type": "application/json"} + try: + data = open_file(op.join(dbt_docs_dir, "catalog.json"), conn_id=dbt_docs_conn_id) + except FileNotFoundError: + abort(404) + else: + return data, 200, {"Content-Type": "application/json"} @expose("/manifest.json") # type: ignore[misc] @has_access([(permissions.ACTION_CAN_READ, permissions.RESOURCE_WEBSITE)]) def manifest(self) -> Tuple[str, int, Dict[str, Any]]: if dbt_docs_dir is None: abort(404) - data = open_file(op.join(dbt_docs_dir, "manifest.json")) - return data, 200, {"Content-Type": "application/json"} + try: + data = open_file(op.join(dbt_docs_dir, "manifest.json"), conn_id=dbt_docs_conn_id) + except FileNotFoundError: + abort(404) + else: + return data, 200, {"Content-Type": "application/json"} dbt_docs_view = DbtDocsView() diff --git a/cosmos/settings.py b/cosmos/settings.py index 369913b932..fc59541315 100644 --- a/cosmos/settings.py +++ b/cosmos/settings.py @@ -14,6 +14,7 @@ propagate_logs = conf.getboolean("cosmos", "propagate_logs", fallback=True) dbt_docs_dir = conf.get("cosmos", "dbt_docs_dir", fallback=None) dbt_docs_conn_id = conf.get("cosmos", "dbt_docs_conn_id", fallback=None) +dbt_docs_index_file_name = conf.get("cosmos", "dbt_docs_index_file_name", fallback="index.html") try: LINEAGE_NAMESPACE = conf.get("openlineage", "namespace") diff --git a/docs/configuration/hosting-docs.rst b/docs/configuration/hosting-docs.rst index 2ab4fdf69e..755bfe815d 100644 --- a/docs/configuration/hosting-docs.rst +++ b/docs/configuration/hosting-docs.rst @@ -34,6 +34,14 @@ or as an environment variable: The path can be either a folder in the local file system the webserver is running on, or a URI to a cloud storage platform (S3, GCS, Azure). +If your docs were generated using the ``--static`` flag, you can set the index filename using ``dbt_docs_index_file_name``: + +.. code-block:: cfg + + [cosmos] + dbt_docs_index_file_name = static_index.html + + Host from Cloud Storage ~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/pyproject.toml b/pyproject.toml index ea97a9c0c4..c91b6abb34 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ dependencies = [ "importlib-metadata; python_version < '3.8'", "Jinja2>=3.0.0", "msgpack", + "packaging", "pydantic>=1.10.0", "typing-extensions; python_version < '3.8'", "virtualenv", @@ -127,6 +128,7 @@ dependencies = [ "apache-airflow-providers-cncf-kubernetes>=5.1.1", "apache-airflow-providers-amazon>=3.0.0,<8.20.0", # https://github.com/apache/airflow/issues/39103 "apache-airflow-providers-docker>=3.5.0", + "apache-airflow-providers-google", "apache-airflow-providers-microsoft-azure", "apache-airflow-providers-postgres", "types-PyYAML", diff --git a/tests/plugin/test_plugin.py b/tests/plugin/test_plugin.py index 796bfff8d0..c15d183bd0 100644 --- a/tests/plugin/test_plugin.py +++ b/tests/plugin/test_plugin.py @@ -14,10 +14,10 @@ jinja2.escape = markupsafe.escape import sys +from importlib.util import find_spec from unittest.mock import MagicMock, PropertyMock, mock_open, patch import pytest -from airflow.configuration import conf from airflow.utils.db import initdb, resetdb from airflow.www.app import cached_app from airflow.www.extensions.init_appbuilder import AirflowAppBuilder @@ -34,8 +34,6 @@ open_s3_file, ) -original_conf_get = conf.get - def _get_text_from_response(response) -> str: # Airflow < 2.4 uses an old version of Werkzeug that does not have Response.text. @@ -64,13 +62,6 @@ def app() -> FlaskClient: def test_dbt_docs(monkeypatch, app): - def conf_get(section, key, *args, **kwargs): - if section == "cosmos" and key == "dbt_docs_dir": - return "path/to/docs/dir" - else: - return original_conf_get(section, key, *args, **kwargs) - - monkeypatch.setattr(conf, "get", conf_get) monkeypatch.setattr("cosmos.plugin.dbt_docs_dir", "path/to/docs/dir") response = app.get("/cosmos/dbt_docs") @@ -90,28 +81,37 @@ def test_dbt_docs_not_set_up(monkeypatch, app): @patch.object(cosmos.plugin, "open_file") @pytest.mark.parametrize("artifact", ["dbt_docs_index.html", "manifest.json", "catalog.json"]) def test_dbt_docs_artifact(mock_open_file, monkeypatch, app, artifact): - def conf_get(section, key, *args, **kwargs): - if section == "cosmos" and key == "dbt_docs_dir": - return "path/to/docs/dir" - else: - return original_conf_get(section, key, *args, **kwargs) - - monkeypatch.setattr(conf, "get", conf_get) monkeypatch.setattr("cosmos.plugin.dbt_docs_dir", "path/to/docs/dir") + monkeypatch.setattr("cosmos.plugin.dbt_docs_conn_id", "mock_conn_id") + monkeypatch.setattr("cosmos.plugin.dbt_docs_index_file_name", "custom_index.html") if artifact == "dbt_docs_index.html": mock_open_file.return_value = "" + storage_path = "path/to/docs/dir/custom_index.html" else: mock_open_file.return_value = "{}" + storage_path = f"path/to/docs/dir/{artifact}" response = app.get(f"/cosmos/{artifact}") - mock_open_file.assert_called_once() + mock_open_file.assert_called_once_with(storage_path, conn_id="mock_conn_id") assert response.status_code == 200 if artifact == "dbt_docs_index.html": assert iframe_script in _get_text_from_response(response) +@patch.object(cosmos.plugin, "open_file") +@pytest.mark.parametrize("artifact", ["dbt_docs_index.html", "manifest.json", "catalog.json"]) +def test_dbt_docs_artifact_not_found(mock_open_file, monkeypatch, app, artifact): + monkeypatch.setattr("cosmos.plugin.dbt_docs_dir", "path/to/docs/dir") + mock_open_file.side_effect = FileNotFoundError + + response = app.get(f"/cosmos/{artifact}") + + mock_open_file.assert_called_once() + assert response.status_code == 404 + + @pytest.mark.parametrize("artifact", ["dbt_docs_index.html", "manifest.json", "catalog.json"]) def test_dbt_docs_artifact_missing(app, artifact): response = app.get(f"/cosmos/{artifact}") @@ -128,21 +128,12 @@ def test_dbt_docs_artifact_missing(app, artifact): ("https://my-bucket/my/path/", "open_http_file"), ], ) -def test_open_file_calls(path, open_file_callback, monkeypatch): - def conf_get(section, key, *args, **kwargs): - if section == "cosmos" and key == "dbt_docs_conn_id": - return "mock_conn_id" - else: - return original_conf_get(section, key, *args, **kwargs) - - monkeypatch.setattr(conf, "get", conf_get) - monkeypatch.setattr("cosmos.plugin.dbt_docs_conn_id", "mock_conn_id") - +def test_open_file_calls(path, open_file_callback): with patch.object(cosmos.plugin, open_file_callback) as mock_callback: mock_callback.return_value = "mock file contents" - res = open_file(path) + res = open_file(path, conn_id="mock_conn_id") - mock_callback.assert_called_with(conn_id="mock_conn_id", path=path) + mock_callback.assert_called_with(path, conn_id="mock_conn_id") assert res == "mock file contents" @@ -153,7 +144,7 @@ def test_open_s3_file(conn_id): mock_hook = mock_module.S3Hook.return_value mock_hook.read_key.return_value = "mock file contents" - res = open_s3_file(conn_id=conn_id, path="s3://mock-path/to/docs") + res = open_s3_file("s3://mock-path/to/docs", conn_id=conn_id) if conn_id is not None: mock_module.S3Hook.assert_called_once_with(aws_conn_id=conn_id) @@ -162,6 +153,28 @@ def test_open_s3_file(conn_id): assert res == "mock file contents" +@pytest.mark.skipif( + find_spec("airflow.providers.google") is None, + reason="apache-airflow-providers-amazon not installed, which is required for this test.", +) +def test_open_s3_file_not_found(): + from botocore.exceptions import ClientError + + mock_module = MagicMock() + with patch.dict(sys.modules, {"airflow.providers.amazon.aws.hooks.s3": mock_module}): + mock_hook = mock_module.S3Hook.return_value + + def side_effect(*args, **kwargs): + raise ClientError({"Error": {"Code": "NoSuchKey"}}, "") + + mock_hook.read_key.side_effect = side_effect + + with pytest.raises(FileNotFoundError): + open_s3_file("s3://mock-path/to/docs", conn_id="mock-conn-id") + + mock_module.S3Hook.assert_called_once() + + @pytest.mark.parametrize("conn_id", ["mock_conn_id", None]) def test_open_gcs_file(conn_id): mock_module = MagicMock() @@ -169,7 +182,7 @@ def test_open_gcs_file(conn_id): mock_hook = mock_module.GCSHook.return_value = MagicMock() mock_hook.download.return_value = b"mock file contents" - res = open_gcs_file(conn_id=conn_id, path="gs://mock-path/to/docs") + res = open_gcs_file("gs://mock-path/to/docs", conn_id=conn_id) if conn_id is not None: mock_module.GCSHook.assert_called_once_with(gcp_conn_id=conn_id) @@ -178,6 +191,28 @@ def test_open_gcs_file(conn_id): assert res == "mock file contents" +@pytest.mark.skipif( + find_spec("airflow.providers.google") is None, + reason="apache-airflow-providers-google not installed, which is required for this test.", +) +def test_open_gcs_file_not_found(): + from google.cloud.exceptions import NotFound + + mock_module = MagicMock() + with patch.dict(sys.modules, {"airflow.providers.google.cloud.hooks.gcs": mock_module}): + mock_hook = mock_module.GCSHook.return_value = MagicMock() + + def side_effect(*args, **kwargs): + raise NotFound("") + + mock_hook.download.side_effect = side_effect + + with pytest.raises(FileNotFoundError): + open_gcs_file("gs://mock-path/to/docs", conn_id="mock-conn-id") + + mock_module.GCSHook.assert_called_once() + + @pytest.mark.parametrize("conn_id", ["mock_conn_id", None]) def test_open_azure_file(conn_id): mock_module = MagicMock() @@ -186,7 +221,7 @@ def test_open_azure_file(conn_id): mock_hook.default_conn_name = PropertyMock(return_value="default_conn") mock_hook.read_file.return_value = "mock file contents" - res = open_azure_file(conn_id=conn_id, path="wasb://mock-path/to/docs") + res = open_azure_file("wasb://mock-path/to/docs", conn_id=conn_id) if conn_id is not None: mock_module.WasbHook.assert_called_once_with(wasb_conn_id=conn_id) @@ -195,6 +230,25 @@ def test_open_azure_file(conn_id): assert res == "mock file contents" +@pytest.mark.skipif( + find_spec("airflow.providers.microsoft") is None, + reason="apache-airflow-providers-microsoft not installed, which is required for this test.", +) +def test_open_azure_file_not_found(): + from azure.core.exceptions import ResourceNotFoundError + + mock_module = MagicMock() + with patch.dict(sys.modules, {"airflow.providers.microsoft.azure.hooks.wasb": mock_module}): + mock_hook = mock_module.WasbHook.return_value = MagicMock() + + mock_hook.read_file.side_effect = ResourceNotFoundError + + with pytest.raises(FileNotFoundError): + open_azure_file("wasb://mock-path/to/docs", conn_id="mock-conn-id") + + mock_module.WasbHook.assert_called_once() + + @pytest.mark.parametrize("conn_id", ["mock_conn_id", None]) def test_open_http_file(conn_id): mock_module = MagicMock() @@ -205,7 +259,7 @@ def test_open_http_file(conn_id): mock_hook.check_response.return_value = mock_response mock_response.text = "mock file contents" - res = open_http_file(conn_id=conn_id, path="http://mock-path/to/docs") + res = open_http_file("http://mock-path/to/docs", conn_id=conn_id) if conn_id is not None: mock_module.HttpHook.assert_called_once_with(method="GET", http_conn_id=conn_id) @@ -216,6 +270,27 @@ def test_open_http_file(conn_id): assert res == "mock file contents" +def test_open_http_file_not_found(): + from requests.exceptions import HTTPError + + mock_module = MagicMock() + with patch.dict(sys.modules, {"airflow.providers.http.hooks.http": mock_module}): + mock_hook = mock_module.HttpHook.return_value = MagicMock() + + def side_effect(*args, **kwargs): + raise HTTPError("404 Client Error: Not Found for url: https://google.com/this/is/a/fake/path") + + mock_hook.run.side_effect = side_effect + + with pytest.raises(FileNotFoundError): + open_http_file("https://google.com/this/is/a/fake/path", conn_id="mock-conn-id") + + mock_module.HttpHook.assert_called_once() + + +"404 Client Error: Not Found for url: https://google.com/ashjdfasdkfahdjsf" + + @patch("builtins.open", new_callable=mock_open, read_data="mock file contents") def test_open_file_local(mock_file): res = open_file("/my/path") From e798f82744e0bb9caf2b43e27f9ce76f560be0fb Mon Sep 17 00:00:00 2001 From: Daniel Reeves <31971762+dwreeves@users.noreply.github.com> Date: Mon, 10 Jun 2024 07:08:46 -0400 Subject: [PATCH 196/223] Add deep linking of dbt docs (#1038) Ties the URL in the browser to the URL of the dbt docs Resolves #1029 --- cosmos/plugin/__init__.py | 20 ++++++++++++++++++++ cosmos/plugin/templates/dbt_docs.html | 22 +++++++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/cosmos/plugin/__init__.py b/cosmos/plugin/__init__.py index a40d07c7f7..3701f15728 100644 --- a/cosmos/plugin/__init__.py +++ b/cosmos/plugin/__init__.py @@ -163,6 +163,26 @@ def open_file(path: str, conn_id: Optional[str] = None) -> str: ) } } + + // Prevent parent hash changes from sending a message back to the parent. + // This is necessary for making sure the browser back button works properly. + let hashChangeLock = true; + + window.addEventListener('hashchange', function () { + if (!hashChangeLock) { + window.parent.postMessage(window.location.hash); + } + hashChangeLock = false; + }); + window.addEventListener('message', function (event) { + let msgData = event.data; + if (typeof msgData === 'string' && msgData.startsWith('#!')) { + let updateUrl = new URL(window.location); + updateUrl.hash = msgData; + hashChangeLock = true; + history.replaceState(null, null, updateUrl); + } + }); """ diff --git a/cosmos/plugin/templates/dbt_docs.html b/cosmos/plugin/templates/dbt_docs.html index 214d88e4a4..fe3f794b6a 100644 --- a/cosmos/plugin/templates/dbt_docs.html +++ b/cosmos/plugin/templates/dbt_docs.html @@ -1,4 +1,5 @@ {% extends base_template %} +{% block page_title %}dbt docs - {{ appbuilder.app_name }}{% endblock %} {% block content %} @@ -10,6 +11,25 @@ minHeight: 500 }, '#dbtIframe' - ) + ); + + window.addEventListener('message', function (event) { + let msgData = event.data; + if (msgData.startsWith('#!')) { + let updateUrl = new URL(window.location); + updateUrl.hash = msgData; + history.replaceState(null, null, updateUrl); + } + }); + + window.addEventListener('popstate', function () { + dbtIframe.contentWindow.postMessage(window.location.hash); + }); + + let dbtIframe = document.getElementById('dbtIframe'); + let iframeUrl = new URL(dbtIframe.src); + iframeUrl.hash = window.location.hash; + dbtIframe.src = iframeUrl.href; + {% endblock %} From 00ac2501450bd9590177adc8295b9e5eef12ca1e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 10 Jun 2024 22:25:01 +0100 Subject: [PATCH 197/223] =?UTF-8?q?=E2=AC=86=20[pre-commit.ci]=20pre-commi?= =?UTF-8?q?t=20autoupdate=20(#1039)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/asottile/pyupgrade: v3.15.2 → v3.16.0](https://github.com/asottile/pyupgrade/compare/v3.15.2...v3.16.0) - [github.com/astral-sh/ruff-pre-commit: v0.4.7 → v0.4.8](https://github.com/astral-sh/ruff-pre-commit/compare/v0.4.7...v0.4.8) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ad4bb2c650..b67c861451 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -47,14 +47,14 @@ repos: - id: remove-tabs exclude: ^docs/make.bat$|^docs/Makefile$|^dev/dags/dbt/jaffle_shop/seeds/raw_orders.csv$ - repo: https://github.com/asottile/pyupgrade - rev: v3.15.2 + rev: v3.16.0 hooks: - id: pyupgrade args: - --py37-plus - --keep-runtime-typing - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.4.7 + rev: v0.4.8 hooks: - id: ruff args: From 0c0756ac9519268c1f54eaffcaaf468d71b7dcbb Mon Sep 17 00:00:00 2001 From: Arjun Anandkumar <102953522+arjunanan6@users.noreply.github.com> Date: Fri, 14 Jun 2024 11:54:56 +0200 Subject: [PATCH 198/223] Update documentation for DbtDocs generator (#1043) t isn't particularly clear on how to choose a folder on the specified bucket for any of the cloud storage options. It is of course present in the source, but I think it would be a nice addition to have on the documentation page. --- docs/configuration/generating-docs.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/configuration/generating-docs.rst b/docs/configuration/generating-docs.rst index 69b38be15b..208280cbb9 100644 --- a/docs/configuration/generating-docs.rst +++ b/docs/configuration/generating-docs.rst @@ -85,6 +85,12 @@ You can use the :class:`~cosmos.operators.DbtDocsGCSOperator` to generate and up bucket_name="test_bucket", ) +Choosing a folder +~~~~~~~~~~~~~~~~~~~~~~~ + +All the DbtDocsOperators support specification of a custom folder (prefix) to place documentation in on the target cloud storage. This can be done by +adding a ``folder_dir`` parameter to the operator definition. + Static Flag ~~~~~~~~~~~~~~~~~~~~~~~ From 47ab25aa51c32695d92b80b71e6ef3fed455e8e0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 18 Jun 2024 09:56:42 +0530 Subject: [PATCH 199/223] =?UTF-8?q?=E2=AC=86=20[pre-commit.ci]=20pre-commi?= =?UTF-8?q?t=20autoupdate=20(#1050)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.4.8 → v0.4.9](https://github.com/astral-sh/ruff-pre-commit/compare/v0.4.8...v0.4.9) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b67c861451..8903f52035 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -54,7 +54,7 @@ repos: - --py37-plus - --keep-runtime-typing - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.4.8 + rev: v0.4.9 hooks: - id: ruff args: From a1a0ea033e24c4e66ddce9607a2a0cd39fff29af Mon Sep 17 00:00:00 2001 From: Pankaj Singh <98807258+pankajastro@users.noreply.github.com> Date: Tue, 18 Jun 2024 14:00:32 +0530 Subject: [PATCH 200/223] Ensure compliance with linting rule D300 by using triple quotes for docstrings (#1049) Enable D300 to make docstring more consistent --- cosmos/config.py | 6 +++--- cosmos/dbt/selector.py | 2 +- cosmos/exceptions.py | 2 +- cosmos/operators/local.py | 8 ++++---- cosmos/profiles/__init__.py | 2 +- cosmos/profiles/athena/__init__.py | 2 +- cosmos/profiles/athena/access_key.py | 7 ++++--- cosmos/profiles/base.py | 6 +++--- cosmos/profiles/bigquery/__init__.py | 2 +- cosmos/profiles/bigquery/oauth.py | 7 ++++--- cosmos/profiles/bigquery/service_account_file.py | 7 ++++--- cosmos/profiles/bigquery/service_account_keyfile_dict.py | 7 ++++--- cosmos/profiles/databricks/__init__.py | 2 +- cosmos/profiles/databricks/token.py | 7 ++++--- cosmos/profiles/exasol/__init__.py | 2 +- cosmos/profiles/exasol/user_pass.py | 7 ++++--- cosmos/profiles/postgres/__init__.py | 2 +- cosmos/profiles/postgres/user_pass.py | 7 ++++--- cosmos/profiles/redshift/__init__.py | 2 +- cosmos/profiles/redshift/user_pass.py | 7 ++++--- cosmos/profiles/snowflake/__init__.py | 2 +- .../snowflake/user_encrypted_privatekey_env_variable.py | 7 ++++--- .../profiles/snowflake/user_encrypted_privatekey_file.py | 7 ++++--- cosmos/profiles/snowflake/user_pass.py | 7 ++++--- cosmos/profiles/snowflake/user_privatekey.py | 7 ++++--- cosmos/profiles/spark/__init__.py | 2 +- cosmos/profiles/spark/thrift.py | 3 ++- cosmos/profiles/trino/__init__.py | 2 +- cosmos/profiles/trino/base.py | 9 +++++---- cosmos/profiles/trino/certificate.py | 5 +++-- cosmos/profiles/trino/jwt.py | 5 +++-- cosmos/profiles/trino/ldap.py | 3 ++- cosmos/profiles/vertica/__init__.py | 2 +- cosmos/profiles/vertica/user_pass.py | 7 ++++--- dev/dags/dbt_docs.py | 2 +- docs/generate_mappings.py | 3 ++- pyproject.toml | 2 +- tests/profiles/athena/test_athena_access_key.py | 3 ++- tests/profiles/bigquery/test_bq_oauth.py | 2 +- tests/profiles/bigquery/test_bq_service_account_file.py | 2 +- tests/profiles/databricks/test_dbr_token.py | 2 +- tests/profiles/exasol/test_exasol_user_pass.py | 2 +- tests/profiles/postgres/test_pg_user_pass.py | 2 +- tests/profiles/redshift/test_redshift_user_pass.py | 2 +- ...t_snowflake_user_encrypted_privatekey_env_variable.py | 2 +- .../test_snowflake_user_encrypted_privatekey_file.py | 2 +- tests/profiles/snowflake/test_snowflake_user_pass.py | 2 +- .../profiles/snowflake/test_snowflake_user_privatekey.py | 2 +- tests/profiles/spark/test_spark_thrift.py | 2 +- tests/profiles/trino/test_trino_base.py | 2 +- tests/profiles/trino/test_trino_certificate.py | 2 +- tests/profiles/trino/test_trino_jwt.py | 2 +- tests/profiles/trino/test_trino_ldap.py | 2 +- tests/profiles/vertica/test_vertica_user_pass.py | 2 +- 54 files changed, 112 insertions(+), 92 deletions(-) diff --git a/cosmos/config.py b/cosmos/config.py index 820833e6c9..13622563e1 100644 --- a/cosmos/config.py +++ b/cosmos/config.py @@ -244,7 +244,7 @@ def __post_init__(self) -> None: self.validate_profile() def validate_profile(self) -> None: - "Validates that we have enough information to render a profile." + """Validates that we have enough information to render a profile.""" if not self.profiles_yml_filepath and not self.profile_mapping: raise CosmosValueError("Either profiles_yml_filepath or profile_mapping must be set to render a profile") if self.profiles_yml_filepath and self.profile_mapping: @@ -253,7 +253,7 @@ def validate_profile(self) -> None: ) def validate_profiles_yml(self) -> None: - "Validates a user-supplied profiles.yml is present" + """Validates a user-supplied profiles.yml is present""" if self.profiles_yml_filepath and not Path(self.profiles_yml_filepath).exists(): raise CosmosValueError(f"The file {self.profiles_yml_filepath} does not exist.") @@ -261,7 +261,7 @@ def validate_profiles_yml(self) -> None: def ensure_profile( self, desired_profile_path: Path | None = None, use_mock_values: bool = False ) -> Iterator[tuple[Path, dict[str, str]]]: - "Context manager to ensure that there is a profile. If not, create one." + """Context manager to ensure that there is a profile. If not, create one.""" if self.profiles_yml_filepath: logger.info("Using user-supplied profiles.yml at %s", self.profiles_yml_filepath) yield Path(self.profiles_yml_filepath), {} diff --git a/cosmos/dbt/selector.py b/cosmos/dbt/selector.py index 47c118b281..257d60721a 100644 --- a/cosmos/dbt/selector.py +++ b/cosmos/dbt/selector.py @@ -289,7 +289,7 @@ def select_nodes_ids_by_intersection(self) -> set[str]: return selected_nodes def _should_include_node(self, node_id: str, node: DbtNode) -> bool: - "Checks if a single node should be included. Only runs once per node with caching." + """Checks if a single node should be included. Only runs once per node with caching.""" logger.debug("Inspecting if the node <%s> should be included.", node_id) if node_id in self.visited_nodes: return node_id in self.selected_nodes diff --git a/cosmos/exceptions.py b/cosmos/exceptions.py index 85df285b11..308214475a 100644 --- a/cosmos/exceptions.py +++ b/cosmos/exceptions.py @@ -1,4 +1,4 @@ -"Contains exceptions that Cosmos uses" +"""Contains exceptions that Cosmos uses""" class CosmosValueError(ValueError): diff --git a/cosmos/operators/local.py b/cosmos/operators/local.py index 232a6680d1..83c0dfa74b 100644 --- a/cosmos/operators/local.py +++ b/cosmos/operators/local.py @@ -631,7 +631,7 @@ def __init__( folder_dir: str | None = None, **kwargs: Any, ) -> None: - "Initializes the operator." + """Initializes the operator.""" self.connection_id = connection_id self.bucket_name = bucket_name self.folder_dir = folder_dir @@ -674,7 +674,7 @@ def __init__( super().__init__(*args, **kwargs) def upload_to_cloud_storage(self, project_dir: str) -> None: - "Uploads the generated documentation to S3." + """Uploads the generated documentation to S3.""" logger.info( 'Attempting to upload generated docs to S3 using S3Hook("%s")', self.connection_id, @@ -740,7 +740,7 @@ def __init__( super().__init__(*args, **kwargs) def upload_to_cloud_storage(self, project_dir: str) -> None: - "Uploads the generated documentation to Azure Blob Storage." + """Uploads the generated documentation to Azure Blob Storage.""" logger.info( 'Attempting to upload generated docs to Azure Blob Storage using WasbHook(conn_id="%s")', self.connection_id, @@ -784,7 +784,7 @@ class DbtDocsGCSLocalOperator(DbtDocsCloudLocalOperator): ui_color = "#4772d5" def upload_to_cloud_storage(self, project_dir: str) -> None: - "Uploads the generated documentation to Google Cloud Storage" + """Uploads the generated documentation to Google Cloud Storage""" logger.info( 'Attempting to upload generated docs to Storage using GCSHook(conn_id="%s")', self.connection_id, diff --git a/cosmos/profiles/__init__.py b/cosmos/profiles/__init__.py index 5cc3109cc3..b182bacf77 100644 --- a/cosmos/profiles/__init__.py +++ b/cosmos/profiles/__init__.py @@ -1,4 +1,4 @@ -"Contains a function to get the profile mapping based on the connection ID." +"""Contains a function to get the profile mapping based on the connection ID.""" from __future__ import annotations diff --git a/cosmos/profiles/athena/__init__.py b/cosmos/profiles/athena/__init__.py index 0cbb09a7c3..3be305ace6 100644 --- a/cosmos/profiles/athena/__init__.py +++ b/cosmos/profiles/athena/__init__.py @@ -1,4 +1,4 @@ -"Athena Airflow connection -> dbt profile mappings" +"""Athena Airflow connection -> dbt profile mappings""" from .access_key import AthenaAccessKeyProfileMapping diff --git a/cosmos/profiles/athena/access_key.py b/cosmos/profiles/athena/access_key.py index 8dc14f8399..05c2ec0998 100644 --- a/cosmos/profiles/athena/access_key.py +++ b/cosmos/profiles/athena/access_key.py @@ -1,4 +1,5 @@ -"Maps Airflow AWS connections to a dbt Athena profile using an access key id and secret access key." +"""Maps Airflow AWS connections to a dbt Athena profile using an access key id and secret access key.""" + from __future__ import annotations from typing import Any @@ -57,7 +58,7 @@ class AthenaAccessKeyProfileMapping(BaseProfileMapping): @property def profile(self) -> dict[str, Any | None]: - "Gets profile. The password is stored in an environment variable." + """Gets profile. The password is stored in an environment variable.""" self.temporary_credentials = self._get_temporary_credentials() # type: ignore @@ -75,7 +76,7 @@ def profile(self) -> dict[str, Any | None]: @property def env_vars(self) -> dict[str, str]: - "Overwrites the env_vars for athena, Returns a dictionary of environment variables that should be set based on the self.temporary_credentials." + """Overwrites the env_vars for athena, Returns a dictionary of environment variables that should be set based on the self.temporary_credentials.""" if self.temporary_credentials is None: raise CosmosValueError(f"Could not find the athena credentials.") diff --git a/cosmos/profiles/base.py b/cosmos/profiles/base.py index d4b44b591d..7c7b277b14 100755 --- a/cosmos/profiles/base.py +++ b/cosmos/profiles/base.py @@ -122,7 +122,7 @@ def _validate_disable_event_tracking(self) -> None: @property def conn(self) -> Connection: - "Returns the Airflow connection." + """Returns the Airflow connection.""" if not self._conn: conn = BaseHook.get_connection(self.conn_id) if not conn: @@ -197,7 +197,7 @@ def mock_profile(self) -> dict[str, Any]: @property def env_vars(self) -> dict[str, str]: - "Returns a dictionary of environment variables that should be set based on self.secret_fields." + """Returns a dictionary of environment variables that should be set based on self.secret_fields.""" env_vars = {} for field in self.secret_fields: @@ -287,7 +287,7 @@ def get_dbt_value(self, name: str) -> Any: @property def mapped_params(self) -> dict[str, Any]: - "Turns the self.airflow_param_mapping into a dictionary of dbt fields and their values." + """Turns the self.airflow_param_mapping into a dictionary of dbt fields and their values.""" mapped_params = { DBT_PROFILE_TYPE_FIELD: self.dbt_profile_type, } diff --git a/cosmos/profiles/bigquery/__init__.py b/cosmos/profiles/bigquery/__init__.py index b547bbf845..97217ea9d9 100644 --- a/cosmos/profiles/bigquery/__init__.py +++ b/cosmos/profiles/bigquery/__init__.py @@ -1,4 +1,4 @@ -"BigQuery Airflow connection -> dbt profile mappings" +"""BigQuery Airflow connection -> dbt profile mappings""" from .oauth import GoogleCloudOauthProfileMapping from .service_account_file import GoogleCloudServiceAccountFileProfileMapping diff --git a/cosmos/profiles/bigquery/oauth.py b/cosmos/profiles/bigquery/oauth.py index 6ea02cfa4a..f619a28fff 100644 --- a/cosmos/profiles/bigquery/oauth.py +++ b/cosmos/profiles/bigquery/oauth.py @@ -1,4 +1,5 @@ -"Maps Airflow GCP connections to dbt BigQuery profiles that uses oauth via gcloud, if they don't use key file or JSON." +"""Maps Airflow GCP connections to dbt BigQuery profiles that uses oauth via gcloud, if they don't use key file or JSON.""" + from __future__ import annotations from typing import Any @@ -31,7 +32,7 @@ class GoogleCloudOauthProfileMapping(BaseProfileMapping): @property def profile(self) -> dict[str, Any | None]: - "Generates profile. Defaults `threads` to 1." + """Generates profile. Defaults `threads` to 1.""" return { **self.mapped_params, "method": "oauth", @@ -41,7 +42,7 @@ def profile(self) -> dict[str, Any | None]: @property def mock_profile(self) -> dict[str, Any | None]: - "Generates mock profile. Defaults `threads` to 1." + """Generates mock profile. Defaults `threads` to 1.""" parent_mock_profile = super().mock_profile return { diff --git a/cosmos/profiles/bigquery/service_account_file.py b/cosmos/profiles/bigquery/service_account_file.py index 02d00ffae9..fb294a167d 100644 --- a/cosmos/profiles/bigquery/service_account_file.py +++ b/cosmos/profiles/bigquery/service_account_file.py @@ -1,4 +1,5 @@ -"Maps Airflow GCP connections to dbt BigQuery profiles if they use a service account file." +"""Maps Airflow GCP connections to dbt BigQuery profiles if they use a service account file.""" + from __future__ import annotations from typing import Any @@ -32,7 +33,7 @@ class GoogleCloudServiceAccountFileProfileMapping(BaseProfileMapping): @property def profile(self) -> dict[str, Any | None]: - "Generates profile. Defaults `threads` to 1." + """Generates profile. Defaults `threads` to 1.""" return { **self.mapped_params, "threads": 1, @@ -41,7 +42,7 @@ def profile(self) -> dict[str, Any | None]: @property def mock_profile(self) -> dict[str, Any | None]: - "Generates mock profile. Defaults `threads` to 1." + """Generates mock profile. Defaults `threads` to 1.""" parent_mock_profile = super().mock_profile return { diff --git a/cosmos/profiles/bigquery/service_account_keyfile_dict.py b/cosmos/profiles/bigquery/service_account_keyfile_dict.py index 480db669b9..cfa57056b7 100644 --- a/cosmos/profiles/bigquery/service_account_keyfile_dict.py +++ b/cosmos/profiles/bigquery/service_account_keyfile_dict.py @@ -1,4 +1,5 @@ -"Maps Airflow GCP connections to dbt BigQuery profiles if they use a service account keyfile dict/json." +"""Maps Airflow GCP connections to dbt BigQuery profiles if they use a service account keyfile dict/json.""" + from __future__ import annotations import json @@ -57,7 +58,7 @@ def profile(self) -> dict[str, Any | None]: @property def mock_profile(self) -> dict[str, Any | None]: - "Generates mock profile. Defaults `threads` to 1." + """Generates mock profile. Defaults `threads` to 1.""" parent_mock_profile = super().mock_profile return {**parent_mock_profile, "threads": 1, "keyfile_json": None} @@ -86,5 +87,5 @@ def transform_keyfile_json(self, keyfile_json: str | dict[str, str]) -> dict[str @property def env_vars(self) -> dict[str, str]: - "Returns a dictionary of environment variables that should be set based on self.secret_fields." + """Returns a dictionary of environment variables that should be set based on self.secret_fields.""" return self._env_vars diff --git a/cosmos/profiles/databricks/__init__.py b/cosmos/profiles/databricks/__init__.py index dda537ab1e..2e3a9d1143 100644 --- a/cosmos/profiles/databricks/__init__.py +++ b/cosmos/profiles/databricks/__init__.py @@ -1,4 +1,4 @@ -"Databricks Airflow connection -> dbt profile mappings" +"""Databricks Airflow connection -> dbt profile mappings""" from .token import DatabricksTokenProfileMapping diff --git a/cosmos/profiles/databricks/token.py b/cosmos/profiles/databricks/token.py index cf73713027..78d97eb77c 100644 --- a/cosmos/profiles/databricks/token.py +++ b/cosmos/profiles/databricks/token.py @@ -1,4 +1,5 @@ -"Maps Airflow Databricks connections with a token to dbt profiles." +"""Maps Airflow Databricks connections with a token to dbt profiles.""" + from __future__ import annotations from typing import Any @@ -37,7 +38,7 @@ class DatabricksTokenProfileMapping(BaseProfileMapping): @property def profile(self) -> dict[str, Any | None]: - "Generates profile. The token is stored in an environment variable." + """Generates profile. The token is stored in an environment variable.""" return { **self.mapped_params, **self.profile_args, @@ -46,5 +47,5 @@ def profile(self) -> dict[str, Any | None]: } def transform_host(self, host: str) -> str: - "Removes the https:// prefix." + """Removes the https:// prefix.""" return host.replace("https://", "") diff --git a/cosmos/profiles/exasol/__init__.py b/cosmos/profiles/exasol/__init__.py index 48585d7fc5..17062569db 100644 --- a/cosmos/profiles/exasol/__init__.py +++ b/cosmos/profiles/exasol/__init__.py @@ -1,4 +1,4 @@ -"Exasol Airflow connection -> dbt profile mappings" +"""Exasol Airflow connection -> dbt profile mappings""" from .user_pass import ExasolUserPasswordProfileMapping diff --git a/cosmos/profiles/exasol/user_pass.py b/cosmos/profiles/exasol/user_pass.py index 62311221d8..aa5d66ebf3 100644 --- a/cosmos/profiles/exasol/user_pass.py +++ b/cosmos/profiles/exasol/user_pass.py @@ -1,4 +1,5 @@ -"Maps Airflow Exasol connections with a username and password to dbt profiles." +"""Maps Airflow Exasol connections with a username and password to dbt profiles.""" + from __future__ import annotations from typing import Any @@ -45,7 +46,7 @@ class ExasolUserPasswordProfileMapping(BaseProfileMapping): @property def profile(self) -> dict[str, Any | None]: - "Gets profile. The password is stored in an environment variable." + """Gets profile. The password is stored in an environment variable.""" profile_vars = { **self.mapped_params, **self.profile_args, @@ -57,7 +58,7 @@ def profile(self) -> dict[str, Any | None]: return self.filter_null(profile_vars) def transform_dsn(self, host: str) -> str: - "Adds the port if it's not already there." + """Adds the port if it's not already there.""" if ":" not in host: port = self.conn.port or self.default_port return f"{host}:{port}" diff --git a/cosmos/profiles/postgres/__init__.py b/cosmos/profiles/postgres/__init__.py index 3b9a6c895a..2c07cfd109 100644 --- a/cosmos/profiles/postgres/__init__.py +++ b/cosmos/profiles/postgres/__init__.py @@ -1,4 +1,4 @@ -"Postgres Airflow connection -> dbt profile mappings" +"""Postgres Airflow connection -> dbt profile mappings""" from .user_pass import PostgresUserPasswordProfileMapping diff --git a/cosmos/profiles/postgres/user_pass.py b/cosmos/profiles/postgres/user_pass.py index a081ff81a0..c204ff8d4b 100644 --- a/cosmos/profiles/postgres/user_pass.py +++ b/cosmos/profiles/postgres/user_pass.py @@ -1,4 +1,5 @@ -"Maps Airflow Postgres connections using user + password authentication to dbt profiles." +"""Maps Airflow Postgres connections using user + password authentication to dbt profiles.""" + from __future__ import annotations from typing import Any @@ -37,7 +38,7 @@ class PostgresUserPasswordProfileMapping(BaseProfileMapping): @property def profile(self) -> dict[str, Any | None]: - "Gets profile. The password is stored in an environment variable." + """Gets profile. The password is stored in an environment variable.""" profile = { "port": 5432, **self.mapped_params, @@ -53,7 +54,7 @@ def profile(self) -> dict[str, Any | None]: @property def mock_profile(self) -> dict[str, Any | None]: - "Gets mock profile. Defaults port to 5432." + """Gets mock profile. Defaults port to 5432.""" profile_dict = { "port": 5432, **super().mock_profile, diff --git a/cosmos/profiles/redshift/__init__.py b/cosmos/profiles/redshift/__init__.py index 3528e058dd..c434850dc7 100644 --- a/cosmos/profiles/redshift/__init__.py +++ b/cosmos/profiles/redshift/__init__.py @@ -1,4 +1,4 @@ -"Redshift Airflow connection -> dbt profile mappings" +"""Redshift Airflow connection -> dbt profile mappings""" from .user_pass import RedshiftUserPasswordProfileMapping diff --git a/cosmos/profiles/redshift/user_pass.py b/cosmos/profiles/redshift/user_pass.py index 0dd3e115f4..adf89c2c6e 100644 --- a/cosmos/profiles/redshift/user_pass.py +++ b/cosmos/profiles/redshift/user_pass.py @@ -1,4 +1,5 @@ -"Maps Airflow Redshift connections to dbt Redshift profiles if they use a username and password." +"""Maps Airflow Redshift connections to dbt Redshift profiles if they use a username and password.""" + from __future__ import annotations from typing import Any @@ -39,7 +40,7 @@ class RedshiftUserPasswordProfileMapping(BaseProfileMapping): @property def profile(self) -> dict[str, Any | None]: - "Gets profile." + """Gets profile.""" profile = { "port": 5439, **self.mapped_params, @@ -52,7 +53,7 @@ def profile(self) -> dict[str, Any | None]: @property def mock_profile(self) -> dict[str, Any | None]: - "Gets mock profile. Defaults port to 5439." + """Gets mock profile. Defaults port to 5439.""" parent_mock = super().mock_profile return { diff --git a/cosmos/profiles/snowflake/__init__.py b/cosmos/profiles/snowflake/__init__.py index 01745d01ee..7e81a96c7d 100644 --- a/cosmos/profiles/snowflake/__init__.py +++ b/cosmos/profiles/snowflake/__init__.py @@ -1,4 +1,4 @@ -"Snowflake Airflow connection -> dbt profile mapping." +"""Snowflake Airflow connection -> dbt profile mapping.""" from .user_encrypted_privatekey_env_variable import SnowflakeEncryptedPrivateKeyPemProfileMapping from .user_encrypted_privatekey_file import SnowflakeEncryptedPrivateKeyFilePemProfileMapping diff --git a/cosmos/profiles/snowflake/user_encrypted_privatekey_env_variable.py b/cosmos/profiles/snowflake/user_encrypted_privatekey_env_variable.py index fecfa97fe7..70722dd59a 100644 --- a/cosmos/profiles/snowflake/user_encrypted_privatekey_env_variable.py +++ b/cosmos/profiles/snowflake/user_encrypted_privatekey_env_variable.py @@ -1,4 +1,5 @@ -"Maps Airflow Snowflake connections to dbt profiles if they use a user/private key." +"""Maps Airflow Snowflake connections to dbt profiles if they use a user/private key.""" + from __future__ import annotations import json @@ -73,7 +74,7 @@ def conn(self) -> Connection: @property def profile(self) -> dict[str, Any | None]: - "Gets profile." + """Gets profile.""" profile_vars = { **self.mapped_params, **self.profile_args, @@ -85,7 +86,7 @@ def profile(self) -> dict[str, Any | None]: return self.filter_null(profile_vars) def transform_account(self, account: str) -> str: - "Transform the account to the format . if it's not already." + """Transform the account to the format . if it's not already.""" region = self.conn.extra_dejson.get("region") if region and region not in account: account = f"{account}.{region}" diff --git a/cosmos/profiles/snowflake/user_encrypted_privatekey_file.py b/cosmos/profiles/snowflake/user_encrypted_privatekey_file.py index 6831cbd280..e217a6c22e 100644 --- a/cosmos/profiles/snowflake/user_encrypted_privatekey_file.py +++ b/cosmos/profiles/snowflake/user_encrypted_privatekey_file.py @@ -1,4 +1,5 @@ -"Maps Airflow Snowflake connections to dbt profiles if they use a user/private key path." +"""Maps Airflow Snowflake connections to dbt profiles if they use a user/private key path.""" + from __future__ import annotations import json @@ -72,7 +73,7 @@ def conn(self) -> Connection: @property def profile(self) -> dict[str, Any | None]: - "Gets profile." + """Gets profile.""" profile_vars = { **self.mapped_params, **self.profile_args, @@ -84,7 +85,7 @@ def profile(self) -> dict[str, Any | None]: return self.filter_null(profile_vars) def transform_account(self, account: str) -> str: - "Transform the account to the format . if it's not already." + """Transform the account to the format . if it's not already.""" region = self.conn.extra_dejson.get("region") if region and region not in account: account = f"{account}.{region}" diff --git a/cosmos/profiles/snowflake/user_pass.py b/cosmos/profiles/snowflake/user_pass.py index fa634d1a2e..a6042495e1 100644 --- a/cosmos/profiles/snowflake/user_pass.py +++ b/cosmos/profiles/snowflake/user_pass.py @@ -1,4 +1,5 @@ -"Maps Airflow Snowflake connections to dbt profiles if they use a user/password." +"""Maps Airflow Snowflake connections to dbt profiles if they use a user/password.""" + from __future__ import annotations import json @@ -72,7 +73,7 @@ def conn(self) -> Connection: @property def profile(self) -> dict[str, Any | None]: - "Gets profile." + """Gets profile.""" profile_vars = { **self.mapped_params, **self.profile_args, @@ -84,7 +85,7 @@ def profile(self) -> dict[str, Any | None]: return self.filter_null(profile_vars) def transform_account(self, account: str) -> str: - "Transform the account to the format . if it's not already." + """Transform the account to the format . if it's not already.""" region = self.conn.extra_dejson.get("region") if region and region not in account: account = f"{account}.{region}" diff --git a/cosmos/profiles/snowflake/user_privatekey.py b/cosmos/profiles/snowflake/user_privatekey.py index 3aa0454ec6..c74194b7ae 100644 --- a/cosmos/profiles/snowflake/user_privatekey.py +++ b/cosmos/profiles/snowflake/user_privatekey.py @@ -1,4 +1,5 @@ -"Maps Airflow Snowflake connections to dbt profiles if they use a user/private key." +"""Maps Airflow Snowflake connections to dbt profiles if they use a user/private key.""" + from __future__ import annotations import json @@ -63,7 +64,7 @@ def conn(self) -> Connection: @property def profile(self) -> dict[str, Any | None]: - "Gets profile." + """Gets profile.""" profile_vars = { **self.mapped_params, **self.profile_args, @@ -75,7 +76,7 @@ def profile(self) -> dict[str, Any | None]: return self.filter_null(profile_vars) def transform_account(self, account: str) -> str: - "Transform the account to the format . if it's not already." + """Transform the account to the format . if it's not already.""" region = self.conn.extra_dejson.get("region") if region and region not in account: account = f"{account}.{region}" diff --git a/cosmos/profiles/spark/__init__.py b/cosmos/profiles/spark/__init__.py index d90b71dcc9..9bbfd775a9 100644 --- a/cosmos/profiles/spark/__init__.py +++ b/cosmos/profiles/spark/__init__.py @@ -1,4 +1,4 @@ -"Spark Airflow connection -> dbt profile mappings" +"""Spark Airflow connection -> dbt profile mappings""" from .thrift import SparkThriftProfileMapping diff --git a/cosmos/profiles/spark/thrift.py b/cosmos/profiles/spark/thrift.py index 3f9b44c041..e81851a747 100644 --- a/cosmos/profiles/spark/thrift.py +++ b/cosmos/profiles/spark/thrift.py @@ -1,4 +1,5 @@ -"Maps Airflow Spark connections to dbt profiles if they use a thrift connection." +"""Maps Airflow Spark connections to dbt profiles if they use a thrift connection.""" + from __future__ import annotations from typing import Any diff --git a/cosmos/profiles/trino/__init__.py b/cosmos/profiles/trino/__init__.py index adb633208b..97431e4508 100644 --- a/cosmos/profiles/trino/__init__.py +++ b/cosmos/profiles/trino/__init__.py @@ -1,4 +1,4 @@ -"Trino Airflow connection -> dbt profile mappings" +"""Trino Airflow connection -> dbt profile mappings""" from .base import TrinoBaseProfileMapping from .certificate import TrinoCertificateProfileMapping diff --git a/cosmos/profiles/trino/base.py b/cosmos/profiles/trino/base.py index 3ed5c7e6a7..a3c242d8f9 100644 --- a/cosmos/profiles/trino/base.py +++ b/cosmos/profiles/trino/base.py @@ -1,4 +1,5 @@ -"Maps common fields for Airflow Trino connections to dbt profiles." +"""Maps common fields for Airflow Trino connections to dbt profiles.""" + from __future__ import annotations from typing import Any @@ -7,7 +8,7 @@ class TrinoBaseProfileMapping(BaseProfileMapping): - "Maps common fields for Airflow Trino connections to dbt profiles." + """Maps common fields for Airflow Trino connections to dbt profiles.""" airflow_connection_type: str = "trino" dbt_profile_type: str = "trino" @@ -31,7 +32,7 @@ class TrinoBaseProfileMapping(BaseProfileMapping): @property def profile(self) -> dict[str, Any]: - "Gets profile." + """Gets profile.""" profile_vars = { **self.mapped_params, **self.profile_args, @@ -41,5 +42,5 @@ def profile(self) -> dict[str, Any]: return self.filter_null(profile_vars) def transform_host(self, host: str) -> str: - "Replaces http:// or https:// with nothing." + """Replaces http:// or https:// with nothing.""" return host.replace("http://", "").replace("https://", "") diff --git a/cosmos/profiles/trino/certificate.py b/cosmos/profiles/trino/certificate.py index c87e7c67cb..1ee5687383 100644 --- a/cosmos/profiles/trino/certificate.py +++ b/cosmos/profiles/trino/certificate.py @@ -1,4 +1,5 @@ -"Maps Airflow Trino connections to Certificate Trino dbt profiles." +"""Maps Airflow Trino connections to Certificate Trino dbt profiles.""" + from __future__ import annotations from typing import Any @@ -28,7 +29,7 @@ class TrinoCertificateProfileMapping(TrinoBaseProfileMapping): @property def profile(self) -> dict[str, Any | None]: - "Gets profile." + """Gets profile.""" common_profile_vars = super().profile profile_vars = { **self.mapped_params, diff --git a/cosmos/profiles/trino/jwt.py b/cosmos/profiles/trino/jwt.py index 4f09cd3f14..f9c0fd60d9 100644 --- a/cosmos/profiles/trino/jwt.py +++ b/cosmos/profiles/trino/jwt.py @@ -1,4 +1,5 @@ -"Maps Airflow Trino connections to JWT Trino dbt profiles." +"""Maps Airflow Trino connections to JWT Trino dbt profiles.""" + from __future__ import annotations from typing import Any @@ -29,7 +30,7 @@ class TrinoJWTProfileMapping(TrinoBaseProfileMapping): @property def profile(self) -> dict[str, Any | None]: - "Gets profile." + """Gets profile.""" common_profile_vars: dict[str, Any] = super().profile # need to remove jwt from profile_args because it will be set as an environment variable diff --git a/cosmos/profiles/trino/ldap.py b/cosmos/profiles/trino/ldap.py index 5ba122b932..7b1e7579b4 100644 --- a/cosmos/profiles/trino/ldap.py +++ b/cosmos/profiles/trino/ldap.py @@ -1,4 +1,5 @@ -"Maps Airflow Trino connections to LDAP Trino dbt profiles." +"""Maps Airflow Trino connections to LDAP Trino dbt profiles.""" + from __future__ import annotations from typing import Any diff --git a/cosmos/profiles/vertica/__init__.py b/cosmos/profiles/vertica/__init__.py index 4a88f2edd7..7a8a8acfd2 100644 --- a/cosmos/profiles/vertica/__init__.py +++ b/cosmos/profiles/vertica/__init__.py @@ -1,4 +1,4 @@ -"Vertica Airflow connection -> dbt profile mappings" +"""Vertica Airflow connection -> dbt profile mappings""" from .user_pass import VerticaUserPasswordProfileMapping diff --git a/cosmos/profiles/vertica/user_pass.py b/cosmos/profiles/vertica/user_pass.py index e016b612c6..214dbc3570 100644 --- a/cosmos/profiles/vertica/user_pass.py +++ b/cosmos/profiles/vertica/user_pass.py @@ -1,4 +1,5 @@ -"Maps Airflow Vertica connections using username + password authentication to dbt profiles." +"""Maps Airflow Vertica connections using username + password authentication to dbt profiles.""" + from __future__ import annotations from typing import Any @@ -59,7 +60,7 @@ class VerticaUserPasswordProfileMapping(BaseProfileMapping): @property def profile(self) -> dict[str, Any | None]: - "Gets profile. The password is stored in an environment variable." + """Gets profile. The password is stored in an environment variable.""" profile = { "port": 5433, **self.mapped_params, @@ -72,7 +73,7 @@ def profile(self) -> dict[str, Any | None]: @property def mock_profile(self) -> dict[str, Any | None]: - "Gets mock profile. Defaults port to 5433." + """Gets mock profile. Defaults port to 5433.""" parent_mock = super().mock_profile return { diff --git a/dev/dags/dbt_docs.py b/dev/dags/dbt_docs.py index 924928a801..a7703d4e40 100644 --- a/dev/dags/dbt_docs.py +++ b/dev/dags/dbt_docs.py @@ -43,7 +43,7 @@ @task.branch(task_id="which_upload") def which_upload(): - "Only run the docs tasks if we have the proper connections set up" + """Only run the docs tasks if we have the proper connections set up""" downstream_tasks_to_run = [] try: diff --git a/docs/generate_mappings.py b/docs/generate_mappings.py index 8955efc731..049eb8b4e1 100644 --- a/docs/generate_mappings.py +++ b/docs/generate_mappings.py @@ -15,7 +15,8 @@ @dataclass class Field: - "Represents a field in a profile mapping." + """Represents a field in a profile mapping.""" + dbt_name: str required: bool = False airflow_name: str | list[str] | None = None diff --git a/pyproject.toml b/pyproject.toml index c91b6abb34..c8bee0b202 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -208,7 +208,7 @@ no_warn_unused_ignores = true [tool.ruff] line-length = 120 [tool.ruff.lint] -select = ["C901", "I", "F"] +select = ["C901", "D300", "I", "F"] ignore = ["F541"] [tool.ruff.lint.mccabe] max-complexity = 10 diff --git a/tests/profiles/athena/test_athena_access_key.py b/tests/profiles/athena/test_athena_access_key.py index c0a25b7e95..a59a6b6525 100644 --- a/tests/profiles/athena/test_athena_access_key.py +++ b/tests/profiles/athena/test_athena_access_key.py @@ -1,4 +1,5 @@ -"Tests for the Athena profile." +"""Tests for the Athena profile.""" + from __future__ import annotations import json diff --git a/tests/profiles/bigquery/test_bq_oauth.py b/tests/profiles/bigquery/test_bq_oauth.py index f225f585f7..d48cb8cc4b 100644 --- a/tests/profiles/bigquery/test_bq_oauth.py +++ b/tests/profiles/bigquery/test_bq_oauth.py @@ -1,4 +1,4 @@ -"Tests for the BigQuery profile." +"""Tests for the BigQuery profile.""" import json from unittest.mock import patch diff --git a/tests/profiles/bigquery/test_bq_service_account_file.py b/tests/profiles/bigquery/test_bq_service_account_file.py index 7b4cbd6efc..7c685b50b1 100644 --- a/tests/profiles/bigquery/test_bq_service_account_file.py +++ b/tests/profiles/bigquery/test_bq_service_account_file.py @@ -1,4 +1,4 @@ -"Tests for the BigQuery profile." +"""Tests for the BigQuery profile.""" import json from unittest.mock import patch diff --git a/tests/profiles/databricks/test_dbr_token.py b/tests/profiles/databricks/test_dbr_token.py index 0a1701af25..ada72f0e7b 100644 --- a/tests/profiles/databricks/test_dbr_token.py +++ b/tests/profiles/databricks/test_dbr_token.py @@ -1,4 +1,4 @@ -"Tests for the postgres profile." +"""Tests for the postgres profile.""" from unittest.mock import patch diff --git a/tests/profiles/exasol/test_exasol_user_pass.py b/tests/profiles/exasol/test_exasol_user_pass.py index b2880c222d..b4f4d14b49 100644 --- a/tests/profiles/exasol/test_exasol_user_pass.py +++ b/tests/profiles/exasol/test_exasol_user_pass.py @@ -1,4 +1,4 @@ -"Tests for the Exasol profile." +"""Tests for the Exasol profile.""" from unittest.mock import patch diff --git a/tests/profiles/postgres/test_pg_user_pass.py b/tests/profiles/postgres/test_pg_user_pass.py index 174fff1391..c23e6add68 100644 --- a/tests/profiles/postgres/test_pg_user_pass.py +++ b/tests/profiles/postgres/test_pg_user_pass.py @@ -1,4 +1,4 @@ -"Tests for the postgres profile." +"""Tests for the postgres profile.""" from unittest.mock import patch diff --git a/tests/profiles/redshift/test_redshift_user_pass.py b/tests/profiles/redshift/test_redshift_user_pass.py index 073ce2deec..f1e87b3cdc 100644 --- a/tests/profiles/redshift/test_redshift_user_pass.py +++ b/tests/profiles/redshift/test_redshift_user_pass.py @@ -1,4 +1,4 @@ -"Tests for the Redshift profile." +"""Tests for the Redshift profile.""" from unittest.mock import patch diff --git a/tests/profiles/snowflake/test_snowflake_user_encrypted_privatekey_env_variable.py b/tests/profiles/snowflake/test_snowflake_user_encrypted_privatekey_env_variable.py index 2c7515f72f..ff00ee6980 100644 --- a/tests/profiles/snowflake/test_snowflake_user_encrypted_privatekey_env_variable.py +++ b/tests/profiles/snowflake/test_snowflake_user_encrypted_privatekey_env_variable.py @@ -1,4 +1,4 @@ -"Tests for the Snowflake user/private key environmentvariable profile." +"""Tests for the Snowflake user/private key environmentvariable profile.""" import json from unittest.mock import patch diff --git a/tests/profiles/snowflake/test_snowflake_user_encrypted_privatekey_file.py b/tests/profiles/snowflake/test_snowflake_user_encrypted_privatekey_file.py index d8c3aedcf6..73f2d947d5 100644 --- a/tests/profiles/snowflake/test_snowflake_user_encrypted_privatekey_file.py +++ b/tests/profiles/snowflake/test_snowflake_user_encrypted_privatekey_file.py @@ -1,4 +1,4 @@ -"Tests for the Snowflake user/private key file profile." +"""Tests for the Snowflake user/private key file profile.""" import json from unittest.mock import patch diff --git a/tests/profiles/snowflake/test_snowflake_user_pass.py b/tests/profiles/snowflake/test_snowflake_user_pass.py index 6276a20f1b..8113d8528c 100644 --- a/tests/profiles/snowflake/test_snowflake_user_pass.py +++ b/tests/profiles/snowflake/test_snowflake_user_pass.py @@ -1,4 +1,4 @@ -"Tests for the Snowflake user/password profile." +"""Tests for the Snowflake user/password profile.""" import json from unittest.mock import patch diff --git a/tests/profiles/snowflake/test_snowflake_user_privatekey.py b/tests/profiles/snowflake/test_snowflake_user_privatekey.py index 2692792cfa..edbf2b464f 100644 --- a/tests/profiles/snowflake/test_snowflake_user_privatekey.py +++ b/tests/profiles/snowflake/test_snowflake_user_privatekey.py @@ -1,4 +1,4 @@ -"Tests for the Snowflake user/private key profile." +"""Tests for the Snowflake user/private key profile.""" import json from unittest.mock import patch diff --git a/tests/profiles/spark/test_spark_thrift.py b/tests/profiles/spark/test_spark_thrift.py index da733e32fc..2ac8303ca9 100644 --- a/tests/profiles/spark/test_spark_thrift.py +++ b/tests/profiles/spark/test_spark_thrift.py @@ -1,4 +1,4 @@ -"Tests for the Spark profile." +"""Tests for the Spark profile.""" from unittest.mock import patch diff --git a/tests/profiles/trino/test_trino_base.py b/tests/profiles/trino/test_trino_base.py index ad1e84748f..ee03821e2f 100644 --- a/tests/profiles/trino/test_trino_base.py +++ b/tests/profiles/trino/test_trino_base.py @@ -1,4 +1,4 @@ -"Tests for the Trino profile." +"""Tests for the Trino profile.""" import json from unittest.mock import patch diff --git a/tests/profiles/trino/test_trino_certificate.py b/tests/profiles/trino/test_trino_certificate.py index 4ab3589e81..81728c32d7 100644 --- a/tests/profiles/trino/test_trino_certificate.py +++ b/tests/profiles/trino/test_trino_certificate.py @@ -1,4 +1,4 @@ -"Tests for the Trino profile." +"""Tests for the Trino profile.""" import json from unittest.mock import patch diff --git a/tests/profiles/trino/test_trino_jwt.py b/tests/profiles/trino/test_trino_jwt.py index 36d2d21530..b886120f23 100644 --- a/tests/profiles/trino/test_trino_jwt.py +++ b/tests/profiles/trino/test_trino_jwt.py @@ -1,4 +1,4 @@ -"Tests for the Trino profile." +"""Tests for the Trino profile.""" import json from unittest.mock import patch diff --git a/tests/profiles/trino/test_trino_ldap.py b/tests/profiles/trino/test_trino_ldap.py index f8adddf933..98bb2a642c 100644 --- a/tests/profiles/trino/test_trino_ldap.py +++ b/tests/profiles/trino/test_trino_ldap.py @@ -1,4 +1,4 @@ -"Tests for the Trino profile." +"""Tests for the Trino profile.""" from unittest.mock import patch diff --git a/tests/profiles/vertica/test_vertica_user_pass.py b/tests/profiles/vertica/test_vertica_user_pass.py index 6459dea962..cae259dffe 100644 --- a/tests/profiles/vertica/test_vertica_user_pass.py +++ b/tests/profiles/vertica/test_vertica_user_pass.py @@ -1,4 +1,4 @@ -"Tests for the vertica profile." +"""Tests for the vertica profile.""" from unittest.mock import patch From 20b4ae807f523e1008e2bf62d585aa86298e85b1 Mon Sep 17 00:00:00 2001 From: Gleb <34161740+glebkrapivin@users.noreply.github.com> Date: Tue, 18 Jun 2024 17:27:14 +0200 Subject: [PATCH 201/223] [Bug] Accidentally attaching adapters to root logger leads to increased logging costs (#1047) Closes #1048 After having upgraded to `astronomer-cosmos==1.4.3` we noticed an unusual 2x increase of the cost of the logs in our cloud provider. Quick investigation showed that the logs from cosmos logger were duplicated multiple times. **Example:** ```bash [2024-06-17 12:18:48,259] {{manager.py:165}} INFO - Launched DagFileProcessorManager with pid: 656 2024-06-17T12:18:48.260107549Z [2024-06-17 12:18:48,259] {manager.py:165} INFO - (astronomer-cosmos) - Launched DagFileProcessorManager with pid: 656 2024-06-17T12:18:48.260225215Z [2024-06-17 12:18:48,259] {manager.py:165} INFO - (astronomer-cosmos) - Launched DagFileProcessorManager with pid: 656 2024-06-17T12:18:48.260345799Z [2024-06-17 12:18:48,259] {manager.py:165} INFO - (astronomer-cosmos) - Launched DagFileProcessorManager with pid: 656 ``` After reproducing the problem locally I've found the highest version that seemed to not have the problem, which was `1.3.2`. However, the problem repeated itself when `cosmos` was imported in the dag. Afterwards, I looked at the changed files and saw that airflow plugin was added in the 1.4.0 version. It explains why the issue reproduces in all the version, but is hidden at first in 1.3.2 ```toml [project.entry-points."airflow.plugins"] cosmos = "cosmos.plugin:CosmosPlugin" ``` Following this discovery i looked through the source code and saw that in some of the lines the logger was initialized though function `get_logger` without the name argument. If you call `logging.getLogger(None)`, you get a root logger. Hence, cosmos adapter got attached to root logger multiple times in these places. ```python def get_logger(name: str) -> logging.Logger: logger = logging.getLogger(None) # Now it is a root logger .... logger.addHandler(handler) # now we have added astronomer logger to root logger, which is not intented ``` ## What was done - I made the `name` argument in `get_logger` required, otherwise it would be easy to forget to add it when a new file with logger call is created. - Added `LOGGER_NAME_TEMPLATE` so that there are no collisions with the adapters attaching to wrong loggers in the future. The name is not used in the actual format of the log message, so it does not matter much. The previous tests did not catch this corner case, but when the argument is required, there is no need to check it --- cosmos/__init__.py | 2 +- cosmos/cache.py | 2 +- cosmos/dbt/project.py | 2 +- cosmos/log.py | 6 ++++-- tests/test_log.py | 15 +++++++++++++++ 5 files changed, 22 insertions(+), 5 deletions(-) diff --git a/cosmos/__init__.py b/cosmos/__init__.py index 100649bfbe..555f97e064 100644 --- a/cosmos/__init__.py +++ b/cosmos/__init__.py @@ -30,7 +30,7 @@ DbtTestLocalOperator, ) -logger = get_logger() +logger = get_logger(__name__) try: from cosmos.operators.docker import ( diff --git a/cosmos/cache.py b/cosmos/cache.py index 1e0b341f07..563c4fd703 100644 --- a/cosmos/cache.py +++ b/cosmos/cache.py @@ -12,7 +12,7 @@ from cosmos.dbt.project import get_partial_parse_path from cosmos.log import get_logger -logger = get_logger() +logger = get_logger(__name__) # It was considered to create a cache identifier based on the dbt project path, as opposed diff --git a/cosmos/dbt/project.py b/cosmos/dbt/project.py index d8750cd442..987d10f3a6 100644 --- a/cosmos/dbt/project.py +++ b/cosmos/dbt/project.py @@ -13,7 +13,7 @@ ) from cosmos.log import get_logger -logger = get_logger() +logger = get_logger(__name__) def has_non_empty_dependencies_file(project_path: Path) -> bool: diff --git a/cosmos/log.py b/cosmos/log.py index 3294ac5b7c..3522b09dd0 100644 --- a/cosmos/log.py +++ b/cosmos/log.py @@ -14,8 +14,10 @@ "%(log_color)s%(message)s%(reset)s" ) +LOGGER_NAME_TEMPLATE = "astronomer-cosmos-{}" -def get_logger(name: str | None = None) -> logging.Logger: + +def get_logger(name: str) -> logging.Logger: """ Get custom Astronomer cosmos logger. @@ -25,7 +27,7 @@ def get_logger(name: str | None = None) -> logging.Logger: By using this logger, we introduce a (yellow) astronomer-cosmos string into the project's log messages: [2023-08-09T14:20:55.532+0100] {subprocess.py:94} INFO - (astronomer-cosmos) - 13:20:55 Completed successfully """ - logger = logging.getLogger(name) + logger = logging.getLogger(LOGGER_NAME_TEMPLATE.format(name)) formatter: logging.Formatter = CustomTTYColoredFormatter(fmt=LOG_FORMAT) # type: ignore handler = logging.StreamHandler() handler.setFormatter(formatter) diff --git a/tests/test_log.py b/tests/test_log.py index 75676d1ec6..c4f8b4bc11 100644 --- a/tests/test_log.py +++ b/tests/test_log.py @@ -1,5 +1,7 @@ import logging +import pytest + from cosmos import get_provider_info from cosmos.log import get_logger @@ -14,6 +16,19 @@ def test_get_logger(): assert custom_logger.handlers[0].formatter.__class__.__name__ == "CustomTTYColoredFormatter" assert custom_string in custom_logger.handlers[0].formatter._fmt + with pytest.raises(TypeError): + # Ensure that the get_logger signature is not changed in the future + # and name is still a required parameter + custom_logger = get_logger() # noqa + + # Explicitly ensure that even if we pass None or empty string + # we will not get root logger in any case + custom_logger = get_logger("") + assert custom_logger.name != "" + + custom_logger = get_logger(None) # noqa + assert custom_logger.name != "" + def test_propagate_logs_conf(monkeypatch): monkeypatch.setattr("cosmos.log.propagate_logs", False) From 6a32b232115a5416eb00efcc01ae44c642471efb Mon Sep 17 00:00:00 2001 From: Daniel Reeves <31971762+dwreeves@users.noreply.github.com> Date: Wed, 19 Jun 2024 20:00:27 -0400 Subject: [PATCH 202/223] [Bug fix] Add CSP header to iframe contents (#1055) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **This should be released as part of 1.5**. 😃 ## Description There was a bug in some environments relating to the lack of a CSP header explicitly allowing for same-origin iframes to render `dbt_docs_index.html`. @joppevos and I were able to fix this issue and confirm that it works by adding `Content-Security-Policy: frame-ancestors 'self';` to the response header for `dbt_docs_index.html`. Discussion here: https://apache-airflow.slack.com/archives/C059CC42E9W/p1718807254386149 Co-authored-by: joppevos <44348300+joppevos@users.noreply.github.com> --- cosmos/plugin/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cosmos/plugin/__init__.py b/cosmos/plugin/__init__.py index 3701f15728..ff4d8e8175 100644 --- a/cosmos/plugin/__init__.py +++ b/cosmos/plugin/__init__.py @@ -208,7 +208,7 @@ def dbt_docs(self) -> str: @expose("/dbt_docs_index.html") # type: ignore[misc] @has_access([(permissions.ACTION_CAN_READ, permissions.RESOURCE_WEBSITE)]) - def dbt_docs_index(self) -> str: + def dbt_docs_index(self) -> Tuple[str, int, Dict[str, Any]]: if dbt_docs_dir is None: abort(404) try: @@ -219,7 +219,7 @@ def dbt_docs_index(self) -> str: # Hack the dbt docs to render properly in an iframe iframe_resizer_url = url_for(".static", filename="iframeResizer.contentWindow.min.js") html = html.replace("", f'{iframe_script}', 1) - return html + return html, 200, {"Content-Security-Policy": "frame-ancestors 'self'"} @expose("/catalog.json") # type: ignore[misc] @has_access([(permissions.ACTION_CAN_READ, permissions.RESOURCE_WEBSITE)]) From 5eb470955f6e990c10493ad6729e93484a42e24b Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Thu, 20 Jun 2024 15:07:57 +0100 Subject: [PATCH 203/223] Mark plugin integration tests as integration (#1057) Some of the plugin tests relied on the `airflow.db`, and would raise exceptions depending on how it was configured locally. This PR changes those tests to integration, so they run with other tests alike. --- tests/plugin/test_plugin.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/plugin/test_plugin.py b/tests/plugin/test_plugin.py index c15d183bd0..7b367bffb3 100644 --- a/tests/plugin/test_plugin.py +++ b/tests/plugin/test_plugin.py @@ -58,9 +58,10 @@ def app() -> FlaskClient: yield app.test_client() - resetdb(skip_init=True) + resetdb() +@pytest.mark.integration def test_dbt_docs(monkeypatch, app): monkeypatch.setattr("cosmos.plugin.dbt_docs_dir", "path/to/docs/dir") @@ -70,6 +71,7 @@ def test_dbt_docs(monkeypatch, app): assert " Date: Thu, 20 Jun 2024 10:37:16 -0400 Subject: [PATCH 204/223] Add test for response header (#1058) Follow up from: #1055 --- tests/plugin/test_plugin.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/plugin/test_plugin.py b/tests/plugin/test_plugin.py index 7b367bffb3..04dc39cd8b 100644 --- a/tests/plugin/test_plugin.py +++ b/tests/plugin/test_plugin.py @@ -101,6 +101,8 @@ def test_dbt_docs_artifact(mock_open_file, monkeypatch, app, artifact): assert response.status_code == 200 if artifact == "dbt_docs_index.html": assert iframe_script in _get_text_from_response(response) + assert "Content-Security-Policy" in response.headers + assert response.headers["Content-Security-Policy"] == "frame-ancestors 'self'" @pytest.mark.integration From 19731ee29609da1c6093d57dfedc68aa4c158377 Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Thu, 20 Jun 2024 15:45:47 +0100 Subject: [PATCH 205/223] Change example DAGs to use `example_conn` as opposed to `airflow_db` (#1054) Using the Airflow metadata database connection as an example connection is misleading. The mismatch in the environment variable value used in the Cosmos integration tests, particularly with sqlite as the Airflow metadata database, is an issue that can hide other underlining problems. This PR decouples the test connection used by Cosmos example DAGs from the Airflow metadata Database connection. Since this change affects the Github action configuration, it will only work for the branch-triggered GH action runs, such as: https://github.com/astronomer/astronomer-cosmos/actions/runs/9596066209 Because this is a breaking change to the CI script itself, all the tests `pull_request_target` are expected to fail during the PR - and will pass once this is merged to `main`. This improvement was originally part of #1014 --------- Co-authored-by: Pankaj Koti --- .github/workflows/test.yml | 18 +++++++++--------- dev/dags/basic_cosmos_dag.py | 4 ++-- dev/dags/basic_cosmos_task_group.py | 4 ++-- dev/dags/cosmos_manifest_example.py | 4 ++-- dev/dags/cosmos_profile_mapping.py | 2 +- dev/dags/cosmos_seed_dag.py | 2 +- dev/dags/dbt_docs.py | 2 +- dev/dags/example_model_version.py | 4 ++-- dev/dags/example_virtualenv.py | 4 ++-- dev/dags/performance_dag.py | 4 ++-- tests/dbt/test_graph.py | 4 ++-- tests/operators/test_local.py | 2 +- 12 files changed, 27 insertions(+), 27 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dcac01496c..379b96b656 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -140,7 +140,7 @@ jobs: hatch run tests.py${{ matrix.python-version }}-${{ matrix.airflow-version }}:test-integration env: AIRFLOW_HOME: /home/runner/work/astronomer-cosmos/astronomer-cosmos/ - AIRFLOW_CONN_AIRFLOW_DB: postgres://postgres:postgres@0.0.0.0:5432/postgres + AIRFLOW_CONN_EXAMPLE_CONN: postgres://postgres:postgres@0.0.0.0:5432/postgres DATABRICKS_HOST: mock DATABRICKS_WAREHOUSE_ID: mock DATABRICKS_TOKEN: mock @@ -210,7 +210,7 @@ jobs: DATABRICKS_UNIQUE_ID="${{github.run_id}}" hatch run tests.py${{ matrix.python-version }}-${{ matrix.airflow-version }}:test-integration-expensive env: AIRFLOW_HOME: /home/runner/work/astronomer-cosmos/astronomer-cosmos/ - AIRFLOW_CONN_AIRFLOW_DB: postgres://postgres:postgres@0.0.0.0:5432/postgres + AIRFLOW_CONN_EXAMPLE_CONN: postgres://postgres:postgres@0.0.0.0:5432/postgres PYTHONPATH: /home/runner/work/astronomer-cosmos/astronomer-cosmos/:$PYTHONPATH AIRFLOW_CONN_DATABRICKS_DEFAULT: ${{ secrets.AIRFLOW_CONN_DATABRICKS_DEFAULT }} DATABRICKS_CLUSTER_ID: ${{ secrets.DATABRICKS_CLUSTER_ID }} @@ -231,7 +231,7 @@ jobs: env: AIRFLOW_HOME: /home/runner/work/astronomer-cosmos/astronomer-cosmos/ - AIRFLOW_CONN_AIRFLOW_DB: postgres://postgres:postgres@0.0.0.0:5432/postgres + AIRFLOW_CONN_EXAMPLE_CONN: postgres://postgres:postgres@0.0.0.0:5432/postgres PYTHONPATH: /home/runner/work/astronomer-cosmos/astronomer-cosmos/:$PYTHONPATH AIRFLOW_CONN_DATABRICKS_DEFAULT: ${{ secrets.AIRFLOW_CONN_DATABRICKS_DEFAULT }} DATABRICKS_CLUSTER_ID: ${{ secrets.DATABRICKS_CLUSTER_ID }} @@ -272,7 +272,7 @@ jobs: hatch run tests.py${{ matrix.python-version }}-${{ matrix.airflow-version }}:test-integration-sqlite env: AIRFLOW_HOME: /home/runner/work/astronomer-cosmos/astronomer-cosmos/ - AIRFLOW_CONN_AIRFLOW_DB: postgres://postgres:postgres@0.0.0.0:5432/postgres + AIRFLOW_CONN_EXAMPLE_CONN: postgres://postgres:postgres@0.0.0.0:5432/postgres AIRFLOW__CORE__DAGBAG_IMPORT_TIMEOUT: 90.0 PYTHONPATH: /home/runner/work/astronomer-cosmos/astronomer-cosmos/:$PYTHONPATH COSMOS_CONN_POSTGRES_PASSWORD: ${{ secrets.COSMOS_CONN_POSTGRES_PASSWORD }} @@ -295,7 +295,7 @@ jobs: env: AIRFLOW_HOME: /home/runner/work/astronomer-cosmos/astronomer-cosmos/ - AIRFLOW_CONN_AIRFLOW_DB: postgres://postgres:postgres@0.0.0.0:5432/postgres + AIRFLOW_CONN_EXAMPLE_CONN: postgres://postgres:postgres@0.0.0.0:5432/postgres PYTHONPATH: /home/runner/work/astronomer-cosmos/astronomer-cosmos/:$PYTHONPATH Run-Integration-Tests-DBT-1-5-4: @@ -345,7 +345,7 @@ jobs: hatch run tests.py${{ matrix.python-version }}-${{ matrix.airflow-version }}:test-integration-dbt-1-5-4 env: AIRFLOW_HOME: /home/runner/work/astronomer-cosmos/astronomer-cosmos/ - AIRFLOW_CONN_AIRFLOW_DB: postgres://postgres:postgres@0.0.0.0:5432/postgres + AIRFLOW_CONN_EXAMPLE_CONN: postgres://postgres:postgres@0.0.0.0:5432/postgres AIRFLOW__CORE__DAGBAG_IMPORT_TIMEOUT: 90.0 PYTHONPATH: /home/runner/work/astronomer-cosmos/astronomer-cosmos/:$PYTHONPATH COSMOS_CONN_POSTGRES_PASSWORD: ${{ secrets.COSMOS_CONN_POSTGRES_PASSWORD }} @@ -368,7 +368,7 @@ jobs: env: AIRFLOW_HOME: /home/runner/work/astronomer-cosmos/astronomer-cosmos/ - AIRFLOW_CONN_AIRFLOW_DB: postgres://postgres:postgres@0.0.0.0:5432/postgres + AIRFLOW_CONN_EXAMPLE_CONN: postgres://postgres:postgres@0.0.0.0:5432/postgres PYTHONPATH: /home/runner/work/astronomer-cosmos/astronomer-cosmos/:$PYTHONPATH Run-Performance-Tests: @@ -424,7 +424,7 @@ jobs: cat /tmp/performance_results.txt > $GITHUB_STEP_SUMMARY env: AIRFLOW_HOME: /home/runner/work/astronomer-cosmos/astronomer-cosmos/ - AIRFLOW_CONN_AIRFLOW_DB: postgres://postgres:postgres@0.0.0.0:5432/postgres + AIRFLOW_CONN_EXAMPLE_CONN: postgres://postgres:postgres@0.0.0.0:5432/postgres AIRFLOW__CORE__DAGBAG_IMPORT_TIMEOUT: 90.0 PYTHONPATH: /home/runner/work/astronomer-cosmos/astronomer-cosmos/:$PYTHONPATH COSMOS_CONN_POSTGRES_PASSWORD: ${{ secrets.COSMOS_CONN_POSTGRES_PASSWORD }} @@ -437,7 +437,7 @@ jobs: MODEL_COUNT: ${{ matrix.num-models }} env: AIRFLOW_HOME: /home/runner/work/astronomer-cosmos/astronomer-cosmos/ - AIRFLOW_CONN_AIRFLOW_DB: postgres://postgres:postgres@0.0.0.0:5432/postgres + AIRFLOW_CONN_EXAMPLE_CONN: postgres://postgres:postgres@0.0.0.0:5432/postgres PYTHONPATH: /home/runner/work/astronomer-cosmos/astronomer-cosmos/:$PYTHONPATH Code-Coverage: diff --git a/dev/dags/basic_cosmos_dag.py b/dev/dags/basic_cosmos_dag.py index c961638b39..f71f351f0a 100644 --- a/dev/dags/basic_cosmos_dag.py +++ b/dev/dags/basic_cosmos_dag.py @@ -1,5 +1,5 @@ """ -An example DAG that uses Cosmos to render a dbt project. +An example DAG that uses Cosmos to render a dbt project into an Airflow DAG. """ import os @@ -16,7 +16,7 @@ profile_name="default", target_name="dev", profile_mapping=PostgresUserPasswordProfileMapping( - conn_id="airflow_db", + conn_id="example_conn", profile_args={"schema": "public"}, disable_event_tracking=True, ), diff --git a/dev/dags/basic_cosmos_task_group.py b/dev/dags/basic_cosmos_task_group.py index f230e87d4e..4221e30190 100644 --- a/dev/dags/basic_cosmos_task_group.py +++ b/dev/dags/basic_cosmos_task_group.py @@ -1,5 +1,5 @@ """ -An example DAG that uses Cosmos to render a dbt project as a TaskGroup. +An example DAG that uses Cosmos to render a dbt project as an Airflow TaskGroup. """ import os @@ -20,7 +20,7 @@ profile_name="default", target_name="dev", profile_mapping=PostgresUserPasswordProfileMapping( - conn_id="airflow_db", + conn_id="example_conn", profile_args={"schema": "public"}, ), ) diff --git a/dev/dags/cosmos_manifest_example.py b/dev/dags/cosmos_manifest_example.py index e8721aefdf..225b38ddc1 100644 --- a/dev/dags/cosmos_manifest_example.py +++ b/dev/dags/cosmos_manifest_example.py @@ -1,5 +1,5 @@ """ -An example DAG that uses Cosmos to render a dbt project. +An example DAG that uses Cosmos to render a dbt project into Airflow using a dbt manifest file. """ import os @@ -16,7 +16,7 @@ profile_name="default", target_name="dev", profile_mapping=PostgresUserPasswordProfileMapping( - conn_id="airflow_db", + conn_id="example_conn", profile_args={"schema": "public"}, dbt_config_vars=DbtProfileConfigVars(send_anonymous_usage_stats=True), ), diff --git a/dev/dags/cosmos_profile_mapping.py b/dev/dags/cosmos_profile_mapping.py index 3c9a503ba3..36f344446e 100644 --- a/dev/dags/cosmos_profile_mapping.py +++ b/dev/dags/cosmos_profile_mapping.py @@ -41,7 +41,7 @@ def cosmos_profile_mapping() -> None: profile_name="default", target_name="dev", profile_mapping=get_automatic_profile_mapping( - conn_id="airflow_db", + conn_id="example_conn", profile_args={"schema": "public"}, ), ), diff --git a/dev/dags/cosmos_seed_dag.py b/dev/dags/cosmos_seed_dag.py index b682ecc368..d0b4f41034 100644 --- a/dev/dags/cosmos_seed_dag.py +++ b/dev/dags/cosmos_seed_dag.py @@ -29,7 +29,7 @@ profile_name="default", target_name="dev", profile_mapping=PostgresUserPasswordProfileMapping( - conn_id="airflow_db", + conn_id="example_conn", profile_args={"schema": "public"}, ), ) diff --git a/dev/dags/dbt_docs.py b/dev/dags/dbt_docs.py index a7703d4e40..e26e10d531 100644 --- a/dev/dags/dbt_docs.py +++ b/dev/dags/dbt_docs.py @@ -35,7 +35,7 @@ profile_name="default", target_name="dev", profile_mapping=PostgresUserPasswordProfileMapping( - conn_id="airflow_db", + conn_id="example_conn", profile_args={"schema": "public"}, ), ) diff --git a/dev/dags/example_model_version.py b/dev/dags/example_model_version.py index 909c7494a8..df9205f3ef 100644 --- a/dev/dags/example_model_version.py +++ b/dev/dags/example_model_version.py @@ -1,5 +1,5 @@ """ -An example DAG that uses Cosmos to render a dbt project. +An example DAG that uses Cosmos to render a dbt project as an Airflow DAG. """ import os @@ -16,7 +16,7 @@ profile_name="default", target_name="dev", profile_mapping=PostgresUserPasswordProfileMapping( - conn_id="airflow_db", + conn_id="example_conn", profile_args={"schema": "public"}, ), ) diff --git a/dev/dags/example_virtualenv.py b/dev/dags/example_virtualenv.py index 6275b10485..cd38cba9ec 100644 --- a/dev/dags/example_virtualenv.py +++ b/dev/dags/example_virtualenv.py @@ -1,5 +1,5 @@ """ -An example DAG that uses Cosmos to render a dbt project. +An example DAG that uses Cosmos to render a dbt project as an Airflow DAG. """ import os @@ -17,7 +17,7 @@ profile_name="default", target_name="dev", profile_mapping=PostgresUserPasswordProfileMapping( - conn_id="airflow_db", + conn_id="example_conn", profile_args={"schema": "public"}, ), ) diff --git a/dev/dags/performance_dag.py b/dev/dags/performance_dag.py index 1c8d639e02..3ed23c94c4 100644 --- a/dev/dags/performance_dag.py +++ b/dev/dags/performance_dag.py @@ -1,5 +1,5 @@ """ -An airflow DAG that uses Cosmos to render a dbt project for performance testing. +An Airflow DAG that uses Cosmos to render a dbt project for performance testing. """ import os @@ -17,7 +17,7 @@ profile_name="default", target_name="dev", profile_mapping=PostgresUserPasswordProfileMapping( - conn_id="airflow_db", + conn_id="example_conn", profile_args={"schema": "public"}, ), ) diff --git a/tests/dbt/test_graph.py b/tests/dbt/test_graph.py index 5f778b763d..0166dd89f3 100644 --- a/tests/dbt/test_graph.py +++ b/tests/dbt/test_graph.py @@ -51,7 +51,7 @@ def postgres_profile_config() -> ProfileConfig: profile_name="default", target_name="default", profile_mapping=PostgresUserPasswordProfileMapping( - conn_id="airflow_db", + conn_id="example_conn", profile_args={"schema": "public"}, ), ) @@ -265,7 +265,7 @@ def test_load_automatic_without_manifest_with_profile_mapping(mock_load_via_dbt_ profile_name="test", target_name="test", profile_mapping=PostgresUserPasswordProfileMapping( - conn_id="airflow_db", + conn_id="example_conn", profile_args={"schema": "public"}, ), ) diff --git a/tests/operators/test_local.py b/tests/operators/test_local.py index f90237082c..ed46caf887 100644 --- a/tests/operators/test_local.py +++ b/tests/operators/test_local.py @@ -56,7 +56,7 @@ profile_name="default", target_name="dev", profile_mapping=PostgresUserPasswordProfileMapping( - conn_id="airflow_db", + conn_id="example_conn", profile_args={"schema": "public"}, ), ) From 0d395317fccd6a8b98091e17abc0a0d00b584825 Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Mon, 24 Jun 2024 10:29:40 +0100 Subject: [PATCH 206/223] Cache hatch folder in the CI (#1056) As part of the CI build, we create a Python virtual environment with the dependencies necessary to run the tests. Currently, we recreate this environment variable every time a Github Action job is run. This PR caches the folder hatch and stores the Python virtualenv. It seems to have helped to reduce a bit, although the jobs are still very slow: - Unit tests execution from ~[2:40](https://github.com/astronomer/astronomer-cosmos/actions/runs/9550554350/job/26322778438) to [~2:25](https://github.com/astronomer/astronomer-cosmos/actions/runs/9598977261/job/26471650029) - Integration tests execution from [~11:07](https://github.com/astronomer/astronomer-cosmos/actions/runs/9550554350/job/26322894839) to [~10:27](https://github.com/astronomer/astronomer-cosmos/actions/runs/9598977261/job/26471677561) --- .github/workflows/test.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 379b96b656..afc32e702e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -66,7 +66,7 @@ jobs: with: path: | ~/.cache/pip - .nox + .local/share/hatch/ key: unit-${{ runner.os }}-${{ matrix.python-version }}-${{ matrix.airflow-version }}-${{ hashFiles('pyproject.toml') }}-${{ hashFiles('cosmos/__init__.py') }} - name: Set up Python ${{ matrix.python-version }} @@ -120,7 +120,7 @@ jobs: with: path: | ~/.cache/pip - .nox + .local/share/hatch/ key: integration-${{ runner.os }}-${{ matrix.python-version }}-${{ matrix.airflow-version }}-${{ hashFiles('pyproject.toml') }}-${{ hashFiles('cosmos/__init__.py') }} - name: Set up Python ${{ matrix.python-version }} @@ -190,7 +190,7 @@ jobs: with: path: | ~/.cache/pip - .nox + .local/share/hatch/ key: integration-expensive-${{ runner.os }}-${{ matrix.python-version }}-${{ matrix.airflow-version }}-${{ hashFiles('pyproject.toml') }}-${{ hashFiles('cosmos/__init__.py') }} - name: Set up Python ${{ matrix.python-version }} @@ -252,7 +252,7 @@ jobs: with: path: | ~/.cache/pip - .nox + .local/share/hatch/ key: integration-sqlite-${{ runner.os }}-${{ matrix.python-version }}-${{ matrix.airflow-version }}-${{ hashFiles('pyproject.toml') }}-${{ hashFiles('cosmos/__init__.py') }} - name: Set up Python ${{ matrix.python-version }} @@ -326,7 +326,7 @@ jobs: with: path: | ~/.cache/pip - .nox + .local/share/hatch/ key: integration-dbt-1-5-4-${{ runner.os }}-${{ matrix.python-version }}-${{ matrix.airflow-version }}-${{ hashFiles('pyproject.toml') }}-${{ hashFiles('cosmos/__init__.py') }} - name: Set up Python ${{ matrix.python-version }} @@ -399,7 +399,7 @@ jobs: with: path: | ~/.cache/pip - .nox + .local/share/hatch/ key: perf-test-${{ runner.os }}-${{ matrix.python-version }}-${{ matrix.airflow-version }}-${{ hashFiles('pyproject.toml') }}-${{ hashFiles('cosmos/__init__.py') }} - name: Set up Python ${{ matrix.python-version }} From 5db0f2ce4ff28006c2b8d01148ddddc829cc5e90 Mon Sep 17 00:00:00 2001 From: Pankaj Singh <98807258+pankajastro@users.noreply.github.com> Date: Mon, 24 Jun 2024 15:01:16 +0530 Subject: [PATCH 207/223] Fix AKS permission error in restricted env (#1051) ## Description ~shutil.copy includes permission copying via chmod. If the user lacks permission to run chmod, a PermissionError occurs. To avoid this, we split the operation into two steps: first, copy the file contents; then, copy metadata if feasible without raising exceptions. Step 1: Copy file contents (no metadata) Step 2: Copy file metadata (permission bits and other metadata) without raising exception~ use shutil.copyfile(...) instead of shutil.copy(...) to avoid running chmod ## Related Issue(s) closes: https://github.com/astronomer/astronomer-cosmos/issues/1008 ## Breaking Change? No ## Checklist - [ ] I have made corresponding changes to the documentation (if required) - [ ] I have added tests that prove my fix is effective or that my feature works --- cosmos/cache.py | 4 ++-- tests/test_cache.py | 32 +++++++++++++++++++++++++++++--- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/cosmos/cache.py b/cosmos/cache.py index 563c4fd703..b101366a01 100644 --- a/cosmos/cache.py +++ b/cosmos/cache.py @@ -107,8 +107,8 @@ def _update_partial_parse_cache(latest_partial_parse_filepath: Path, cache_dir: manifest_path = get_partial_parse_path(cache_dir).parent / DBT_MANIFEST_FILE_NAME latest_manifest_filepath = latest_partial_parse_filepath.parent / DBT_MANIFEST_FILE_NAME - shutil.copy(str(latest_partial_parse_filepath), str(cache_path)) - shutil.copy(str(latest_manifest_filepath), str(manifest_path)) + shutil.copyfile(str(latest_partial_parse_filepath), str(cache_path)) + shutil.copyfile(str(latest_manifest_filepath), str(manifest_path)) def patch_partial_parse_content(partial_parse_filepath: Path, project_path: Path) -> bool: diff --git a/tests/test_cache.py b/tests/test_cache.py index d75bc439b8..7d6a2d36c8 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -4,13 +4,18 @@ import time from datetime import datetime from pathlib import Path -from unittest.mock import patch +from unittest.mock import call, patch import pytest from airflow import DAG from airflow.utils.task_group import TaskGroup -from cosmos.cache import _copy_partial_parse_to_project, _create_cache_identifier, _get_latest_partial_parse +from cosmos.cache import ( + _copy_partial_parse_to_project, + _create_cache_identifier, + _get_latest_partial_parse, + _update_partial_parse_cache, +) from cosmos.constants import DBT_PARTIAL_PARSE_FILE_NAME, DBT_TARGET_DIR_NAME START_DATE = datetime(2024, 4, 16) @@ -74,7 +79,6 @@ def test_get_latest_partial_parse(tmp_path): @patch("cosmos.cache.msgpack.unpack", side_effect=ValueError) def test__copy_partial_parse_to_project_msg_fails_msgpack(mock_unpack, tmp_path, caplog): - # setup caplog.set_level(logging.INFO) source_dir = tmp_path / DBT_TARGET_DIR_NAME source_dir.mkdir() @@ -86,3 +90,25 @@ def test__copy_partial_parse_to_project_msg_fails_msgpack(mock_unpack, tmp_path, _copy_partial_parse_to_project(partial_parse_filepath, Path(tmp_dir)) assert "Unable to patch the partial_parse.msgpack file due to ValueError()" in caplog.text + + +@patch("cosmos.cache.shutil.copyfile") +@patch("cosmos.cache.get_partial_parse_path") +def test_update_partial_parse_cache(mock_get_partial_parse_path, mock_copyfile): + mock_get_partial_parse_path.side_effect = lambda cache_dir: cache_dir / "partial_parse.yml" + + latest_partial_parse_filepath = Path("/path/to/latest_partial_parse.yml") + cache_dir = Path("/path/to/cache_directory") + + # Expected paths + cache_path = cache_dir / "partial_parse.yml" + manifest_path = cache_dir / "manifest.json" + + _update_partial_parse_cache(latest_partial_parse_filepath, cache_dir) + + # Assert shutil.copyfile was called twice with the correct arguments + calls = [ + call(str(latest_partial_parse_filepath), str(cache_path)), + call(str(latest_partial_parse_filepath.parent / "manifest.json"), str(manifest_path)), + ] + mock_copyfile.assert_has_calls(calls) From b07ec4dd32ac6109f326b3a98ed4a7d4c1271dff Mon Sep 17 00:00:00 2001 From: linchun Date: Mon, 24 Jun 2024 17:56:49 +0800 Subject: [PATCH 208/223] Add node config to TaskInstance Context (#1044) Add the node's attributes (config, tags, etc, ...) into a TaskInstance context for retrieval by callback functions in Airflow through the use of `pre_execute` to store these attributes into a task's context. As [this PR](https://github.com/astronomer/astronomer-cosmos/pull/700/files) seems to be closed, and I have a use case for this feature, I attempt to recreate the needed feature. We leverage the `context_merge` utility function from Airflow to merge the extra context into the `Context` object of a `TaskInstance`. Closes #698 --- cosmos/airflow/graph.py | 8 ++++ cosmos/core/airflow.py | 1 + cosmos/core/graph/entities.py | 1 + cosmos/dbt/graph.py | 18 +++++++ cosmos/operators/base.py | 8 +++- tests/airflow/test_graph.py | 90 ++++++++++++++++++++++++++++++++--- tests/dbt/test_graph.py | 41 ++++++++++++++++ tests/operators/test_base.py | 79 ++++++++++++++++++++++++++++++ 8 files changed, 238 insertions(+), 8 deletions(-) diff --git a/cosmos/airflow/graph.py b/cosmos/airflow/graph.py index 348b0ce65d..0c81351787 100644 --- a/cosmos/airflow/graph.py +++ b/cosmos/airflow/graph.py @@ -93,6 +93,8 @@ def create_test_task_metadata( """ task_args = dict(task_args) task_args["on_warning_callback"] = on_warning_callback + extra_context = {} + if test_indirect_selection != TestIndirectSelection.EAGER: task_args["indirect_selection"] = test_indirect_selection.value if node is not None: @@ -102,6 +104,9 @@ def create_test_task_metadata( task_args["select"] = f"source:{node.resource_name}" else: # tested with node.resource_type == DbtResourceType.SEED or DbtResourceType.SNAPSHOT task_args["select"] = node.resource_name + + extra_context = {"dbt_node_config": node.context_dict} + elif render_config is not None: # TestBehavior.AFTER_ALL task_args["select"] = render_config.select task_args["selector"] = render_config.selector @@ -114,6 +119,7 @@ def create_test_task_metadata( dbt_class="DbtTest", ), arguments=task_args, + extra_context=extra_context, ) @@ -141,6 +147,7 @@ def create_task_metadata( args = {**args, **{"models": node.resource_name}} if DbtResourceType(node.resource_type) in DEFAULT_DBT_RESOURCES and node.resource_type in dbt_resource_to_class: + extra_context = {"dbt_node_config": node.context_dict} if node.resource_type == DbtResourceType.MODEL: task_id = f"{node.name}_run" if use_task_group is True: @@ -168,6 +175,7 @@ def create_task_metadata( execution_mode=execution_mode, dbt_class=dbt_resource_to_class[node.resource_type] ), arguments=args, + extra_context=extra_context, ) return task_metadata else: diff --git a/cosmos/core/airflow.py b/cosmos/core/airflow.py index f6f7464d87..d4a9624832 100644 --- a/cosmos/core/airflow.py +++ b/cosmos/core/airflow.py @@ -29,6 +29,7 @@ def get_airflow_task(task: Task, dag: DAG, task_group: "TaskGroup | None" = None task_id=task.id, dag=dag, task_group=task_group, + extra_context=task.extra_context, **task.arguments, ) diff --git a/cosmos/core/graph/entities.py b/cosmos/core/graph/entities.py index f88c3d6b23..3c3ee58d03 100644 --- a/cosmos/core/graph/entities.py +++ b/cosmos/core/graph/entities.py @@ -59,3 +59,4 @@ class Task(CosmosEntity): operator_class: str = "airflow.operators.empty.EmptyOperator" arguments: Dict[str, Any] = field(default_factory=dict) + extra_context: Dict[str, Any] = field(default_factory=dict) diff --git a/cosmos/dbt/graph.py b/cosmos/dbt/graph.py index 9ad8caaee0..f7ebb512fa 100644 --- a/cosmos/dbt/graph.py +++ b/cosmos/dbt/graph.py @@ -71,6 +71,24 @@ def name(self) -> str: """ return self.resource_name.replace(".", "_") + @property + def context_dict(self) -> dict[str, Any]: + """ + Returns a dictionary containing all the attributes of the DbtNode object, + ensuring that the output is JSON serializable so it can be stored in Airflow's db + """ + return { + "unique_id": self.unique_id, + "resource_type": self.resource_type.value, # convert enum to value + "depends_on": self.depends_on, + "file_path": str(self.file_path), # convert path to string + "tags": self.tags, + "config": self.config, + "has_test": self.has_test, + "resource_name": self.resource_name, + "name": self.name, + } + def is_freshness_effective(freshness: dict[str, Any]) -> bool: """Function to find if a source has null freshness. Scenarios where freshness diff --git a/cosmos/operators/base.py b/cosmos/operators/base.py index e22703fb5b..d0cbdd282a 100644 --- a/cosmos/operators/base.py +++ b/cosmos/operators/base.py @@ -7,7 +7,7 @@ import yaml from airflow.models.baseoperator import BaseOperator -from airflow.utils.context import Context +from airflow.utils.context import Context, context_merge from airflow.utils.operator_helpers import context_to_airflow_vars from airflow.utils.strings import to_boolean @@ -63,6 +63,7 @@ class AbstractDbtBaseOperator(BaseOperator, metaclass=ABCMeta): :param dbt_cmd_flags: List of flags to pass to dbt command :param dbt_cmd_global_flags: List of dbt global flags to be passed to the dbt command :param cache_dir: Directory used to cache Cosmos/dbt artifacts in Airflow worker nodes + :param extra_context: A dictionary of values to add to the TaskInstance's Context """ template_fields: Sequence[str] = ("env", "select", "exclude", "selector", "vars", "models") @@ -111,6 +112,7 @@ def __init__( dbt_cmd_flags: list[str] | None = None, dbt_cmd_global_flags: list[str] | None = None, cache_dir: Path | None = None, + extra_context: dict[str, Any] | None = None, **kwargs: Any, ) -> None: self.project_dir = project_dir @@ -139,6 +141,7 @@ def __init__( self.dbt_cmd_flags = dbt_cmd_flags self.dbt_cmd_global_flags = dbt_cmd_global_flags or [] self.cache_dir = cache_dir + self.extra_context = extra_context or {} super().__init__(**kwargs) def get_env(self, context: Context) -> dict[str, str | bytes | os.PathLike[Any]]: @@ -261,6 +264,9 @@ def build_and_run_cmd(self, context: Context, cmd_flags: list[str]) -> Any: """Override this method for the operator to execute the dbt command""" def execute(self, context: Context) -> Any | None: # type: ignore + if self.extra_context: + context_merge(context, self.extra_context) + self.build_and_run_cmd(context=context, cmd_flags=self.add_cmd_flags()) diff --git a/tests/airflow/test_graph.py b/tests/airflow/test_graph.py index 4ef7d112ce..a238475c2c 100644 --- a/tests/airflow/test_graph.py +++ b/tests/airflow/test_graph.py @@ -277,19 +277,95 @@ def test_create_task_metadata_unsupported(caplog): assert caplog.messages[0] == expected_msg -def test_create_task_metadata_model(caplog): +@pytest.mark.parametrize( + "unique_id, resource_type, expected_id, expected_operator_class, expected_arguments, expected_extra_context", + [ + ( + f"{DbtResourceType.MODEL.value}.my_folder.my_model", + DbtResourceType.MODEL, + "my_model_run", + "cosmos.operators.local.DbtRunLocalOperator", + {"models": "my_model"}, + { + "dbt_node_config": { + "unique_id": "model.my_folder.my_model", + "resource_type": "model", + "depends_on": [], + "file_path": ".", + "tags": [], + "config": {}, + "has_test": False, + "resource_name": "my_model", + "name": "my_model", + } + }, + ), + ( + f"{DbtResourceType.SOURCE.value}.my_folder.my_source", + DbtResourceType.SOURCE, + "my_source_run", + "cosmos.operators.local.DbtRunLocalOperator", + {"models": "my_source"}, + { + "dbt_node_config": { + "unique_id": "model.my_folder.my_source", + "resource_type": "source", + "depends_on": [], + "file_path": ".", + "tags": [], + "config": {}, + "has_test": False, + "resource_name": "my_source", + "name": "my_source", + } + }, + ), + ( + f"{DbtResourceType.SNAPSHOT.value}.my_folder.my_snapshot", + DbtResourceType.SNAPSHOT, + "my_snapshot_snapshot", + "cosmos.operators.local.DbtSnapshotLocalOperator", + {"models": "my_snapshot"}, + { + "dbt_node_config": { + "unique_id": "snapshot.my_folder.my_snapshot", + "resource_type": "snapshot", + "depends_on": [], + "file_path": ".", + "tags": [], + "config": {}, + "has_test": False, + "resource_name": "my_snapshot", + "name": "my_snapshot", + }, + }, + ), + ], +) +def test_create_task_metadata_model( + unique_id, + resource_type, + expected_id, + expected_operator_class, + expected_arguments, + expected_extra_context, + caplog, +): child_node = DbtNode( - unique_id=f"{DbtResourceType.MODEL.value}.my_folder.my_model", - resource_type=DbtResourceType.MODEL, + unique_id=unique_id, + resource_type=resource_type, depends_on=[], - file_path="", + file_path=Path(""), tags=[], config={}, ) + metadata = create_task_metadata(child_node, execution_mode=ExecutionMode.LOCAL, args={}) - assert metadata.id == "my_model_run" - assert metadata.operator_class == "cosmos.operators.local.DbtRunLocalOperator" - assert metadata.arguments == {"models": "my_model"} + if metadata: + assert metadata.id == expected_id + assert metadata.operator_class == expected_operator_class + assert metadata.arguments == expected_arguments + assert metadata.extra_context == expected_extra_context def test_create_task_metadata_model_with_versions(caplog): diff --git a/tests/dbt/test_graph.py b/tests/dbt/test_graph.py index 0166dd89f3..652a814827 100644 --- a/tests/dbt/test_graph.py +++ b/tests/dbt/test_graph.py @@ -71,6 +71,47 @@ def test_dbt_node_name_and_select(unique_id, expected_name, expected_select): assert node.resource_name == expected_select +@pytest.mark.parametrize( + "unique_id,expected_dict", + [ + ( + "model.my_project.customers", + { + "unique_id": "model.my_project.customers", + "resource_type": "model", + "depends_on": [], + "file_path": "", + "tags": [], + "config": {}, + "has_test": False, + "resource_name": "customers", + "name": "customers", + }, + ), + ( + "model.my_project.customers.v1", + { + "unique_id": "model.my_project.customers.v1", + "resource_type": "model", + "depends_on": [], + "file_path": "", + "tags": [], + "config": {}, + "has_test": False, + "resource_name": "customers.v1", + "name": "customers_v1", + }, + ), + ], +) +def test_dbt_node_context_dict( + unique_id, + expected_dict, +): + node = DbtNode(unique_id=unique_id, resource_type=DbtResourceType.MODEL, depends_on=[], file_path="") + assert node.context_dict == expected_dict + + @pytest.mark.parametrize( "project_name,manifest_filepath,model_filepath", [(DBT_PROJECT_NAME, SAMPLE_MANIFEST, "customers.sql"), ("jaffle_shop_python", SAMPLE_MANIFEST_PY, "customers.py")], diff --git a/tests/operators/test_base.py b/tests/operators/test_base.py index 3d39d43a7c..6f44252820 100644 --- a/tests/operators/test_base.py +++ b/tests/operators/test_base.py @@ -1,7 +1,9 @@ import sys +from datetime import datetime from unittest.mock import patch import pytest +from airflow.utils.context import Context from cosmos.operators.base import ( AbstractDbtBaseOperator, @@ -55,6 +57,83 @@ def test_dbt_base_operator_execute(mock_build_and_run_cmd, cmd_flags, monkeypatc mock_build_and_run_cmd.assert_called_once_with(context={}, cmd_flags=cmd_flags) +@patch("cosmos.operators.base.context_merge") +def test_dbt_base_operator_context_merge_called(mock_context_merge): + """Tests that the base operator execute method calls the context_merge method with the expected arguments.""" + base_operator = AbstractDbtBaseOperator( + task_id="fake_task", + project_dir="fake_dir", + extra_context={"extra": "extra"}, + ) + + base_operator.execute(context={}) + mock_context_merge.assert_called_once_with({}, {"extra": "extra"}) + + +@pytest.mark.parametrize( + "context, extra_context, expected_context", + [ + ( + Context( + start_date=datetime(2021, 1, 1), + ), + { + "extra": "extra", + }, + Context( + start_date=datetime(2021, 1, 1), + extra="extra", + ), + ), + ( + Context( + start_date=datetime(2021, 1, 1), + end_date=datetime(2023, 1, 1), + ), + { + "extra": "extra", + "extra_2": "extra_2", + }, + Context( + start_date=datetime(2021, 1, 1), + end_date=datetime(2023, 1, 1), + extra="extra", + extra_2="extra_2", + ), + ), + ( + Context( + overwrite="to_overwrite", + start_date=datetime(2021, 1, 1), + end_date=datetime(2023, 1, 1), + ), + { + "overwrite": "overwritten", + }, + Context( + start_date=datetime(2021, 1, 1), + end_date=datetime(2023, 1, 1), + overwrite="overwritten", + ), + ), + ], +) +def test_dbt_base_operator_context_merge( + context, + extra_context, + expected_context, +): + """Tests that the base operator execute method calls and update context""" + base_operator = AbstractDbtBaseOperator( + task_id="fake_task", + project_dir="fake_dir", + extra_context=extra_context, + ) + + base_operator.execute(context=context) + assert context == expected_context + + @pytest.mark.parametrize( "dbt_command, dbt_operator_class", [ From 4664918251cc279ac92996090332807182ad275f Mon Sep 17 00:00:00 2001 From: Waldemar Hummer Date: Mon, 24 Jun 2024 15:36:08 +0200 Subject: [PATCH 209/223] Add ability to specify host/port for Snowflake connection (#1063) Add ability to specify `host`/`port` for Snowflake connection. At LocalStack, we have recently started building a Snowflake emulator that allows running SF queries entirely on the local machine: https://blog.localstack.cloud/2024-05-22-introducing-localstack-for-snowflake/ . As part of a sample application we're building, we have an Apache Airflow DAG that uses Cosmos (and DBT) to connect to the local Snowflake emulator running on `localhost`. Here is a link to the sample app: https://github.com/localstack-samples/localstack-snowflake-samples/pull/12 Currently, we're hardcoding this integration in the user DAG file itself, [see here](https://github.com/localstack-samples/localstack-snowflake-samples/pull/12/files#diff-559d4f883ad589522b8a9d33f87fe95b0da72ac29b775e98b273a8eb3ede9924R10-R19): ``` ... from cosmos.profiles.snowflake.user_pass import SnowflakeUserPasswordProfileMapping ... SnowflakeUserPasswordProfileMapping.airflow_param_mapping["host"] = "extra.host" SnowflakeUserPasswordProfileMapping.airflow_param_mapping["port"] = "extra.port" ... ``` --- cosmos/profiles/snowflake/user_pass.py | 2 ++ .../snowflake/test_snowflake_user_pass.py | 24 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/cosmos/profiles/snowflake/user_pass.py b/cosmos/profiles/snowflake/user_pass.py index a6042495e1..3fc6595c93 100644 --- a/cosmos/profiles/snowflake/user_pass.py +++ b/cosmos/profiles/snowflake/user_pass.py @@ -40,6 +40,8 @@ class SnowflakeUserPasswordProfileMapping(BaseProfileMapping): "warehouse": "extra.warehouse", "schema": "schema", "role": "extra.role", + "host": "extra.host", + "port": "extra.port", } def can_claim_connection(self) -> bool: diff --git a/tests/profiles/snowflake/test_snowflake_user_pass.py b/tests/profiles/snowflake/test_snowflake_user_pass.py index 8113d8528c..6514bdf8db 100644 --- a/tests/profiles/snowflake/test_snowflake_user_pass.py +++ b/tests/profiles/snowflake/test_snowflake_user_pass.py @@ -231,3 +231,27 @@ def test_appends_region() -> None: "database": conn.extra_dejson.get("database"), "warehouse": conn.extra_dejson.get("warehouse"), } + + +def test_appends_host_and_port() -> None: + """ + Tests that host/port extras are appended to the connection settings. + """ + conn = Connection( + conn_id="my_snowflake_connection", + conn_type="snowflake", + login="my_user", + password="my_password", + schema="my_schema", + extra=json.dumps( + { + "host": "snowflake.localhost.localstack.cloud", + "port": 4566, + } + ), + ) + + with patch("airflow.hooks.base.BaseHook.get_connection", return_value=conn): + profile_mapping = SnowflakeUserPasswordProfileMapping(conn) + assert profile_mapping.profile["host"] == "snowflake.localhost.localstack.cloud" + assert profile_mapping.profile["port"] == 4566 From e3ab7c481bb1334a669103c51dd146bdb4d42f59 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 25 Jun 2024 02:54:33 +0530 Subject: [PATCH 210/223] =?UTF-8?q?=E2=AC=86=20[pre-commit.ci]=20pre-commi?= =?UTF-8?q?t=20autoupdate=20(#1064)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.4.9 → v0.4.10](https://github.com/astral-sh/ruff-pre-commit/compare/v0.4.9...v0.4.10) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8903f52035..86bfdbc742 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -54,7 +54,7 @@ repos: - --py37-plus - --keep-runtime-typing - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.4.9 + rev: v0.4.10 hooks: - id: ruff args: From eb262286fc9c90a0a00ac30015cb4fd33eb7025e Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Tue, 25 Jun 2024 13:49:36 +0100 Subject: [PATCH 211/223] Speed up `LoadMode.DBT_LS` by caching dbt ls output in Airflow Variable (#1014) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improve significantly the `LoadMode.DBT_LS` performance. The example DAGs tested reduced the task queueing time significantly (from ~30s to ~0.5s) and the total DAG run time for Jaffle Shop from 1 min 25s to 40s (by more than 50%). Some users[ reported improvements of 84%](https://github.com/astronomer/astronomer-cosmos/pull/1014#issuecomment-2168185343) in the DAG run time when trying out these changes. This difference can be even more significant on larger dbt projects. The improvement was accomplished by caching the dbt ls output as an Airflow Variable. This is an alternative to #992, when we cached the pickled DAG/TaskGroup into a local file in the Airflow node. Unlike #992, this approach works well for distributed deployments of Airflow. As with any caching solution, this strategy does not guarantee optimal performance on every run—whenever the cache is regenerated, the scheduler or DAG processor will experience a delay. It was also observed that the key value could change across platforms (e.g., `Darwin` and `Linux`). Therefore, if using a deployment with heterogeneous OS, the key may be regenerated often. Closes: #990 Closes: #1061 **Enabling/disabling this feature** This feature is enabled by default. Users can disable it by setting the environment variable `AIRFLOW__COSMOS__ENABLE_CACHE_DBT_LS=0`. **How the cache is refreshed** Users can purge or delete the cache via Airflow UI by identifying and deleting the cache key. The cache will be automatically refreshed in case any files of the dbt project change. Changes are calculated using the SHA256 of all the files in the directory. Initially, this feature was implemented using the files' modified timestamp, but this did not work well for some Airflow deployments (e.g., `astro --dags` since the timestamp was changed during deployments). Additionally, if any of the following DAG configurations are changed, we'll automatically purge the cache of the DAGs that use that specific configuration: * `ProjectConfig.dbt_vars` * `ProjectConfig.env_vars` * `ProjectConfig.partial_parse` * `RenderConfig.env_vars` * `RenderConfig.exclude` * `RenderConfig.select` * `RenderConfig.selector` The following argument was introduced in case users would like to define Airflow variables that could be used to refresh the cache (it expects a list with Airflow variable names): * `RenderConfig.airflow_vars_to_purge_cache` Example: ``` RenderConfig( airflow_vars_to_purge_cache==["refresh_cache"] ) ``` **Cache key** The Airflow variables that represent the dbt ls cache are prefixed by `cosmos_cache`. When using `DbtDag`, the keys use the DAG name. When using `DbtTaskGroup`, they consider the TaskGroup and parent task groups and DAG. Examples: 1. The `DbtDag` "cosmos_dag" will have the cache represented by `"cosmos_cache__basic_cosmos_dag"`. 2. The `DbtTaskGroup` "customers" declared inside teh DAG "basic_cosmos_task_group" will have the cache key `"cosmos_cache__basic_cosmos_task_group__customers"`. **Cache value** The cache values contain a few properties: - `last_modified` timestamp, represented using the ISO 8601 format. - `version` is a hash that represents the version of the dbt project and arguments used to run dbt ls by the time the cache was created - `dbt_ls_compressed` represents the dbt ls output compressed using zlib and encoded to base64 to be recorded as a string to the Airflow metadata database. Steps used to compress: ``` compressed_data = zlib.compress(dbt_ls_output.encode("utf-8")) encoded_data = base64.b64encode(compressed_data) dbt_ls_compressed = encoded_data.decode("utf-8") ``` We are compressing this value because it will be significant for larger dbt projects, depending on the selectors used, and we wanted this approach to be safe and not clutter the Airflow metadata database. Some numbers on the compression * A dbt project with 100 models can lead to a dbt ls output of 257k characters when using JSON. Zlib could compress it by 20x. * Another [real-life dbt project](https://gitlab.com/gitlab-data/analytics/-/tree/master/transform/snowflake-dbt?ref_type=heads) with 9,285 models led to a dbt ls output of 8.4 MB, uncompressed. It reduces to 489 KB after being compressed using `zlib` and encoded using `base64` - to 6% of the original size. * Maximum cell size in Postgres: 20MB The latency used to compress is in the order of milliseconds, not interfering in the performance of this solution. **Future work** * How this will affect the Airflow db in the long term * How does this performance compare to `ObjectStorage`? **Example of results before and after this change** Task queue times in Astro before the change: Screenshot 2024-06-03 at 11 15 26 Task queue times in Astro after the change on the second run of the DAG: Screenshot 2024-06-03 at 11 15 44 This feature will be available in `astronomer-cosmos==1.5.0a8`. --- .github/workflows/test.yml | 2 + .pre-commit-config.yaml | 1 + CHANGELOG.rst | 77 +++++++- cosmos/__init__.py | 2 +- cosmos/cache.py | 193 ++++++++++++++++++- cosmos/config.py | 1 + cosmos/constants.py | 1 + cosmos/converter.py | 23 ++- cosmos/dbt/graph.py | 225 ++++++++++++++++++---- cosmos/settings.py | 2 + dev/dags/basic_cosmos_task_group.py | 9 +- dev/dags/example_cosmos_cleanup_dag.py | 34 ++++ docs/configuration/caching.rst | 118 ++++++++++++ docs/configuration/cosmos-conf.rst | 16 ++ docs/configuration/index.rst | 1 + docs/configuration/parsing-methods.rst | 3 + docs/configuration/render-config.rst | 1 + pyproject.toml | 3 +- scripts/test/integration-setup.sh | 14 +- scripts/test/integration.sh | 12 ++ tests/dbt/test_graph.py | 247 ++++++++++++++++++++++++- tests/test_cache.py | 72 ++++++- 22 files changed, 994 insertions(+), 63 deletions(-) create mode 100644 dev/dags/example_cosmos_cleanup_dag.py create mode 100644 docs/configuration/caching.rst diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index afc32e702e..96f0d5564b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -116,6 +116,7 @@ jobs: - uses: actions/checkout@v3 with: ref: ${{ github.event.pull_request.head.sha || github.ref }} + - uses: actions/cache@v3 with: path: | @@ -139,6 +140,7 @@ jobs: hatch run tests.py${{ matrix.python-version }}-${{ matrix.airflow-version }}:test-integration-setup hatch run tests.py${{ matrix.python-version }}-${{ matrix.airflow-version }}:test-integration env: + AIRFLOW__COSMOS__ENABLE_CACHE_DBT_LS: 0 AIRFLOW_HOME: /home/runner/work/astronomer-cosmos/astronomer-cosmos/ AIRFLOW_CONN_EXAMPLE_CONN: postgres://postgres:postgres@0.0.0.0:5432/postgres DATABRICKS_HOST: mock diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 86bfdbc742..a95bc2bdf4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -82,6 +82,7 @@ repos: types-PyYAML, types-attrs, attrs, + types-pytz, types-requests, types-python-dateutil, apache-airflow, diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 7757d5fb58..065209c997 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,8 +1,83 @@ Changelog ========= +1.5.0a9 (2024-06-25) +-------------------- + +New Features + +* Speed up ``LoadMode.DBT_LS`` by caching dbt ls output in Airflow Variable by @tatiana in #1014 +* Support for running dbt tasks in AWS EKS in #944 by @VolkerSchiewe +* Add Clickhouse profile mapping by @roadan and @pankajastro in #353 and #1016 +* Add node config to TaskInstance Context by @linchun3 in #1044 + +Bug fixes + +* Fix disk permission error in restricted env by @pankajastro in #1051 +* Add CSP header to iframe contents by @dwreeves in #1055 +* Stop attaching log adaptors to root logger to reduce logging costs by @glebkrapivin in #1047 + +Enhancements + +* Support ``static_index.html`` docs by @dwreeves in #999 +* Support deep linking dbt docs via Airflow UI by @dwreeves in #1038 +* Add ability to specify host/port for Snowflake connection by @whummer in #1063 + +Others + +* Update documentation for DbtDocs generator by @arjunanan6 in #1043 +* Use uv in CI by @dwreeves in #1013 +* Cache hatch folder in the CI by @tatiana in #1056 +* Change example DAGs to use ``example_conn`` as opposed to ``airflow_db`` by @tatiana in #1054 +* Mark plugin integration tests as integration by @tatiana in #1057 +* Ensure compliance with linting rule D300 by using triple quotes for docstrings by @pankajastro in #1049 +* Pre-commit hook updates in #1039, #1050, #1064 + + 1.4.3 (2024-06-07) ------------------ +------------------ + +Bug fixes + +* Bring back ``dataset`` as a required field for BigQuery profile by @pankajkoti in #1033 + +Enhancements + +* Only run ``dbt deps`` when there are dependencies by @tatiana and @AlgirdasDubickas in #1030 + +Docs + +* Fix docs so it does not reference non-existing ``get_dbt_dataset`` by @tatiana in #1034 + + +v1.4.2 (2024-06-06) +------------------- + +Bug fixes + +* Fix the invocation mode for ``ExecutionMode.VIRTUALENV`` by @marco9663 in #1023 +* Fix Cosmos ``enable_cache`` setting by @tatiana in #1025 +* Make ``GoogleCloudServiceAccountDictProfileMapping`` dataset profile arg optional by @oliverrmaa and @pankajastro in #839 and #1017 +* Athena profile mapping set ``aws_session_token`` in profile only if it exists by @pankajastro in #1022 + +Others + +* Update dbt and Airflow conflicts matrix by @tatiana in #1026 +* Enable Python 3.12 unittest by @pankajastro in #1018 +* Improve error logging in ``DbtLocalBaseOperator`` by @davidsteinar in #1004 +* Add GitHub issue templates for bug reports and feature request by @pankajkoti in #1009 +* Add more fields in bug template to reduce turnaround in issue triaging by @pankajkoti in #1027 +* Fix ``dev/Dockerfile`` + Add ``uv pip install`` for faster build time by @dwreeves in #997 +* Drop support for Airflow 2.3 by @pankajkoti in #994 +* Update Astro Runtime image by @RNHTTR in #988 and #989 +* Enable ruff F linting by @pankajastro in #985 +* Move Cosmos Airflow configuration to settings.py by @pankajastro in #975 +* Fix CI Issues by @tatiana in #1005 +* Pre-commit hook updates in #1000, #1019 + + +1.4.1 (2024-05-17) +------------------ Bug fixes diff --git a/cosmos/__init__.py b/cosmos/__init__.py index 555f97e064..ee860228ac 100644 --- a/cosmos/__init__.py +++ b/cosmos/__init__.py @@ -5,7 +5,7 @@ Contains dags, task groups, and operators. """ -__version__ = "1.4.3" +__version__ = "1.5.0a9" from cosmos.airflow.dag import DbtDag diff --git a/cosmos/cache.py b/cosmos/cache.py index b101366a01..fd1dd53f4f 100644 --- a/cosmos/cache.py +++ b/cosmos/cache.py @@ -1,11 +1,22 @@ from __future__ import annotations +import functools +import hashlib +import json +import os import shutil +import time +from collections import defaultdict +from datetime import datetime, timedelta, timezone from pathlib import Path import msgpack +from airflow.models import DagRun, Variable from airflow.models.dag import DAG +from airflow.utils.session import provide_session from airflow.utils.task_group import TaskGroup +from sqlalchemy import select +from sqlalchemy.orm import Session from cosmos import settings from cosmos.constants import DBT_MANIFEST_FILE_NAME, DBT_TARGET_DIR_NAME @@ -13,6 +24,24 @@ from cosmos.log import get_logger logger = get_logger(__name__) +VAR_KEY_CACHE_PREFIX = "cosmos_cache__" + + +def _get_airflow_metadata(dag: DAG, task_group: TaskGroup | None) -> dict[str, str | None]: + dag_id = None + task_group_id = None + cosmos_type = "DbtDag" + + if task_group: + if task_group.dag_id is not None: + dag_id = task_group.dag_id + if task_group.group_id is not None: + task_group_id = task_group.group_id + cosmos_type = "DbtTaskGroup" + else: + dag_id = dag.dag_id + + return {"cosmos_type": cosmos_type, "dag_id": dag_id, "task_group_id": task_group_id} # It was considered to create a cache identifier based on the dbt project path, as opposed @@ -28,16 +57,21 @@ def _create_cache_identifier(dag: DAG, task_group: TaskGroup | None) -> str: :param task_group_name: (optional) Name of the Cosmos DbtTaskGroup being cached :return: Unique identifier representing the cache """ - if task_group: - if task_group.dag_id is not None: - cache_identifiers_list = [task_group.dag_id] - if task_group.group_id is not None: - cache_identifiers_list.extend([task_group.group_id.replace(".", "__")]) - cache_identifier = "__".join(cache_identifiers_list) - else: - cache_identifier = dag.dag_id + metadata = _get_airflow_metadata(dag, task_group) + cache_identifiers_list = [] + dag_id = metadata.get("dag_id") + task_group_id = metadata.get("task_group_id") + + if dag_id: + cache_identifiers_list.append(dag_id) + if task_group_id: + cache_identifiers_list.append(task_group_id.replace(".", "__")) - return cache_identifier + return "__".join(cache_identifiers_list) + + +def create_cache_key(cache_identifier: str) -> str: + return f"{VAR_KEY_CACHE_PREFIX}{cache_identifier}" def _obtain_cache_dir_path(cache_identifier: str, base_dir: Path = settings.cache_dir) -> Path: @@ -171,3 +205,144 @@ def _copy_partial_parse_to_project(partial_parse_filepath: Path, project_path: P if source_manifest_filepath.exists(): shutil.copy(str(source_manifest_filepath), str(target_manifest_filepath)) + + +def _create_folder_version_hash(dir_path: Path) -> str: + """ + Given a directory, iterate through its content and create a hash that will change in case the + contents of the directory change. The value should not change if the values of the directory do not change, even if + the command is run from different Airflow instances. + + This method output must be concise and it currently changes based on operating system. + """ + # This approach is less efficient than using modified time + # sum([path.stat().st_mtime for path in dir_path.glob("**/*")]) + # unfortunately, the modified time approach does not work well for dag-only deployments + # where DAGs are constantly synced to the deployed Airflow + # for 5k files, this seems to take 0.14 + hasher = hashlib.md5() + filepaths = [] + + for root_dir, dirs, files in os.walk(dir_path): + paths = [os.path.join(root_dir, filepath) for filepath in files] + filepaths.extend(paths) + + for filepath in sorted(filepaths): + with open(str(filepath), "rb") as fp: + buf = fp.read() + hasher.update(buf) + + return hasher.hexdigest() + + +def _calculate_dbt_ls_cache_current_version(cache_identifier: str, project_dir: Path, cmd_args: list[str]) -> str: + """ + Taking into account the project directory contents and the command arguments, calculate the + hash that represents the "dbt ls" command version - to be used to decide if the cache should be refreshed or not. + + :param cache_identifier: Unique identifier of the cache (may include DbtDag or DbtTaskGroup information) + :param project_path: Path to the target dbt project directory + :param cmd_args: List containing the arguments passed to the dbt ls command that would affect its output + """ + start_time = time.perf_counter() + + # Combined value for when the dbt project directory files were last modified + # This is fast (e.g. 0.01s for jaffle shop, 0.135s for a 5k models dbt folder) + dbt_project_hash = _create_folder_version_hash(project_dir) + + # The performance for the following will depend on the user's configuration + hash_args = hashlib.md5("".join(cmd_args).encode()).hexdigest() + + elapsed_time = time.perf_counter() - start_time + logger.info( + f"Cosmos performance: time to calculate cache identifier {cache_identifier} for current version: {elapsed_time}" + ) + return f"{dbt_project_hash},{hash_args}" + + +@functools.lru_cache +def was_project_modified(previous_version: str, current_version: str) -> bool: + """ + Given the cache version of a project and the latest version of the project, + decides if the project was modified or not. + """ + return previous_version != current_version + + +@provide_session +def delete_unused_dbt_ls_cache( + max_age_last_usage: timedelta = timedelta(days=30), session: Session | None = None +) -> int: + """ + Delete Cosmos cache stored in Airflow Variables based on the last execution of their associated DAGs. + + Example usage: + + There are three Cosmos cache Airflow Variables: + 1. ``cache cosmos_cache__basic_cosmos_dag`` + 2. ``cosmos_cache__basic_cosmos_task_group__orders`` + 3. ``cosmos_cache__basic_cosmos_task_group__customers`` + + The first relates to the ``DbtDag`` ``basic_cosmos_dag`` and the two last ones relate to the DAG + ``basic_cosmos_task_group`` that has two ``DbtTaskGroups``: ``orders`` and ``customers``. + + Let's assume the last DAG run of ``basic_cosmos_dag`` was a week ago and the last DAG run of + ``basic_cosmos_task_group`` was an hour ago. + + To delete the cache related to ``DbtDags`` and ``DbtTaskGroup`` that were run more than 5 days ago: + + ..code: python + >>> delete_unused_dbt_ls_cache(max_age_last_usage=timedelta(days=5)) + INFO - Removing the dbt ls cache cosmos_cache__basic_cosmos_dag + + To delete the cache related to ``DbtDags`` and ``DbtTaskGroup`` that were run more than 10 minutes ago: + + ..code: python + >>> delete_unused_dbt_ls_cache(max_age_last_usage=timedelta(minutes=10)) + INFO - Removing the dbt ls cache cosmos_cache__basic_cosmos_dag + INFO - Removing the dbt ls cache cosmos_cache__basic_cosmos_task_group__orders + INFO - Removing the dbt ls cache cosmos_cache__basic_cosmos_task_group__orders + + To delete the cache related to ``DbtDags`` and ``DbtTaskGroup`` that were run more than 10 days ago + + ..code: python + >>> delete_unused_dbt_ls_cache(max_age_last_usage=timedelta(days=10)) + + In this last example, nothing is deleted. + """ + if session is None: + return 0 + + logger.info(f"Delete the Cosmos cache stored in Airflow Variables that hasn't been used for {max_age_last_usage}") + cosmos_dags_ids = defaultdict(list) + all_variables = session.scalars(select(Variable)).all() + total_cosmos_variables = 0 + deleted_cosmos_variables = 0 + + # Identify Cosmos-related cache in Airflow variables + for var in all_variables: + if var.key.startswith(VAR_KEY_CACHE_PREFIX): + var_value = json.loads(var.val) + cosmos_dags_ids[var_value["dag_id"]].append(var.key) + total_cosmos_variables += 1 + + # Delete DAGs that have not been run in the last X time + for dag_id, vars_keys in cosmos_dags_ids.items(): + last_dag_run = ( + session.query(DagRun) + .filter( + DagRun.dag_id == dag_id, + ) + .order_by(DagRun.execution_date.desc()) + .first() + ) + if last_dag_run and last_dag_run.execution_date < (datetime.now(timezone.utc) - max_age_last_usage): + for var_key in vars_keys: + logger.info(f"Removing the dbt ls cache {var_key}") + Variable.delete(var_key) + deleted_cosmos_variables += 1 + + logger.info( + f"Deleted {deleted_cosmos_variables}/{total_cosmos_variables} Airflow Variables used to store Cosmos cache. " + ) + return deleted_cosmos_variables diff --git a/cosmos/config.py b/cosmos/config.py index 13622563e1..5ca21709d2 100644 --- a/cosmos/config.py +++ b/cosmos/config.py @@ -71,6 +71,7 @@ class RenderConfig: dbt_ls_path: Path | None = None project_path: Path | None = field(init=False) enable_mock_profile: bool = True + airflow_vars_to_purge_dbt_ls_cache: list[str] = field(default_factory=list) def __post_init__(self, dbt_project_path: str | Path | None) -> None: if self.env_vars: diff --git a/cosmos/constants.py b/cosmos/constants.py index 92bf883b2e..2a1abb20ed 100644 --- a/cosmos/constants.py +++ b/cosmos/constants.py @@ -36,6 +36,7 @@ class LoadMode(Enum): CUSTOM = "custom" DBT_LS = "dbt_ls" DBT_LS_FILE = "dbt_ls_file" + DBT_LS_CACHE = "dbt_ls_cache" DBT_MANIFEST = "dbt_manifest" diff --git a/cosmos/converter.py b/cosmos/converter.py index 5e415486ee..40929ef559 100644 --- a/cosmos/converter.py +++ b/cosmos/converter.py @@ -5,6 +5,9 @@ import copy import inspect +import os +import platform +import time from typing import Any, Callable from warnings import warn @@ -225,19 +228,31 @@ def __init__( dbt_vars = project_config.dbt_vars or operator_args.get("vars") cache_dir = None + cache_identifier = None if settings.enable_cache: - cache_dir = cache._obtain_cache_dir_path(cache_identifier=cache._create_cache_identifier(dag, task_group)) + cache_identifier = cache._create_cache_identifier(dag, task_group) + cache_dir = cache._obtain_cache_dir_path(cache_identifier=cache_identifier) + previous_time = time.perf_counter() self.dbt_graph = DbtGraph( project=project_config, render_config=render_config, execution_config=execution_config, profile_config=profile_config, cache_dir=cache_dir, + cache_identifier=cache_identifier, dbt_vars=dbt_vars, + airflow_metadata=cache._get_airflow_metadata(dag, task_group), ) self.dbt_graph.load(method=render_config.load_method, execution_mode=execution_config.execution_mode) + current_time = time.perf_counter() + elapsed_time = current_time - previous_time + logger.info( + f"Cosmos performance ({cache_identifier}) - [{platform.node()}|{os.getpid()}]: It took {elapsed_time:.3}s to parse the dbt project for DAG using {self.dbt_graph.load_method}" + ) + previous_time = current_time + task_args = { **operator_args, "project_dir": execution_config.project_path, @@ -272,3 +287,9 @@ def __init__( on_warning_callback=on_warning_callback, render_config=render_config, ) + + current_time = time.perf_counter() + elapsed_time = current_time - previous_time + logger.info( + f"Cosmos performance ({cache_identifier}) - [{platform.node()}|{os.getpid()}]: It took {elapsed_time:.3}s to build the Airflow DAG." + ) diff --git a/cosmos/dbt/graph.py b/cosmos/dbt/graph.py index f7ebb512fa..348ded07f8 100644 --- a/cosmos/dbt/graph.py +++ b/cosmos/dbt/graph.py @@ -1,17 +1,23 @@ from __future__ import annotations +import base64 +import datetime +import functools import itertools import json import os +import platform import tempfile +import zlib from dataclasses import dataclass, field +from functools import cached_property from pathlib import Path from subprocess import PIPE, Popen from typing import Any -import yaml +from airflow.models import Variable -from cosmos import cache +from cosmos import cache, settings from cosmos.config import ExecutionConfig, ProfileConfig, ProjectConfig, RenderConfig from cosmos.constants import ( DBT_LOG_DIR_NAME, @@ -141,7 +147,7 @@ def run_command(command: list[str], tmp_dir: Path, env_vars: dict[str, str]) -> return stdout -def parse_dbt_ls_output(project_path: Path, ls_stdout: str) -> dict[str, DbtNode]: +def parse_dbt_ls_output(project_path: Path | None, ls_stdout: str) -> dict[str, DbtNode]: """Parses the output of `dbt ls` into a dictionary of `DbtNode` instances.""" nodes = {} for line in ls_stdout.split("\n"): @@ -176,6 +182,8 @@ class DbtGraph: nodes: dict[str, DbtNode] = dict() filtered_nodes: dict[str, DbtNode] = dict() + load_method: LoadMode = LoadMode.AUTOMATIC + current_version: str = "" def __init__( self, @@ -184,16 +192,135 @@ def __init__( execution_config: ExecutionConfig = ExecutionConfig(), profile_config: ProfileConfig | None = None, cache_dir: Path | None = None, - # dbt_vars only supported for LegacyDbtProject + cache_identifier: str = "", dbt_vars: dict[str, str] | None = None, + airflow_metadata: dict[str, str] | None = None, ): self.project = project self.render_config = render_config self.profile_config = profile_config self.execution_config = execution_config self.cache_dir = cache_dir + self.airflow_metadata = airflow_metadata or {} + if cache_identifier: + self.dbt_ls_cache_key = cache.create_cache_key(cache_identifier) + else: + self.dbt_ls_cache_key = "" self.dbt_vars = dbt_vars or {} + @cached_property + def env_vars(self) -> dict[str, str]: + """ + User-defined environment variables, relevant to running dbt ls. + """ + return self.render_config.env_vars or self.project.env_vars or {} + + @cached_property + def project_path(self) -> Path: + """ + Return the user-defined path to their dbt project. Tries to retrieve the configuration from render_config and + (legacy support) ExecutionConfig, where it was originally defined. + """ + # we're considering the execution_config only due to backwards compatibility + path = self.render_config.project_path or self.project.dbt_project_path or self.execution_config.project_path + if not path: + raise CosmosLoadDbtException( + "Unable to load project via dbt ls without RenderConfig.dbt_project_path, ProjectConfig.dbt_project_path or ExecutionConfig.dbt_project_path" + ) + return path.absolute() + + @cached_property + def dbt_ls_args(self) -> list[str]: + """ + Flags set while running dbt ls. This information is also used to define the dbt ls cache key. + """ + ls_args = [] + if self.render_config.exclude: + ls_args.extend(["--exclude", *self.render_config.exclude]) + + if self.render_config.select: + ls_args.extend(["--select", *self.render_config.select]) + + if self.project.dbt_vars: + ls_args.extend(["--vars", json.dumps(self.project.dbt_vars, sort_keys=True)]) + + if self.render_config.selector: + ls_args.extend(["--selector", self.render_config.selector]) + + if not self.project.partial_parse: + ls_args.append("--no-partial-parse") + + return ls_args + + @cached_property + def dbt_ls_cache_key_args(self) -> list[str]: + """ + Values that are used to represent the dbt ls cache key. If any parts are changed, the dbt ls command will be + executed and the new value will be stored. + """ + # if dbt deps, we can consider the md5 of the packages or deps file + cache_args = list(self.dbt_ls_args) + env_vars = self.env_vars + if env_vars: + envvars_str = json.dumps(env_vars, sort_keys=True) + cache_args.append(envvars_str) + if self.render_config.airflow_vars_to_purge_dbt_ls_cache: + for var_name in self.render_config.airflow_vars_to_purge_dbt_ls_cache: + airflow_vars = [var_name, Variable.get(var_name, "")] + cache_args.extend(airflow_vars) + + logger.debug(f"Value of `dbt_ls_cache_key_args` for <{self.dbt_ls_cache_key}>: {cache_args}") + return cache_args + + def save_dbt_ls_cache(self, dbt_ls_output: str) -> None: + """ + Store compressed dbt ls output into an Airflow Variable. + + Stores: + { + "version": "cache-version", + "dbt_ls_compressed": "compressed dbt ls output", + "last_modified": "Isoformat timestamp" + } + """ + # This compression reduces the dbt ls output to 10% of the original size + compressed_data = zlib.compress(dbt_ls_output.encode("utf-8")) + encoded_data = base64.b64encode(compressed_data) + dbt_ls_compressed = encoded_data.decode("utf-8") + cache_dict = { + "version": cache._calculate_dbt_ls_cache_current_version( + self.dbt_ls_cache_key, self.project_path, self.dbt_ls_cache_key_args + ), + "dbt_ls_compressed": dbt_ls_compressed, + "last_modified": datetime.datetime.now(datetime.timezone.utc).isoformat(), + **self.airflow_metadata, + } + Variable.set(self.dbt_ls_cache_key, cache_dict, serialize_json=True) + + def get_dbt_ls_cache(self) -> dict[str, str]: + """ + Retrieve previously saved dbt ls cache from an Airflow Variable, decompressing the dbt ls output. + + Outputs: + { + "version": "cache-version", + "dbt_ls": "uncompressed dbt ls output", + "last_modified": "Isoformat timestamp" + } + """ + cache_dict: dict[str, str] = {} + try: + cache_dict = Variable.get(self.dbt_ls_cache_key, deserialize_json=True) + except (json.decoder.JSONDecodeError, KeyError): + return cache_dict + else: + dbt_ls_compressed = cache_dict.pop("dbt_ls_compressed", None) + if dbt_ls_compressed: + encoded_data = base64.b64decode(dbt_ls_compressed.encode()) + cache_dict["dbt_ls"] = zlib.decompress(encoded_data).decode() + + return cache_dict + def load( self, method: LoadMode = LoadMode.AUTOMATIC, @@ -209,11 +336,11 @@ def load( Fundamentally, there are two different execution paths There is automatic, and manual. """ - load_method = { LoadMode.CUSTOM: self.load_via_custom_parser, LoadMode.DBT_LS: self.load_via_dbt_ls, LoadMode.DBT_LS_FILE: self.load_via_dbt_ls_file, + LoadMode.DBT_LS_CACHE: self.load_via_dbt_ls_cache, LoadMode.DBT_MANIFEST: self.load_from_dbt_manifest, } @@ -249,22 +376,9 @@ def run_dbt_ls( "name alias unique_id resource_type depends_on original_file_path tags config freshness", ] - if self.render_config.exclude: - ls_command.extend(["--exclude", *self.render_config.exclude]) - - if self.render_config.select: - ls_command.extend(["--select", *self.render_config.select]) - - if self.project.dbt_vars: - ls_command.extend(["--vars", yaml.dump(self.project.dbt_vars)]) - - if self.render_config.selector: - ls_command.extend(["--selector", self.render_config.selector]) - - if not self.project.partial_parse: - ls_command.append("--no-partial-parse") - + ls_args = self.dbt_ls_args ls_command.extend(self.local_flags) + ls_command.extend(ls_args) stdout = run_command(ls_command, tmp_dir, env_vars) @@ -276,10 +390,56 @@ def run_dbt_ls( for line in logfile: logger.debug(line.strip()) + if self.should_use_dbt_ls_cache(): + self.save_dbt_ls_cache(stdout) + nodes = parse_dbt_ls_output(project_path, stdout) return nodes def load_via_dbt_ls(self) -> None: + """Retrieve the dbt ls cache if enabled and available or run dbt ls""" + if not self.load_via_dbt_ls_cache(): + self.load_via_dbt_ls_without_cache() + + @functools.lru_cache + def should_use_dbt_ls_cache(self) -> bool: + """Identify if Cosmos should use/store dbt ls cache or not.""" + return settings.enable_cache and settings.enable_cache_dbt_ls and bool(self.dbt_ls_cache_key) + + def load_via_dbt_ls_cache(self) -> bool: + """(Try to) load dbt ls cache from an Airflow Variable""" + + logger.info(f"Trying to parse the dbt project using dbt ls cache {self.dbt_ls_cache_key}...") + if self.should_use_dbt_ls_cache(): + project_path = self.project_path + + cache_dict = self.get_dbt_ls_cache() + if not cache_dict: + logger.info(f"Cosmos performance: Cache miss for {self.dbt_ls_cache_key}") + return False + + cache_version = cache_dict.get("version") + dbt_ls_cache = cache_dict.get("dbt_ls") + + current_version = cache._calculate_dbt_ls_cache_current_version( + self.dbt_ls_cache_key, project_path, self.dbt_ls_cache_key_args + ) + + if dbt_ls_cache and not cache.was_project_modified(cache_version, current_version): + logger.info( + f"Cosmos performance [{platform.node()}|{os.getpid()}]: The cache size for {self.dbt_ls_cache_key} is {len(dbt_ls_cache)}" + ) + self.load_method = LoadMode.DBT_LS_CACHE + + nodes = parse_dbt_ls_output(project_path=project_path, ls_stdout=dbt_ls_cache) + self.nodes = nodes + self.filtered_nodes = nodes + logger.info(f"Cosmos performance: Cache hit for {self.dbt_ls_cache_key} - {current_version}") + return True + logger.info(f"Cosmos performance: Cache miss for {self.dbt_ls_cache_key} - skipped") + return False + + def load_via_dbt_ls_without_cache(self) -> None: """ This is the most accurate way of loading `dbt` projects and filtering them out, since it uses the `dbt` command line for both parsing and filtering the nodes. @@ -288,37 +448,33 @@ def load_via_dbt_ls(self) -> None: * self.nodes * self.filtered_nodes """ + self.load_method = LoadMode.DBT_LS self.render_config.validate_dbt_command(fallback_cmd=self.execution_config.dbt_executable_path) dbt_cmd = self.render_config.dbt_executable_path dbt_cmd = dbt_cmd.as_posix() if isinstance(dbt_cmd, Path) else dbt_cmd logger.info(f"Trying to parse the dbt project in `{self.render_config.project_path}` using dbt ls...") - if not self.render_config.project_path or not self.execution_config.project_path: - raise CosmosLoadDbtException( - "Unable to load project via dbt ls without RenderConfig.dbt_project_path and ExecutionConfig.dbt_project_path" - ) - + project_path = self.project_path if not self.profile_config: raise CosmosLoadDbtException("Unable to load project via dbt ls without a profile config.") with tempfile.TemporaryDirectory() as tmpdir: - logger.info( + logger.debug( f"Content of the dbt project dir {self.render_config.project_path}: `{os.listdir(self.render_config.project_path)}`" ) tmpdir_path = Path(tmpdir) - abs_project_path = self.render_config.project_path.absolute() - create_symlinks(abs_project_path, tmpdir_path, self.render_config.dbt_deps) + create_symlinks(project_path, tmpdir_path, self.render_config.dbt_deps) if self.project.partial_parse and self.cache_dir: - latest_partial_parse = cache._get_latest_partial_parse(abs_project_path, self.cache_dir) + latest_partial_parse = cache._get_latest_partial_parse(project_path, self.cache_dir) logger.info("Partial parse is enabled and the latest partial parse file is %s", latest_partial_parse) if latest_partial_parse is not None: cache._copy_partial_parse_to_project(latest_partial_parse, tmpdir_path) with self.profile_config.ensure_profile( use_mock_values=self.render_config.enable_mock_profile - ) as profile_values, environ(self.project.env_vars or self.render_config.env_vars or {}): + ) as profile_values, environ(self.env_vars): (profile_path, env_vars) = profile_values env = os.environ.copy() env.update(env_vars) @@ -338,15 +494,13 @@ def load_via_dbt_ls(self) -> None: env[DBT_LOG_PATH_ENVVAR] = str(self.log_dir) env[DBT_TARGET_PATH_ENVVAR] = str(self.target_dir) - if self.render_config.dbt_deps and has_non_empty_dependencies_file( - Path(self.render_config.project_path) - ): + if self.render_config.dbt_deps and has_non_empty_dependencies_file(self.project_path): deps_command = [dbt_cmd, "deps"] deps_command.extend(self.local_flags) stdout = run_command(deps_command, tmpdir_path, env) logger.debug("dbt deps output: %s", stdout) - nodes = self.run_dbt_ls(dbt_cmd, self.execution_config.project_path, tmpdir_path, env) + nodes = self.run_dbt_ls(dbt_cmd, self.project_path, tmpdir_path, env) self.nodes = nodes self.filtered_nodes = nodes @@ -365,6 +519,7 @@ def load_via_dbt_ls_file(self) -> None: This technically should increase performance and also removes the necessity to have your whole dbt project copied to the airflow image. """ + self.load_method = LoadMode.DBT_LS_FILE logger.info("Trying to parse the dbt project `%s` using a dbt ls output file...", self.project.project_name) if not self.render_config.is_dbt_ls_file_available(): @@ -392,6 +547,7 @@ def load_via_custom_parser(self) -> None: * self.nodes * self.filtered_nodes """ + self.load_method = LoadMode.CUSTOM logger.info("Trying to parse the dbt project `%s` using a custom Cosmos method...", self.project.project_name) if self.render_config.selector: @@ -450,6 +606,7 @@ def load_from_dbt_manifest(self) -> None: * self.nodes * self.filtered_nodes """ + self.load_method = LoadMode.DBT_MANIFEST logger.info("Trying to parse the dbt project `%s` using a dbt manifest...", self.project.project_name) if self.render_config.selector: diff --git a/cosmos/settings.py b/cosmos/settings.py index fc59541315..68ed8758ff 100644 --- a/cosmos/settings.py +++ b/cosmos/settings.py @@ -11,6 +11,8 @@ DEFAULT_CACHE_DIR = Path(tempfile.gettempdir(), DEFAULT_COSMOS_CACHE_DIR_NAME) cache_dir = Path(conf.get("cosmos", "cache_dir", fallback=DEFAULT_CACHE_DIR) or DEFAULT_CACHE_DIR) enable_cache = conf.getboolean("cosmos", "enable_cache", fallback=True) +enable_cache_partial_parse = conf.getboolean("cosmos", "enable_cache_partial_parse", fallback=True) +enable_cache_dbt_ls = conf.getboolean("cosmos", "enable_cache_dbt_ls", fallback=True) propagate_logs = conf.getboolean("cosmos", "propagate_logs", fallback=True) dbt_docs_dir = conf.get("cosmos", "dbt_docs_dir", fallback=None) dbt_docs_conn_id = conf.get("cosmos", "dbt_docs_conn_id", fallback=None) diff --git a/dev/dags/basic_cosmos_task_group.py b/dev/dags/basic_cosmos_task_group.py index 4221e30190..d63cf2c923 100644 --- a/dev/dags/basic_cosmos_task_group.py +++ b/dev/dags/basic_cosmos_task_group.py @@ -43,10 +43,13 @@ def basic_cosmos_task_group() -> None: customers = DbtTaskGroup( group_id="customers", - project_config=ProjectConfig( - (DBT_ROOT_PATH / "jaffle_shop").as_posix(), + project_config=ProjectConfig((DBT_ROOT_PATH / "jaffle_shop").as_posix(), dbt_vars={"var": "2"}), + render_config=RenderConfig( + select=["path:seeds/raw_customers.csv"], + enable_mock_profile=False, + env_vars={"PURGE": os.getenv("PURGE", "0")}, + airflow_vars_to_purge_dbt_ls_cache=["purge"], ), - render_config=RenderConfig(select=["path:seeds/raw_customers.csv"], enable_mock_profile=False), execution_config=shared_execution_config, operator_args={"install_deps": True}, profile_config=profile_config, diff --git a/dev/dags/example_cosmos_cleanup_dag.py b/dev/dags/example_cosmos_cleanup_dag.py new file mode 100644 index 0000000000..c93bdf0020 --- /dev/null +++ b/dev/dags/example_cosmos_cleanup_dag.py @@ -0,0 +1,34 @@ +""" +Example of cleanup DAG that can be used to clear cache originated from running the dbt ls command while +parsing the DbtDag or DbtTaskGroup since Cosmos 1.5. +""" + +# [START cache_example] +from datetime import datetime, timedelta + +from airflow.decorators import dag, task + +from cosmos.cache import delete_unused_dbt_ls_cache + + +@dag( + schedule_interval="0 0 * * 0", # Runs every Sunday + start_date=datetime(2023, 1, 1), + catchup=False, + tags=["example"], +) +def example_cosmos_cleanup_dag(): + + @task() + def clear_db_ls_cache(session=None): + """ + Delete the dbt ls cache that has not been used for the last five days. + """ + delete_unused_dbt_ls_cache(max_age_last_usage=timedelta(days=5)) + + clear_db_ls_cache() + + +# [END cache_example] + +example_cosmos_cleanup_dag() diff --git a/docs/configuration/caching.rst b/docs/configuration/caching.rst new file mode 100644 index 0000000000..b5ec155da2 --- /dev/null +++ b/docs/configuration/caching.rst @@ -0,0 +1,118 @@ +.. _caching: + +Caching +======= + +This page explains the caching strategies in ``astronomer-cosmos`` Astronomer Cosmos behavior. + +All Cosmos caching mechanisms can be enabled or turned off in the ``airflow.cfg`` file or using environment variables. + +.. note:: + For more information, see `configuring a Cosmos project <./project-config.html>`_. + +Depending on the Cosmos version, it creates a cache for two types of data: + +- The ``dbt ls`` output +- The dbt ``partial_parse.msgpack`` file + +It is possible to turn off any cache in Cosmos by exporting the environment variable ``AIRFLOW__COSMOS__ENABLE_CACHE=0``. +Disabling individual types of cache in Cosmos is also possible, as explained below. + +Caching the dbt ls output +~~~~~~~~~~~~~ + +(Introduced in Cosmos 1.5) + +While parsing a dbt project using `LoadMode.DBT_LS <./parsing-methods.html#dbt-ls>`_, Cosmos uses subprocess to run ``dbt ls``. +This operation can be very costly; it can increase the DAG parsing times and affect not only the scheduler DAG processing but +also the tasks queueing time. + +Cosmos 1.5 introduced a feature to mitigate the performance issue associated with ``LoadMode.DBT_LS`` by caching the output +of this command as an `Airflow Variable `_. +Based on an initial `analysis `_, enabling this setting reduced some DAGs ask queueing from 30s to 0s. Additionally, some users `reported improvements of 84% `_ in the DAG run time. + +This feature is on by default. To turn it off, export the following environment variable: ``AIRFLOW__COSMOS__ENABLE_CACHE_DBT_LS=0``. + +**How the cache is refreshed** + +Users can purge or delete the cache via Airflow UI by identifying and deleting the cache key. + +Cosmos will refresh the cache in a few circumstances: + +* if any files of the dbt project change +* if one of the arguments that affect the dbt ls command execution changes + +To evaluate if the dbt project changed, it calculates the changes using a few of the MD5 of all the files in the directory. + +Additionally, if any of the following DAG configurations are changed, we'll automatically purge the cache of the DAGs that use that specific configuration: + +* ``ProjectConfig.dbt_vars`` +* ``ProjectConfig.env_vars`` +* ``ProjectConfig.partial_parse`` +* ``RenderConfig.env_vars`` +* ``RenderConfig.exclude`` +* ``RenderConfig.select`` +* ``RenderConfig.selector`` + +Finally, if users would like to define specific Airflow variables that, if changed, will cause the recreation of the cache, they can specify those by using: + +* ``RenderConfig.airflow_vars_to_purge_cache`` + +Example: + +.. code-block:: python + + RenderConfig(airflow_vars_to_purge_cache == ["refresh_cache"]) + +**Cleaning up stale cache** + +Not rarely, Cosmos DbtDags and DbtTaskGroups may be renamed or deleted. In those cases, to clean up the Airflow metadata database, it is possible to use the method ``delete_unused_dbt_ls_cache``. + +The method deletes the Cosmos cache stored in Airflow Variables based on the last execution of their associated DAGs. + +As an example, the following clean-up DAG will delete any cache associated with Cosmos that has not been used for the last five days: + +.. literalinclude:: ../../dev/dags/example_cosmos_cleanup_dag.py + :language: python + :start-after: [START cache_example] + :end-before: [END cache_example] + +**Cache key** + +The Airflow variables that represent the dbt ls cache are prefixed by ``cosmos_cache``. +When using ``DbtDag``, the keys use the DAG name. When using ``DbtTaskGroup``, they contain the ``TaskGroup`` and parent task groups and DAG. + +Examples: + +* The ``DbtDag`` "cosmos_dag" will have the cache represented by "cosmos_cache__basic_cosmos_dag". +* The ``DbtTaskGroup`` "customers" declared inside the DAG "basic_cosmos_task_group" will have the cache key "cosmos_cache__basic_cosmos_task_group__customers". + +**Cache value** + +The cache values contain a few properties: + +* ``last_modified`` timestamp, represented using the ISO 8601 format. +* ``version`` is a hash that represents the version of the dbt project and arguments used to run dbt ls by the time Cosmos created the cache +* ``dbt_ls_compressed`` represents the dbt ls output compressed using zlib and encoded to base64 so Cosmos can record the value as a compressed string in the Airflow metadata database. +* ``dag_id`` is the DAG associated to this cache +* ``task_group_id`` is the TaskGroup associated to this cache +* ``cosmos_type`` is either ``DbtDag`` or ``DbtTaskGroup`` + + +Caching the partial parse file +~~~~~~~~~~~~~ + +(Introduced in Cosmos 1.4) + +After parsing the dbt project, dbt stores an internal project manifest in a file called ``partial_parse.msgpack`` (`official docs `_). +This file contributes significantly to the performance of running dbt commands when the dbt project did not change. + +Cosmos 1.4 introduced `support to partial parse files `_ both +provided by the user, and also by storing in the disk temporary folder in the Airflow scheduler and worker node the file +generated after running dbt commands. + +Users can customize where to store the cache using the setting ``AIRFLOW__COSMOS__CACHE_DIR``. + +It is possible to switch off this feature by exporting the environment variable ``AIRFLOW__COSMOS__ENABLE_CACHE_PARTIAL_PARSE=0``. + +For more information, read the `Cosmos partial parsing documentation <./partial-parsing.html>`_ diff --git a/docs/configuration/cosmos-conf.rst b/docs/configuration/cosmos-conf.rst index 1d334884fa..9c1b56c891 100644 --- a/docs/configuration/cosmos-conf.rst +++ b/docs/configuration/cosmos-conf.rst @@ -30,6 +30,22 @@ This page lists all available Airflow configurations that affect ``astronomer-co - Default: ``True`` - Environment Variable: ``AIRFLOW__COSMOS__ENABLE_CACHE`` +.. enable_cache_dbt_ls: + +`enable_cache_dbt_ls`_: + Enable or disable caching of the dbt ls command in case using ``LoadMode.DBT_LS`` in an Airflow Variable. + + - Default: ``True`` + - Environment Variable: ``AIRFLOW__COSMOS__ENABLE_CACHE_DBT_LS`` + +.. _enable_cache_partial_parse: + +`enable_cache_partial_parse`_: + Enable or disable caching of dbt partial parse files in the local disk. + + - Default: ``True`` + - Environment Variable: ``AIRFLOW__COSMOS__ENABLE_CACHE_PARTIAL_PARSE`` + .. _propagate_logs: `propagate_logs`_: diff --git a/docs/configuration/index.rst b/docs/configuration/index.rst index fc34b993e0..90f1959385 100644 --- a/docs/configuration/index.rst +++ b/docs/configuration/index.rst @@ -25,3 +25,4 @@ Cosmos offers a number of configuration options to customize its behavior. For m Operator Args Compiled SQL Logging + Caching diff --git a/docs/configuration/parsing-methods.rst b/docs/configuration/parsing-methods.rst index 14dafb0212..ebd6030e6b 100644 --- a/docs/configuration/parsing-methods.rst +++ b/docs/configuration/parsing-methods.rst @@ -66,6 +66,9 @@ If you don't have a ``manifest.json`` file, Cosmos will attempt to generate one When Cosmos runs ``dbt ls``, it also passes your ``select`` and ``exclude`` arguments to the command. This means that Cosmos will only generate a manifest for the models you want to run. +Starting in Cosmos 1.5, Cosmos will cache the output of the ``dbt ls`` command, to improve the performance of this +parsing method. Learn more `here <./caching.html>`_. + To use this: .. code-block:: python diff --git a/docs/configuration/render-config.rst b/docs/configuration/render-config.rst index f3e2167125..4b2535e076 100644 --- a/docs/configuration/render-config.rst +++ b/docs/configuration/render-config.rst @@ -17,6 +17,7 @@ The ``RenderConfig`` class takes the following arguments: - ``dbt_executable_path``: The path to the dbt executable for dag generation. Defaults to dbt if available on the path. - ``env_vars``: (available in v1.2.5, use``ProjectConfig.env_vars`` for v1.3.0 onwards) A dictionary of environment variables for rendering. Only supported when using ``load_method=LoadMode.DBT_LS``. - ``dbt_project_path``: Configures the DBT project location accessible on their airflow controller for DAG rendering - Required when using ``load_method=LoadMode.DBT_LS`` or ``load_method=LoadMode.CUSTOM`` +- ``airflow_vars_to_purge_cache``: (new in v1.5) Specify Airflow variables that will affect the ``LoadMode.DBT_LS`` cache. See `Caching <./caching.html>`_ for more information. Customizing how nodes are rendered (experimental) ------------------------------------------------- diff --git a/pyproject.toml b/pyproject.toml index c8bee0b202..6c518613b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,6 +81,7 @@ tests = [ "pytest-cov", "pytest-describe", "sqlalchemy-stubs", # Change when sqlalchemy is upgraded https://docs.sqlalchemy.org/en/14/orm/extensions/mypy.html + "types-pytz", "types-requests", "sqlalchemy-stubs", # Change when sqlalchemy is upgraded https://docs.sqlalchemy.org/en/14/orm/extensions/mypy.html "pre-commit", @@ -136,7 +137,7 @@ dependencies = [ "types-requests", "types-python-dateutil", "Werkzeug<3.0.0", - "apache-airflow=={matrix:airflow}.0", + "apache-airflow~={matrix:airflow}.0,!=2.9.0,!=2.9.1", # https://github.com/apache/airflow/pull/39670 ] pre-install-commands = ["sh scripts/test/pre-install-airflow.sh {matrix:airflow} {matrix:python}"] diff --git a/scripts/test/integration-setup.sh b/scripts/test/integration-setup.sh index eba4f15137..c6e106fd56 100644 --- a/scripts/test/integration-setup.sh +++ b/scripts/test/integration-setup.sh @@ -1,6 +1,14 @@ +#!/bin/bash + +set -v +set -x +set -e + # we install using the following workaround to overcome installation conflicts, such as: # apache-airflow 2.3.0 and dbt-core [0.13.0 - 1.5.2] and jinja2>=3.0.0 because these package versions have conflicting dependencies -pip uninstall -y dbt-postgres dbt-databricks dbt-vertica; \ -rm -rf airflow.*; \ -airflow db init; \ +pip uninstall -y dbt-postgres dbt-databricks dbt-vertica +rm -rf airflow.* +pip freeze | grep airflow +airflow db reset -y +airflow db init pip install 'dbt-core' 'dbt-databricks' 'dbt-postgres' 'dbt-vertica' 'openlineage-airflow' diff --git a/scripts/test/integration.sh b/scripts/test/integration.sh index 823f70a7e2..1d8264768a 100644 --- a/scripts/test/integration.sh +++ b/scripts/test/integration.sh @@ -1,3 +1,15 @@ +#!/bin/bash + +set -x +set -e + +pip freeze | grep airflow +echo $AIRFLOW_HOME +ls $AIRFLOW_HOME + +airflow db check + + rm -rf dbt/jaffle_shop/dbt_packages; pytest -vv \ --cov=cosmos \ diff --git a/tests/dbt/test_graph.py b/tests/dbt/test_graph.py index 652a814827..9e931ba8c9 100644 --- a/tests/dbt/test_graph.py +++ b/tests/dbt/test_graph.py @@ -1,12 +1,17 @@ +import importlib +import os import shutil +import sys import tempfile +from datetime import datetime from pathlib import Path from subprocess import PIPE, Popen from unittest.mock import MagicMock, patch import pytest -import yaml +from airflow.models import Variable +from cosmos import settings from cosmos.config import CosmosConfigException, ExecutionConfig, ProfileConfig, ProjectConfig, RenderConfig from cosmos.constants import DBT_TARGET_DIR_NAME, DbtResourceType, ExecutionMode from cosmos.dbt.graph import ( @@ -587,7 +592,7 @@ def test_load_via_dbt_ls_without_dbt_deps(postgres_profile_config): ) with pytest.raises(CosmosLoadDbtException) as err_info: - dbt_graph.load_via_dbt_ls() + dbt_graph.load_via_dbt_ls_without_cache() expected = "Unable to run dbt ls command due to missing dbt_packages. Set RenderConfig.dbt_deps=True." assert err_info.value.args[0] == expected @@ -658,12 +663,12 @@ def test_load_via_dbt_ls_caching_partial_parsing(tmp_dbt_project_dir, postgres_p (tmp_path / DBT_TARGET_DIR_NAME).mkdir(parents=True, exist_ok=True) # First time dbt ls is run, partial parsing was not cached, so we don't benefit from this - dbt_graph.load_via_dbt_ls() + dbt_graph.load_via_dbt_ls_without_cache() assert "Unable to do partial parsing" in caplog.text # From the second time we run dbt ls onwards, we benefit from partial parsing caplog.clear() - dbt_graph.load_via_dbt_ls() # should not not raise exception + dbt_graph.load_via_dbt_ls_without_cache() # should not not raise exception assert not "Unable to do partial parsing" in caplog.text @@ -978,10 +983,13 @@ def test_parse_dbt_ls_output_with_json_without_tags_or_config(): assert expected_nodes == nodes +@patch("cosmos.dbt.graph.DbtGraph.should_use_dbt_ls_cache", return_value=False) @patch("cosmos.dbt.graph.Popen") @patch("cosmos.dbt.graph.DbtGraph.update_node_dependency") @patch("cosmos.config.RenderConfig.validate_dbt_command") -def test_load_via_dbt_ls_project_config_env_vars(mock_validate, mock_update_nodes, mock_popen, tmp_dbt_project_dir): +def test_load_via_dbt_ls_project_config_env_vars( + mock_validate, mock_update_nodes, mock_popen, mock_enable_cache, tmp_dbt_project_dir +): """Tests that the dbt ls command in the subprocess has the project config env vars set.""" mock_popen().communicate.return_value = ("", "") mock_popen().returncode = 0 @@ -1006,10 +1014,13 @@ def test_load_via_dbt_ls_project_config_env_vars(mock_validate, mock_update_node assert mock_popen.call_args.kwargs["env"]["MY_ENV_VAR"] == "my_value" +@patch("cosmos.dbt.graph.DbtGraph.should_use_dbt_ls_cache", return_value=False) @patch("cosmos.dbt.graph.Popen") @patch("cosmos.dbt.graph.DbtGraph.update_node_dependency") @patch("cosmos.config.RenderConfig.validate_dbt_command") -def test_load_via_dbt_ls_project_config_dbt_vars(mock_validate, mock_update_nodes, mock_popen, tmp_dbt_project_dir): +def test_load_via_dbt_ls_project_config_dbt_vars( + mock_validate, mock_update_nodes, mock_popen, mock_use_case, tmp_dbt_project_dir +): """Tests that the dbt ls command in the subprocess has "--vars" with the project config dbt_vars.""" mock_popen().communicate.return_value = ("", "") mock_popen().returncode = 0 @@ -1031,14 +1042,15 @@ def test_load_via_dbt_ls_project_config_dbt_vars(mock_validate, mock_update_node dbt_graph.load_via_dbt_ls() ls_command = mock_popen.call_args.args[0] assert "--vars" in ls_command - assert ls_command[ls_command.index("--vars") + 1] == yaml.dump(dbt_vars) + assert ls_command[ls_command.index("--vars") + 1] == '{"my_var1": "my_value1", "my_var2": "my_value2"}' +@patch("cosmos.dbt.graph.DbtGraph.should_use_dbt_ls_cache", return_value=False) @patch("cosmos.dbt.graph.Popen") @patch("cosmos.dbt.graph.DbtGraph.update_node_dependency") @patch("cosmos.config.RenderConfig.validate_dbt_command") def test_load_via_dbt_ls_render_config_selector_arg_is_used( - mock_validate, mock_update_nodes, mock_popen, tmp_dbt_project_dir + mock_validate, mock_update_nodes, mock_popen, mock_enable_cache, tmp_dbt_project_dir ): """Tests that the dbt ls command in the subprocess has "--selector" with the RenderConfig.selector.""" mock_popen().communicate.return_value = ("", "") @@ -1068,11 +1080,12 @@ def test_load_via_dbt_ls_render_config_selector_arg_is_used( assert ls_command[ls_command.index("--selector") + 1] == selector +@patch("cosmos.dbt.graph.DbtGraph.should_use_dbt_ls_cache", return_value=False) @patch("cosmos.dbt.graph.Popen") @patch("cosmos.dbt.graph.DbtGraph.update_node_dependency") @patch("cosmos.config.RenderConfig.validate_dbt_command") def test_load_via_dbt_ls_render_config_no_partial_parse( - mock_validate, mock_update_nodes, mock_popen, tmp_dbt_project_dir + mock_validate, mock_update_nodes, mock_popen, mock_enable_cache, tmp_dbt_project_dir ): """Tests that --no-partial-parse appears when partial_parse=False.""" mock_popen().communicate.return_value = ("", "") @@ -1180,3 +1193,219 @@ def test_load_via_dbt_ls_with_selector_arg(tmp_dbt_project_dir, postgres_profile assert "seed.jaffle_shop.raw_customers" in filtered_nodes # Two tests should be filtered assert sum(node.startswith("test.jaffle_shop") for node in filtered_nodes) == 2 + + +@pytest.mark.parametrize( + "render_config,project_config,expected_envvars", + [ + (RenderConfig(), ProjectConfig(), {}), + (RenderConfig(env_vars={"a": 1}), ProjectConfig(), {"a": 1}), + (RenderConfig(), ProjectConfig(env_vars={"b": 2}), {"b": 2}), + (RenderConfig(env_vars={"a": 1}), ProjectConfig(env_vars={"b": 2}), {"a": 1}), + ], +) +def test_env_vars(render_config, project_config, expected_envvars): + graph = DbtGraph( + project=project_config, + render_config=render_config, + ) + assert graph.env_vars == expected_envvars + + +def test_project_path_fails(): + graph = DbtGraph(project=ProjectConfig()) + with pytest.raises(CosmosLoadDbtException) as e: + graph.project_path + + expected = "Unable to load project via dbt ls without RenderConfig.dbt_project_path, ProjectConfig.dbt_project_path or ExecutionConfig.dbt_project_path" + assert e.value.args[0] == expected + + +@pytest.mark.parametrize( + "render_config,project_config,expected_dbt_ls_args", + [ + (RenderConfig(), ProjectConfig(), []), + (RenderConfig(exclude=["package:snowplow"]), ProjectConfig(), ["--exclude", "package:snowplow"]), + ( + RenderConfig(select=["tag:prod", "config.materialized:incremental"]), + ProjectConfig(), + ["--select", "tag:prod", "config.materialized:incremental"], + ), + (RenderConfig(selector="nightly"), ProjectConfig(), ["--selector", "nightly"]), + (RenderConfig(), ProjectConfig(dbt_vars={"a": 1}), ["--vars", '{"a": 1}']), + (RenderConfig(), ProjectConfig(partial_parse=False), ["--no-partial-parse"]), + ( + RenderConfig(exclude=["1", "2"], select=["a", "b"], selector="nightly"), + ProjectConfig(dbt_vars={"a": 1}, partial_parse=False), + [ + "--exclude", + "1", + "2", + "--select", + "a", + "b", + "--vars", + '{"a": 1}', + "--selector", + "nightly", + "--no-partial-parse", + ], + ), + ], +) +def test_dbt_ls_args(render_config, project_config, expected_dbt_ls_args): + graph = DbtGraph( + project=project_config, + render_config=render_config, + ) + assert graph.dbt_ls_args == expected_dbt_ls_args + + +def test_dbt_ls_cache_key_args_sorts_envvars(): + project_config = ProjectConfig(env_vars={11: "November", 12: "December", 5: "May"}) + graph = DbtGraph(project=project_config) + assert graph.dbt_ls_cache_key_args == ['{"5": "May", "11": "November", "12": "December"}'] + + +@pytest.fixture() +def airflow_variable(): + key = "cosmos_cache__undefined" + value = "some_value" + Variable.set(key, value) + + yield key, value + + Variable.delete(key) + + +@pytest.mark.integration +def test_dbt_ls_cache_key_args_uses_airflow_vars_to_purge_dbt_ls_cache(airflow_variable): + key, value = airflow_variable + graph = DbtGraph(project=ProjectConfig(), render_config=RenderConfig(airflow_vars_to_purge_dbt_ls_cache=[key])) + assert graph.dbt_ls_cache_key_args == [key, value] + + +@patch("cosmos.dbt.graph.datetime") +@patch("cosmos.dbt.graph.Variable.set") +def test_save_dbt_ls_cache(mock_variable_set, mock_datetime, tmp_dbt_project_dir): + mock_datetime.datetime.now.return_value = datetime(2022, 1, 1, 12, 0, 0) + graph = DbtGraph(cache_identifier="something", project=ProjectConfig(dbt_project_path=tmp_dbt_project_dir)) + dbt_ls_output = "some output" + graph.save_dbt_ls_cache(dbt_ls_output) + assert mock_variable_set.call_args[0][0] == "cosmos_cache__something" + assert mock_variable_set.call_args[0][1]["dbt_ls_compressed"] == "eJwrzs9NVcgvLSkoLQEAGpAEhg==" + assert mock_variable_set.call_args[0][1]["last_modified"] == "2022-01-01T12:00:00" + version = mock_variable_set.call_args[0][1].get("version") + hash_dir, hash_args = version.split(",") + assert hash_args == "d41d8cd98f00b204e9800998ecf8427e" + if sys.platform == "darwin": + assert hash_dir == "cdc6f0bec00f4edc616f3aa755a34330" + else: + assert hash_dir == "77d08d6da374330ac1b49438ff2873f7" + + +@pytest.mark.integration +def test_get_dbt_ls_cache_returns_empty_if_non_json_var(airflow_variable): + graph = DbtGraph(project=ProjectConfig()) + assert graph.get_dbt_ls_cache() == {} + + +@patch("cosmos.dbt.graph.Variable.get", return_value={"dbt_ls_compressed": "eJwrzs9NVcgvLSkoLQEAGpAEhg=="}) +def test_get_dbt_ls_cache_returns_decoded_and_decompressed_value(mock_variable_get): + graph = DbtGraph(project=ProjectConfig()) + assert graph.get_dbt_ls_cache() == {"dbt_ls": "some output"} + + +@patch("cosmos.dbt.graph.Variable.get", return_value={}) +def test_get_dbt_ls_cache_returns_empty_dict_if_empty_dict_var(mock_variable_get): + graph = DbtGraph(project=ProjectConfig()) + assert graph.get_dbt_ls_cache() == {} + + +@patch("cosmos.dbt.graph.DbtGraph.load_via_dbt_ls_without_cache") +@patch("cosmos.dbt.graph.DbtGraph.load_via_dbt_ls_cache", return_value=True) +def test_load_via_dbt_ls_does_not_call_without_cache(mock_cache, mock_without_cache): + graph = DbtGraph(project=ProjectConfig()) + graph.load_via_dbt_ls() + assert mock_cache.called + assert not mock_without_cache.called + + +@patch("cosmos.dbt.graph.DbtGraph.load_via_dbt_ls_without_cache") +@patch("cosmos.dbt.graph.DbtGraph.load_via_dbt_ls_cache", return_value=False) +def test_load_via_dbt_ls_calls_without_cache(mock_cache, mock_without_cache): + graph = DbtGraph(project=ProjectConfig()) + graph.load_via_dbt_ls() + assert mock_cache.called + assert mock_without_cache.called + + +@patch("cosmos.dbt.graph.DbtGraph.should_use_dbt_ls_cache", return_value=False) +def test_load_via_dbt_ls_cache_is_false_if_disabled(mock_should_use_dbt_ls_cache): + graph = DbtGraph(project=ProjectConfig()) + assert not graph.load_via_dbt_ls_cache() + assert mock_should_use_dbt_ls_cache.called + + +@patch("cosmos.dbt.graph.DbtGraph.get_dbt_ls_cache", return_value={}) +@patch("cosmos.dbt.graph.DbtGraph.should_use_dbt_ls_cache", return_value=True) +def test_load_via_dbt_ls_cache_is_false_if_no_cache(mock_should_use_dbt_ls_cache, mock_get_dbt_ls_cache): + graph = DbtGraph(project=ProjectConfig(dbt_project_path="/tmp")) + assert not graph.load_via_dbt_ls_cache() + assert mock_should_use_dbt_ls_cache.called + assert mock_get_dbt_ls_cache.called + + +@patch("cosmos.dbt.graph.cache._calculate_dbt_ls_cache_current_version", return_value=1) +@patch("cosmos.dbt.graph.DbtGraph.get_dbt_ls_cache", return_value={"version": 2, "dbt_ls": "output"}) +@patch("cosmos.dbt.graph.DbtGraph.should_use_dbt_ls_cache", return_value=True) +def test_load_via_dbt_ls_cache_is_false_if_cache_is_outdated( + mock_should_use_dbt_ls_cache, mock_get_dbt_ls_cache, mock_calculate_current_version +): + graph = DbtGraph(project=ProjectConfig(dbt_project_path="/tmp")) + assert not graph.load_via_dbt_ls_cache() + assert mock_should_use_dbt_ls_cache.called + assert mock_get_dbt_ls_cache.called + assert mock_calculate_current_version.called + + +@patch("cosmos.dbt.graph.parse_dbt_ls_output", return_value={"some-node": {}}) +@patch("cosmos.dbt.graph.cache._calculate_dbt_ls_cache_current_version", return_value=1) +@patch("cosmos.dbt.graph.DbtGraph.get_dbt_ls_cache", return_value={"version": 1, "dbt_ls": "output"}) +@patch("cosmos.dbt.graph.DbtGraph.should_use_dbt_ls_cache", return_value=True) +def test_load_via_dbt_ls_cache_is_true( + mock_should_use_dbt_ls_cache, mock_get_dbt_ls_cache, mock_calculate_current_version, mock_parse_dbt_ls_output +): + graph = DbtGraph(project=ProjectConfig(dbt_project_path="/tmp")) + assert graph.load_via_dbt_ls_cache() + assert graph.load_method == LoadMode.DBT_LS_CACHE + assert graph.nodes == {"some-node": {}} + assert graph.filtered_nodes == {"some-node": {}} + assert mock_should_use_dbt_ls_cache.called + assert mock_get_dbt_ls_cache.called + assert mock_calculate_current_version.called + assert mock_parse_dbt_ls_output.called + + +@pytest.mark.parametrize( + "enable_cache,enable_cache_dbt_ls,cache_id,should_use", + [ + (False, True, "id", False), + (True, False, "id", False), + (False, False, "id", False), + (True, True, "", False), + (True, True, "id", True), + ], +) +def test_should_use_dbt_ls_cache(enable_cache, enable_cache_dbt_ls, cache_id, should_use): + with patch.dict( + os.environ, + { + "AIRFLOW__COSMOS__ENABLE_CACHE": str(enable_cache), + "AIRFLOW__COSMOS__ENABLE_CACHE_DBT_LS": str(enable_cache_dbt_ls), + }, + ): + importlib.reload(settings) + graph = DbtGraph(cache_identifier=cache_id, project=ProjectConfig(dbt_project_path="/tmp")) + graph.should_use_dbt_ls_cache.cache_clear() + assert graph.should_use_dbt_ls_cache() == should_use diff --git a/tests/test_cache.py b/tests/test_cache.py index 7d6a2d36c8..9cd216998f 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -2,12 +2,14 @@ import shutil import tempfile import time -from datetime import datetime +from datetime import datetime, timedelta, timezone from pathlib import Path from unittest.mock import call, patch import pytest from airflow import DAG +from airflow.models import DagRun, Variable +from airflow.utils.db import create_session from airflow.utils.task_group import TaskGroup from cosmos.cache import ( @@ -15,6 +17,7 @@ _create_cache_identifier, _get_latest_partial_parse, _update_partial_parse_cache, + delete_unused_dbt_ls_cache, ) from cosmos.constants import DBT_PARTIAL_PARSE_FILE_NAME, DBT_TARGET_DIR_NAME @@ -112,3 +115,70 @@ def test_update_partial_parse_cache(mock_get_partial_parse_path, mock_copyfile): call(str(latest_partial_parse_filepath.parent / "manifest.json"), str(manifest_path)), ] mock_copyfile.assert_has_calls(calls) + + +@pytest.fixture +def vars_session(): + with create_session() as session: + var1 = Variable(key="cosmos_cache__dag_a", val='{"dag_id": "dag_a"}') + var2 = Variable(key="cosmos_cache__dag_b", val='{"dag_id": "dag_b"}') + var3 = Variable(key="cosmos_cache__dag_c__task_group_1", val='{"dag_id": "dag_c"}') + + dag_run_a = DagRun( + dag_id="dag_a", + run_id="dag_a_run_a_week_ago", + execution_date=datetime.now(timezone.utc) - timedelta(days=7), + state="success", + run_type="manual", + ) + dag_run_b = DagRun( + dag_id="dag_b", + run_id="dag_b_run_yesterday", + execution_date=datetime.now(timezone.utc) - timedelta(days=1), + state="failed", + run_type="manual", + ) + dag_run_c = DagRun( + dag_id="dag_c", + run_id="dag_c_run_on_hour_ago", + execution_date=datetime.now(timezone.utc) - timedelta(hours=1), + state="running", + run_type="manual", + ) + + session.add(var1) + session.add(var2) + session.add(var3) + session.add(dag_run_a) + session.add(dag_run_b) + session.add(dag_run_c) + session.commit() + + yield session + + session.query(Variable).filter_by(key="cosmos_cache__dag_a").delete() + session.query(Variable).filter_by(key="cosmos_cache__dag_b").delete() + session.query(Variable).filter_by(key="cosmos_cache__dag_c__task_group_1").delete() + + session.query(DagRun).filter_by(dag_id="dag_a", run_id="dag_a_run_a_week_ago").delete() + session.query(DagRun).filter_by(dag_id="dag_b", run_id="dag_b_run_yesterday").delete() + session.query(DagRun).filter_by(dag_id="dag_c", run_id="dag_c_run_on_hour_ago").delete() + session.commit() + + +@pytest.mark.integration +def test_delete_unused_dbt_ls_cache_deletes_a_week_ago_cache(vars_session): + assert vars_session.query(Variable).filter_by(key="cosmos_cache__dag_a").first() + assert delete_unused_dbt_ls_cache(max_age_last_usage=timedelta(days=5), session=vars_session) == 1 + assert not vars_session.query(Variable).filter_by(key="cosmos_cache__dag_a").first() + + +@pytest.mark.integration +def test_delete_unused_dbt_ls_cache_deletes_all_cache_five_minutes_ago(vars_session): + assert vars_session.query(Variable).filter_by(key="cosmos_cache__dag_a").first() + assert vars_session.query(Variable).filter_by(key="cosmos_cache__dag_b").first() + assert vars_session.query(Variable).filter_by(key="cosmos_cache__dag_c__task_group_1").first() + assert delete_unused_dbt_ls_cache(max_age_last_usage=timedelta(minutes=5), session=vars_session) == 3 + assert not vars_session.query(Variable).filter_by(key="cosmos_cache__dag_a").first() + assert not vars_session.query(Variable).filter_by(key="cosmos_cache__dag_b").first() + assert not vars_session.query(Variable).filter_by(key="cosmos_cache__dag_c__task_group_1").first() From a9b5c3eb9cb055e5a641189d118ff0046b333c29 Mon Sep 17 00:00:00 2001 From: Jed Cunningham <66968678+jedcunningham@users.noreply.github.com> Date: Wed, 26 Jun 2024 14:09:14 -0600 Subject: [PATCH 212/223] Remove duplicates in changelog (#1068) ## Description It appears there was an accident resolving conflicts in the changelog, which resulted in 1.4.2 and 1.4.1 (with the content for 1.4.3) being listed twice. ## Related Issue(s) N/A ## Breaking Change? No ## Checklist - [ ] I have made corresponding changes to the documentation (if required) - [ ] I have added tests that prove my fix is effective or that my feature works --- CHANGELOG.rst | 42 ------------------------------------------ 1 file changed, 42 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 065209c997..a760e1d95d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -50,48 +50,6 @@ Docs * Fix docs so it does not reference non-existing ``get_dbt_dataset`` by @tatiana in #1034 -v1.4.2 (2024-06-06) -------------------- - -Bug fixes - -* Fix the invocation mode for ``ExecutionMode.VIRTUALENV`` by @marco9663 in #1023 -* Fix Cosmos ``enable_cache`` setting by @tatiana in #1025 -* Make ``GoogleCloudServiceAccountDictProfileMapping`` dataset profile arg optional by @oliverrmaa and @pankajastro in #839 and #1017 -* Athena profile mapping set ``aws_session_token`` in profile only if it exists by @pankajastro in #1022 - -Others - -* Update dbt and Airflow conflicts matrix by @tatiana in #1026 -* Enable Python 3.12 unittest by @pankajastro in #1018 -* Improve error logging in ``DbtLocalBaseOperator`` by @davidsteinar in #1004 -* Add GitHub issue templates for bug reports and feature request by @pankajkoti in #1009 -* Add more fields in bug template to reduce turnaround in issue triaging by @pankajkoti in #1027 -* Fix ``dev/Dockerfile`` + Add ``uv pip install`` for faster build time by @dwreeves in #997 -* Drop support for Airflow 2.3 by @pankajkoti in #994 -* Update Astro Runtime image by @RNHTTR in #988 and #989 -* Enable ruff F linting by @pankajastro in #985 -* Move Cosmos Airflow configuration to settings.py by @pankajastro in #975 -* Fix CI Issues by @tatiana in #1005 -* Pre-commit hook updates in #1000, #1019 - - -1.4.1 (2024-05-17) ------------------- - -Bug fixes - -* Bring back ``dataset`` as a required field for BigQuery profile by @pankajkoti in #1033 - -Enhancements - -* Only run ``dbt deps`` when there are dependencies by @tatiana in #1030 - -Docs - -* Fix docs so it does not reference non-existing ``get_dbt_dataset`` by @tatiana in #1034 - - 1.4.2 (2024-06-06) ------------------ From a8be286ea1fe53d96c5d9e6f6f19d89215df59cb Mon Sep 17 00:00:00 2001 From: Pankaj Singh <98807258+pankajastro@users.noreply.github.com> Date: Thu, 27 Jun 2024 04:50:34 +0530 Subject: [PATCH 213/223] [Docs]: Fix rendering for env enable_cache_dbt_ls (#1069) Look like rendering for conf `enable_cache_dbt_ls` is broken in docs **Before change** Screenshot 2024-06-27 at 1 36 27 AM **After change** Screenshot 2024-06-27 at 1 37 09 AM --- docs/configuration/cosmos-conf.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration/cosmos-conf.rst b/docs/configuration/cosmos-conf.rst index 9c1b56c891..0c13cbb260 100644 --- a/docs/configuration/cosmos-conf.rst +++ b/docs/configuration/cosmos-conf.rst @@ -30,7 +30,7 @@ This page lists all available Airflow configurations that affect ``astronomer-co - Default: ``True`` - Environment Variable: ``AIRFLOW__COSMOS__ENABLE_CACHE`` -.. enable_cache_dbt_ls: +.. _enable_cache_dbt_ls: `enable_cache_dbt_ls`_: Enable or disable caching of the dbt ls command in case using ``LoadMode.DBT_LS`` in an Airflow Variable. From f7a357b2a0988016b9102748dd263bb5ab61cbef Mon Sep 17 00:00:00 2001 From: Pankaj Singh <98807258+pankajastro@users.noreply.github.com> Date: Thu, 27 Jun 2024 19:09:04 +0530 Subject: [PATCH 214/223] Support to cache profiles created via `ProfileMapping` (#1046) Add dbt profile caching mechanism. 1. Introduced env `enable_cache_profile` to enable or disable profile caching. This will be enabled only if global `enable_cache` is enabled. 2. Users can set the env `profile_cache_dir_name`. This will be the name of a sub-dir inside `cache_dir` where cached profiles will be stored. This is optional, and the default name is `profile` 3. Example Path for versioned profile: `{cache_dir}/{profile_cache_dir}/592906f650558ce1dadb75fcce84a2ec09e444441e6af6069f19204d59fe428b/profiles.yml` 4. Implemented profile mapping hashing: first, the profile is serialized using pickle. Then, the profile_name and target_name are appended before hashing the data using the SHA-256 algorithm **Perf test result:** In local dev env with command ``` AIRFLOW_HOME=`pwd` AIRFLOW_CONN_EXAMPLE_CONN="postgres://postgres:postgres@0.0.0.0:5432/postgres" AIRFLOW_HOME=`pwd` AIRFLOW__CORE__DAGBAG_IMPORT_TIMEOUT=20000 AIRFLOW__CORE__DAG_FILE_PROCESSOR_TIMEOUT=20000 hatch run tests.py3.10-2.8:test-performance ``` NUM_MODELS=100 - TIME=167.45248413085938 (with profile cache enabled) - TIME=173.94845390319824 (with profile cache disabled) NUM_MODELS=200 - TIME=376.2585120201111 (with profile cache enabled) - TIME=418.14210200309753 (with profile cache disabled) Closes: #925 Closes: #647 --- cosmos/cache.py | 52 +++++++++++++- cosmos/config.py | 70 +++++++++++++------ cosmos/constants.py | 1 + cosmos/profiles/base.py | 19 +++++ cosmos/settings.py | 2 + docs/configuration/caching.rst | 14 ++++ docs/configuration/cosmos-conf.rst | 17 +++++ tests/dbt/test_graph.py | 15 +++- tests/profiles/test_base_profile.py | 6 ++ tests/test_cache.py | 104 +++++++++++++++++++++++++++- 10 files changed, 273 insertions(+), 27 deletions(-) diff --git a/cosmos/cache.py b/cosmos/cache.py index fd1dd53f4f..a3b29b0e59 100644 --- a/cosmos/cache.py +++ b/cosmos/cache.py @@ -19,9 +19,10 @@ from sqlalchemy.orm import Session from cosmos import settings -from cosmos.constants import DBT_MANIFEST_FILE_NAME, DBT_TARGET_DIR_NAME +from cosmos.constants import DBT_MANIFEST_FILE_NAME, DBT_TARGET_DIR_NAME, DEFAULT_PROFILES_FILE_NAME from cosmos.dbt.project import get_partial_parse_path from cosmos.log import get_logger +from cosmos.settings import cache_dir, dbt_profile_cache_dir_name, enable_cache, enable_cache_profile logger = get_logger(__name__) VAR_KEY_CACHE_PREFIX = "cosmos_cache__" @@ -346,3 +347,52 @@ def delete_unused_dbt_ls_cache( f"Deleted {deleted_cosmos_variables}/{total_cosmos_variables} Airflow Variables used to store Cosmos cache. " ) return deleted_cosmos_variables + + +def is_profile_cache_enabled() -> bool: + """Return True if global and profile cache is enable""" + return enable_cache and enable_cache_profile + + +def _get_or_create_profile_cache_dir() -> Path: + """ + Get or create the directory path for caching DBT profiles. + + - Constructs the profile cache directory path based on cache_dir and dbt_profile_cache_dir. + - Checks if the directory exists; if not, creates it + - Return profile cache directory + """ + profile_cache_dir = cache_dir / dbt_profile_cache_dir_name + if not profile_cache_dir.exists(): + profile_cache_dir.mkdir(parents=True, exist_ok=True) + return profile_cache_dir + + +def get_cached_profile(version: str) -> Path | None: + """ + Retrieve the path to a cached DBT profile YML file if it exists for the given version. + + - Constructs the DBT profile YML Path based on version and profile cache directory + - Checks if the profile YML exists + - Return the profile YML Path + """ + profile_yml_path = _get_or_create_profile_cache_dir() / version / DEFAULT_PROFILES_FILE_NAME + if profile_yml_path.exists() and profile_yml_path.is_file(): + return profile_yml_path + return None + + +def create_cache_profile(version: str, profile_content: str) -> Path: + """ + Create a cached DBT profile YAML file with the provided content for the given version. + + - Constructs the path for profile YML based on the version in the profile cache directory + - Creates the profile directory if it does not exist + - Writes the profile content to the profile YML file + - Return the profile YML Path + """ + profile_yml_dir = _get_or_create_profile_cache_dir() / version + profile_yml_dir.mkdir(parents=True, exist_ok=True) + profile_yml_path = profile_yml_dir / DEFAULT_PROFILES_FILE_NAME + profile_yml_path.write_text(profile_content) + return profile_yml_path diff --git a/cosmos/config.py b/cosmos/config.py index 5ca21709d2..948d009f7b 100644 --- a/cosmos/config.py +++ b/cosmos/config.py @@ -10,7 +10,9 @@ from pathlib import Path from typing import Any, Callable, Iterator +from cosmos.cache import create_cache_profile, get_cached_profile, is_profile_cache_enabled from cosmos.constants import ( + DEFAULT_PROFILES_FILE_NAME, DbtResourceType, ExecutionMode, InvocationMode, @@ -25,8 +27,6 @@ logger = get_logger(__name__) -DEFAULT_PROFILES_FILE_NAME = "profiles.yml" - class CosmosConfigException(Exception): """ @@ -258,6 +258,27 @@ def validate_profiles_yml(self) -> None: if self.profiles_yml_filepath and not Path(self.profiles_yml_filepath).exists(): raise CosmosValueError(f"The file {self.profiles_yml_filepath} does not exist.") + def _get_profile_path(self, use_mock_values: bool = False) -> Path: + """ + Handle the profile caching mechanism. + + Check if profile object version is exist then reuse it + Otherwise, create profile yml for requested object and return the profile path + """ + assert self.profile_mapping # To satisfy MyPy + current_profile_version = self.profile_mapping.version(self.profile_name, self.target_name, use_mock_values) + cached_profile_path = get_cached_profile(current_profile_version) + if cached_profile_path: + logger.info("Profile found in cache using profile: %s.", cached_profile_path) + return cached_profile_path + else: + profile_contents = self.profile_mapping.get_profile_file_contents( + profile_name=self.profile_name, target_name=self.target_name, use_mock_values=use_mock_values + ) + profile_path = create_cache_profile(current_profile_version, profile_contents) + logger.info("Profile not found in cache storing and using profile: %s.", profile_path) + return profile_path + @contextlib.contextmanager def ensure_profile( self, desired_profile_path: Path | None = None, use_mock_values: bool = False @@ -268,35 +289,40 @@ def ensure_profile( yield Path(self.profiles_yml_filepath), {} elif self.profile_mapping: - profile_contents = self.profile_mapping.get_profile_file_contents( - profile_name=self.profile_name, target_name=self.target_name, use_mock_values=use_mock_values - ) - if use_mock_values: env_vars = {} else: env_vars = self.profile_mapping.env_vars - if desired_profile_path: - logger.info( - "Writing profile to %s with the following contents:\n%s", - desired_profile_path, - profile_contents, - ) - # write profile_contents to desired_profile_path using yaml library - desired_profile_path.write_text(profile_contents) - yield desired_profile_path, env_vars + if is_profile_cache_enabled(): + logger.info("Profile caching is enable.") + cached_profile_path = self._get_profile_path(use_mock_values) + yield cached_profile_path, env_vars else: - with tempfile.TemporaryDirectory() as temp_dir: - temp_file = Path(temp_dir) / DEFAULT_PROFILES_FILE_NAME + profile_contents = self.profile_mapping.get_profile_file_contents( + profile_name=self.profile_name, target_name=self.target_name, use_mock_values=use_mock_values + ) + + if desired_profile_path: logger.info( - "Creating temporary profiles.yml with use_mock_values=%s at %s with the following contents:\n%s", - use_mock_values, - temp_file, + "Writing profile to %s with the following contents:\n%s", + desired_profile_path, profile_contents, ) - temp_file.write_text(profile_contents) - yield temp_file, env_vars + # write profile_contents to desired_profile_path using yaml library + desired_profile_path.write_text(profile_contents) + yield desired_profile_path, env_vars + else: + with tempfile.TemporaryDirectory() as temp_dir: + temp_file = Path(temp_dir) / DEFAULT_PROFILES_FILE_NAME + logger.info( + "Creating temporary profiles.yml with use_mock_values=%s at %s with the following contents:\n%s", + use_mock_values, + temp_file, + profile_contents, + ) + temp_file.write_text(profile_contents) + yield temp_file, env_vars @dataclass diff --git a/cosmos/constants.py b/cosmos/constants.py index 2a1abb20ed..956660e016 100644 --- a/cosmos/constants.py +++ b/cosmos/constants.py @@ -18,6 +18,7 @@ DBT_DEPENDENCIES_FILE_NAMES = {"packages.yml", "dependencies.yml"} DBT_LOG_FILENAME = "dbt.log" DBT_BINARY_NAME = "dbt" +DEFAULT_PROFILES_FILE_NAME = "profiles.yml" DEFAULT_OPENLINEAGE_NAMESPACE = "cosmos" OPENLINEAGE_PRODUCER = "https://github.com/astronomer/astronomer-cosmos/" diff --git a/cosmos/profiles/base.py b/cosmos/profiles/base.py index 7c7b277b14..a81512dbb0 100755 --- a/cosmos/profiles/base.py +++ b/cosmos/profiles/base.py @@ -5,6 +5,8 @@ from __future__ import annotations +import hashlib +import json import warnings from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any, Dict, Literal, Optional @@ -82,6 +84,23 @@ def __init__( self.dbt_config_vars = dbt_config_vars self._validate_disable_event_tracking() + def version(self, profile_name: str, target_name: str, mock_profile: bool = False) -> str: + """ + Generate SHA-256 hash digest based on the provided profile, profile and target names. + + :param profile_name: Name of the DBT profile. + :param target_name: Name of the DBT target + :param mock_profile: If True, use a mock profile. + """ + if mock_profile: + profile = self.mock_profile + else: + profile = self.profile + profile["profile_name"] = profile_name + profile["target_name"] = target_name + hash_object = hashlib.sha256(json.dumps(profile, sort_keys=True).encode()) + return hash_object.hexdigest() + def _validate_profile_args(self) -> None: """ Check if profile_args contains keys that should not be overridden from the diff --git a/cosmos/settings.py b/cosmos/settings.py index 68ed8758ff..62d4ee5bdf 100644 --- a/cosmos/settings.py +++ b/cosmos/settings.py @@ -17,6 +17,8 @@ dbt_docs_dir = conf.get("cosmos", "dbt_docs_dir", fallback=None) dbt_docs_conn_id = conf.get("cosmos", "dbt_docs_conn_id", fallback=None) dbt_docs_index_file_name = conf.get("cosmos", "dbt_docs_index_file_name", fallback="index.html") +enable_cache_profile = conf.getboolean("cosmos", "enable_cache_profile", fallback=True) +dbt_profile_cache_dir_name = conf.get("cosmos", "profile_cache_dir_name", fallback="profile") try: LINEAGE_NAMESPACE = conf.get("openlineage", "namespace") diff --git a/docs/configuration/caching.rst b/docs/configuration/caching.rst index b5ec155da2..7dba9933f8 100644 --- a/docs/configuration/caching.rst +++ b/docs/configuration/caching.rst @@ -116,3 +116,17 @@ Users can customize where to store the cache using the setting ``AIRFLOW__COSMOS It is possible to switch off this feature by exporting the environment variable ``AIRFLOW__COSMOS__ENABLE_CACHE_PARTIAL_PARSE=0``. For more information, read the `Cosmos partial parsing documentation <./partial-parsing.html>`_ + + +Caching the profiles +~~~~~~~~~~~~~~~~~~~~~~~~ + +(Introduced in Cosmos 1.5) + +Cosmos 1.5 introduced `support to profile caching `_, +enabling caching for the profile mapping in the path specified by env ``AIRFLOW__COSMOS__CACHE_DIR`` and ``AIRFLOW__COSMOS__PROFILE_CACHE_DIR_NAME``. +This feature facilitates the reuse of Airflow connections and ``profiles.yml``. + +Users have the flexibility to customize the cache storage location using the settings ``AIRFLOW__COSMOS__CACHE_DIR`` and ``AIRFLOW__COSMOS__PROFILE_CACHE_DIR_NAME``. + +To disable this feature, users can set the environment variable ``AIRFLOW__COSMOS__ENABLE_CACHE_PROFILE=False`` diff --git a/docs/configuration/cosmos-conf.rst b/docs/configuration/cosmos-conf.rst index 0c13cbb260..8dc90a5c18 100644 --- a/docs/configuration/cosmos-conf.rst +++ b/docs/configuration/cosmos-conf.rst @@ -70,6 +70,23 @@ This page lists all available Airflow configurations that affect ``astronomer-co - Default: ``None`` - Environment Variable: ``AIRFLOW__COSMOS__DBT_DOCS_CONN_ID`` +.. _enable_cache_profile: + +`enable_cache_profile`_: + Enable caching for the DBT profile. + + - Default: ``True`` + - Environment Variable: ``AIRFLOW__COSMOS__ENABLE_CACHE_PROFILE`` + +.. _profile_cache_dir_name: + +`profile_cache_dir_name`_: + Folder name to store the DBT cached profiles. This will be a sub-folder of ``cache_dir`` + + - Default: ``profile`` + - Environment Variable: ``AIRFLOW__COSMOS__PROFILE_CACHE_DIR_NAME`` + + [openlineage] ~~~~~~~~~~~~~ diff --git a/tests/dbt/test_graph.py b/tests/dbt/test_graph.py index 9e931ba8c9..064d34a132 100644 --- a/tests/dbt/test_graph.py +++ b/tests/dbt/test_graph.py @@ -405,10 +405,13 @@ def test_load( @pytest.mark.integration +@pytest.mark.parametrize("enable_cache_profile", [True, False]) +@patch("cosmos.config.is_profile_cache_enabled") @patch("cosmos.dbt.graph.Popen") def test_load_via_dbt_ls_does_not_create_target_logs_in_original_folder( - mock_popen, tmp_dbt_project_dir, postgres_profile_config + mock_popen, is_profile_cache_enabled, enable_cache_profile, tmp_dbt_project_dir, postgres_profile_config ): + is_profile_cache_enabled.return_value = enable_cache_profile mock_popen().communicate.return_value = ("", "") mock_popen().returncode = 0 assert not (tmp_dbt_project_dir / "target").exists() @@ -427,7 +430,7 @@ def test_load_via_dbt_ls_does_not_create_target_logs_in_original_folder( assert not (tmp_dbt_project_dir / "target").exists() assert not (tmp_dbt_project_dir / "logs").exists() - used_cwd = Path(mock_popen.call_args[0][0][-5]) + used_cwd = Path(mock_popen.call_args[0][0][5]) assert used_cwd != project_config.dbt_project_path assert not used_cwd.exists() @@ -638,7 +641,11 @@ def test_load_via_dbt_ls_without_dbt_deps_and_preinstalled_dbt_packages( @pytest.mark.integration -def test_load_via_dbt_ls_caching_partial_parsing(tmp_dbt_project_dir, postgres_profile_config, caplog, tmp_path): +@pytest.mark.parametrize("enable_cache_profile", [True, False]) +@patch("cosmos.config.is_profile_cache_enabled") +def test_load_via_dbt_ls_caching_partial_parsing( + is_profile_cache_enabled, enable_cache_profile, tmp_dbt_project_dir, postgres_profile_config, caplog, tmp_path +): """ When using RenderConfig.enable_mock_profile=False and defining DbtGraph.cache_dir, Cosmos should leverage dbt partial parsing. @@ -647,6 +654,8 @@ def test_load_via_dbt_ls_caching_partial_parsing(tmp_dbt_project_dir, postgres_p caplog.set_level(logging.DEBUG) + is_profile_cache_enabled.return_value = enable_cache_profile + project_config = ProjectConfig(dbt_project_path=tmp_dbt_project_dir / DBT_PROJECT_NAME) render_config = RenderConfig( dbt_project_path=tmp_dbt_project_dir / DBT_PROJECT_NAME, dbt_deps=True, enable_mock_profile=False diff --git a/tests/profiles/test_base_profile.py b/tests/profiles/test_base_profile.py index 7fdbdb886a..8eeb83537d 100644 --- a/tests/profiles/test_base_profile.py +++ b/tests/profiles/test_base_profile.py @@ -162,3 +162,9 @@ def test_profile_config_validate_dbt_config_vars_check_values(dbt_config_var: st conn_id="fake_conn_id", dbt_config_vars=DbtProfileConfigVars(**dbt_config_vars), ) + + +def test_profile_version_sha_consistency(): + profile_mapping = TestProfileMapping(conn_id="fake_conn_id") + version = profile_mapping.version(profile_name="dev", target_name="dev") + assert version == "ea3bf1f70b033405ba9ff9cafe65af873fd7a868cac840cdbfd5e8e9a1da9650" diff --git a/tests/test_cache.py b/tests/test_cache.py index 9cd216998f..9edf10f903 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -16,10 +16,15 @@ _copy_partial_parse_to_project, _create_cache_identifier, _get_latest_partial_parse, + _get_or_create_profile_cache_dir, _update_partial_parse_cache, + create_cache_profile, delete_unused_dbt_ls_cache, + get_cached_profile, + is_profile_cache_enabled, ) -from cosmos.constants import DBT_PARTIAL_PARSE_FILE_NAME, DBT_TARGET_DIR_NAME +from cosmos.constants import DBT_PARTIAL_PARSE_FILE_NAME, DBT_TARGET_DIR_NAME, DEFAULT_PROFILES_FILE_NAME +from cosmos.settings import dbt_profile_cache_dir_name START_DATE = datetime(2024, 4, 16) example_dag = DAG("dag", start_date=START_DATE) @@ -182,3 +187,100 @@ def test_delete_unused_dbt_ls_cache_deletes_all_cache_five_minutes_ago(vars_sess assert not vars_session.query(Variable).filter_by(key="cosmos_cache__dag_a").first() assert not vars_session.query(Variable).filter_by(key="cosmos_cache__dag_b").first() assert not vars_session.query(Variable).filter_by(key="cosmos_cache__dag_c__task_group_1").first() + + +@pytest.mark.parametrize( + "enable_cache, enable_cache_profile, expected_result", + [(True, True, True), (True, False, False), (False, True, False), (False, False, False)], +) +def test_is_profile_cache_enabled(enable_cache, enable_cache_profile, expected_result): + with patch("cosmos.cache.enable_cache", enable_cache), patch( + "cosmos.cache.enable_cache_profile", enable_cache_profile + ): + assert is_profile_cache_enabled() == expected_result + + +def test_get_or_create_profile_cache_dir(): + # Create a temporary directory for cache_dir + with tempfile.TemporaryDirectory() as temp_dir: + temp_dir_path = Path(temp_dir) + + # Test case 1: Directory does not exist, should create it + with patch("cosmos.cache.cache_dir", temp_dir_path): + profile_cache_dir = _get_or_create_profile_cache_dir() + expected_dir = temp_dir_path / dbt_profile_cache_dir_name + assert profile_cache_dir == expected_dir + assert expected_dir.exists() + + # Test case 2: Directory already exists, should return existing path + with patch("cosmos.cache.cache_dir", temp_dir_path): + profile_cache_dir_again = _get_or_create_profile_cache_dir() + expected_dir = temp_dir_path / dbt_profile_cache_dir_name + assert profile_cache_dir_again == expected_dir + assert expected_dir.exists() + + +def test_get_cached_profile_not_exists(): + with tempfile.TemporaryDirectory() as temp_dir: + temp_dir = Path(temp_dir) + # Mock cache_dir to use the temporary directory + with patch("cosmos.cache.cache_dir", temp_dir): + # Create a dummy profile YAML file for version 'v1' + version = "592906f650558ce1dadb75fcce84a2ec09e444441e6af6069f19204d59fe428b" + result = get_cached_profile(version) + assert result is None + + +def test_get_cached_profile(): + profile_content = """ + default: + target: dev + outputs: + dev: + type: postgres + host: localhost + user: myuser + pass: mypassword + dbname: mydatabase + """ + with tempfile.TemporaryDirectory() as temp_dir: + temp_dir = Path(temp_dir) + with patch("cosmos.cache.cache_dir", temp_dir): + # Setup DBT profile + version = "592906f650558ce1dadb75fcce84a2ec09e444441e6af6069f19204d59fe428b" + create_cache_profile(version, profile_content) + + expected_yml_path = temp_dir / dbt_profile_cache_dir_name / version / DEFAULT_PROFILES_FILE_NAME + result = get_cached_profile(version) + assert result == expected_yml_path + + +def test_create_cache_profile(): + version = "592906f650558ce1dadb75fcce84a2ec09e444441e6af6069f19204d59fe428b" + profile_content = """ + default: + target: dev + outputs: + dev: + type: postgres + host: localhost + user: myuser + pass: mypassword + dbname: mydatabase + """ + + with tempfile.TemporaryDirectory() as temp_dir: + temp_dir = Path(temp_dir) + with patch("cosmos.cache.cache_dir", temp_dir): + profile_yml_path = create_cache_profile(version, profile_content) + + expected_dir = temp_dir / dbt_profile_cache_dir_name / version + expected_path = expected_dir / DEFAULT_PROFILES_FILE_NAME + + # Check if the directory and file were created + assert expected_dir.exists() + assert expected_path.exists() + + # Check content of the created file + assert expected_path.read_text() == profile_content + assert profile_yml_path == expected_path From bc2a5fec4695832708c93a9e106b35855a2dc6f1 Mon Sep 17 00:00:00 2001 From: Tatiana Al-Chueyr Date: Thu, 27 Jun 2024 15:00:03 +0100 Subject: [PATCH 215/223] Support partial parsing when cache is disabled (#1070) Partial parsing support was introduced in #800 and improved in #904 (caching). However, as the caching layer was introduced, we removed support to use partial parsing if the cache was disabled. This PR solves the issue. Fix: #1041 --- cosmos/cache.py | 1 + cosmos/dbt/graph.py | 25 +- dev/dags/dbt/jaffle_shop/target/manifest.json | 8213 +++++++++++------ tests/dbt/test_graph.py | 52 +- tests/test_cache.py | 2 +- 5 files changed, 5432 insertions(+), 2861 deletions(-) diff --git a/cosmos/cache.py b/cosmos/cache.py index a3b29b0e59..d519eaca47 100644 --- a/cosmos/cache.py +++ b/cosmos/cache.py @@ -139,6 +139,7 @@ def _update_partial_parse_cache(latest_partial_parse_filepath: Path, cache_dir: :param cache_dir: Path to the Cosmos project cache directory """ cache_path = get_partial_parse_path(cache_dir) + cache_path.parent.mkdir(parents=True, exist_ok=True) manifest_path = get_partial_parse_path(cache_dir).parent / DBT_MANIFEST_FILE_NAME latest_manifest_filepath = latest_partial_parse_filepath.parent / DBT_MANIFEST_FILE_NAME diff --git a/cosmos/dbt/graph.py b/cosmos/dbt/graph.py index 348ded07f8..a072014dd5 100644 --- a/cosmos/dbt/graph.py +++ b/cosmos/dbt/graph.py @@ -439,6 +439,10 @@ def load_via_dbt_ls_cache(self) -> bool: logger.info(f"Cosmos performance: Cache miss for {self.dbt_ls_cache_key} - skipped") return False + def should_use_partial_parse_cache(self) -> bool: + """Identify if Cosmos should use/store dbt partial parse cache or not.""" + return settings.enable_cache_partial_parse and settings.enable_cache and bool(self.cache_dir) + def load_via_dbt_ls_without_cache(self) -> None: """ This is the most accurate way of loading `dbt` projects and filtering them out, since it uses the `dbt` command @@ -459,18 +463,21 @@ def load_via_dbt_ls_without_cache(self) -> None: raise CosmosLoadDbtException("Unable to load project via dbt ls without a profile config.") with tempfile.TemporaryDirectory() as tmpdir: - logger.debug( - f"Content of the dbt project dir {self.render_config.project_path}: `{os.listdir(self.render_config.project_path)}`" - ) + logger.debug(f"Content of the dbt project dir {project_path}: `{os.listdir(project_path)}`") tmpdir_path = Path(tmpdir) create_symlinks(project_path, tmpdir_path, self.render_config.dbt_deps) - if self.project.partial_parse and self.cache_dir: - latest_partial_parse = cache._get_latest_partial_parse(project_path, self.cache_dir) + latest_partial_parse = None + if self.project.partial_parse: + if self.should_use_partial_parse_cache() and self.cache_dir: + latest_partial_parse = cache._get_latest_partial_parse(project_path, self.cache_dir) + else: + latest_partial_parse = get_partial_parse_path(project_path) + + if latest_partial_parse is not None and latest_partial_parse.exists(): logger.info("Partial parse is enabled and the latest partial parse file is %s", latest_partial_parse) - if latest_partial_parse is not None: - cache._copy_partial_parse_to_project(latest_partial_parse, tmpdir_path) + cache._copy_partial_parse_to_project(latest_partial_parse, tmpdir_path) with self.profile_config.ensure_profile( use_mock_values=self.render_config.enable_mock_profile @@ -505,9 +512,9 @@ def load_via_dbt_ls_without_cache(self) -> None: self.nodes = nodes self.filtered_nodes = nodes - if self.project.partial_parse and self.cache_dir: + if self.should_use_partial_parse_cache(): partial_parse_file = get_partial_parse_path(tmpdir_path) - if partial_parse_file.exists(): + if partial_parse_file.exists() and self.cache_dir: cache._update_partial_parse_cache(partial_parse_file, self.cache_dir) def load_via_dbt_ls_file(self) -> None: diff --git a/dev/dags/dbt/jaffle_shop/target/manifest.json b/dev/dags/dbt/jaffle_shop/target/manifest.json index 9446d80c3a..c8ba975540 100644 --- a/dev/dags/dbt/jaffle_shop/target/manifest.json +++ b/dev/dags/dbt/jaffle_shop/target/manifest.json @@ -68,39 +68,41 @@ }, "disabled": {}, "docs": { - "dbt.__overview__": { + "doc.dbt.__overview__": { "block_contents": "### Welcome!\n\nWelcome to the auto-generated documentation for your dbt project!\n\n### Navigation\n\nYou can use the `Project` and `Database` navigation tabs on the left side of the window to explore the models\nin your project.\n\n#### Project Tab\nThe `Project` tab mirrors the directory structure of your dbt project. In this tab, you can see all of the\nmodels defined in your dbt project, as well as models imported from dbt packages.\n\n#### Database Tab\nThe `Database` tab also exposes your models, but in a format that looks more like a database explorer. This view\nshows relations (tables and views) grouped into database schemas. Note that ephemeral models are _not_ shown\nin this interface, as they do not exist in the database.\n\n### Graph Exploration\nYou can click the blue icon on the bottom-right corner of the page to view the lineage graph of your models.\n\nOn model pages, you'll see the immediate parents and children of the model you're exploring. By clicking the `Expand`\nbutton at the top-right of this lineage pane, you'll be able to see all of the models that are used to build,\nor are built from, the model you're exploring.\n\nOnce expanded, you'll be able to use the `--select` and `--exclude` model selection syntax to filter the\nmodels in the graph. For more information on model selection, check out the [dbt docs](https://docs.getdbt.com/docs/model-selection-syntax).\n\nNote that you can also right-click on models to interactively filter and explore the graph.\n\n---\n\n### More information\n\n- [What is dbt](https://docs.getdbt.com/docs/introduction)?\n- Read the [dbt viewpoint](https://docs.getdbt.com/docs/viewpoint)\n- [Installation](https://docs.getdbt.com/docs/installation)\n- Join the [dbt Community](https://www.getdbt.com/community/) for questions and discussion", "name": "__overview__", "original_file_path": "docs/overview.md", "package_name": "dbt", "path": "overview.md", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", - "unique_id": "dbt.__overview__" + "resource_type": "doc", + "unique_id": "doc.dbt.__overview__" }, - "jaffle_shop.__overview__": { + "doc.jaffle_shop.__overview__": { "block_contents": "## Data Documentation for Jaffle Shop\n\n`jaffle_shop` is a fictional ecommerce store.\n\nThis [dbt](https://www.getdbt.com/) project is for testing out code.\n\nThe source code can be found [here](https://github.com/clrcrl/jaffle_shop).", "name": "__overview__", "original_file_path": "models/overview.md", "package_name": "jaffle_shop", "path": "overview.md", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop", - "unique_id": "jaffle_shop.__overview__" + "resource_type": "doc", + "unique_id": "doc.jaffle_shop.__overview__" }, - "jaffle_shop.orders_status": { + "doc.jaffle_shop.orders_status": { "block_contents": "Orders can be one of the following statuses:\n\n| status | description |\n|----------------|------------------------------------------------------------------------------------------------------------------------|\n| placed | The order has been placed but has not yet left the warehouse |\n| shipped | The order has ben shipped to the customer and is currently in transit |\n| completed | The order has been received by the customer |\n| return_pending | The customer has indicated that they would like to return the order, but it has not yet been received at the warehouse |\n| returned | The order has been returned by the customer and received at the warehouse |", "name": "orders_status", "original_file_path": "models/docs.md", "package_name": "jaffle_shop", "path": "docs.md", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop", - "unique_id": "jaffle_shop.orders_status" + "resource_type": "doc", + "unique_id": "doc.jaffle_shop.orders_status" } }, "exposures": {}, + "group_map": {}, + "groups": {}, "macros": { "macro.dbt._split_part_negative": { "arguments": [], - "created_at": 1696458269.796739, + "created_at": 1719485736.555134, "depends_on": { "macros": [] }, @@ -109,7 +111,7 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro _split_part_negative(string_text, delimiter_text, part_number) %}\n\n split_part(\n {{ string_text }},\n {{ delimiter_text }},\n length({{ string_text }})\n - length(\n replace({{ string_text }}, {{ delimiter_text }}, '')\n ) + 2 {{ part_number }}\n )\n\n{% endmacro %}", + "macro_sql": "{% macro _split_part_negative(string_text, delimiter_text, part_number) %}\n\n split_part(\n {{ string_text }},\n {{ delimiter_text }},\n length({{ string_text }})\n - length(\n replace({{ string_text }}, {{ delimiter_text }}, '')\n ) + 2 + {{ part_number }}\n )\n\n{% endmacro %}", "meta": {}, "name": "_split_part_negative", "original_file_path": "macros/utils/split_part.sql", @@ -117,14 +119,12 @@ "patch_path": null, "path": "macros/utils/split_part.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt._split_part_negative" }, "macro.dbt.after_commit": { "arguments": [], - "created_at": 1696458269.591864, + "created_at": 1719485736.337471, "depends_on": { "macros": [ "macro.dbt.make_hook_config" @@ -143,14 +143,12 @@ "patch_path": null, "path": "macros/materializations/hooks.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.after_commit" }, "macro.dbt.alter_column_comment": { "arguments": [], - "created_at": 1696458269.832114, + "created_at": 1719485736.583679, "depends_on": { "macros": [ "macro.dbt_postgres.postgres__alter_column_comment" @@ -169,14 +167,12 @@ "patch_path": null, "path": "macros/adapters/persist_docs.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.alter_column_comment" }, "macro.dbt.alter_column_type": { "arguments": [], - "created_at": 1696458269.845595, + "created_at": 1719485736.599694, "depends_on": { "macros": [ "macro.dbt.default__alter_column_type" @@ -195,14 +191,12 @@ "patch_path": null, "path": "macros/adapters/columns.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.alter_column_type" }, "macro.dbt.alter_relation_add_remove_columns": { "arguments": [], - "created_at": 1696458269.847001, + "created_at": 1719485736.600589, "depends_on": { "macros": [ "macro.dbt.default__alter_relation_add_remove_columns" @@ -221,14 +215,12 @@ "patch_path": null, "path": "macros/adapters/columns.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.alter_relation_add_remove_columns" }, "macro.dbt.alter_relation_comment": { "arguments": [], - "created_at": 1696458269.832729, + "created_at": 1719485736.585442, "depends_on": { "macros": [ "macro.dbt_postgres.postgres__alter_relation_comment" @@ -247,14 +239,12 @@ "patch_path": null, "path": "macros/adapters/persist_docs.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.alter_relation_comment" }, "macro.dbt.any_value": { "arguments": [], - "created_at": 1696458269.7846951, + "created_at": 1719485736.542892, "depends_on": { "macros": [ "macro.dbt_postgres.postgres__any_value" @@ -273,14 +263,12 @@ "patch_path": null, "path": "macros/utils/any_value.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.any_value" }, "macro.dbt.apply_grants": { "arguments": [], - "created_at": 1696458269.828692, + "created_at": 1719485736.577766, "depends_on": { "macros": [ "macro.dbt.default__apply_grants" @@ -299,14 +287,12 @@ "patch_path": null, "path": "macros/adapters/apply_grants.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.apply_grants" }, "macro.dbt.array_append": { "arguments": [], - "created_at": 1696458269.7995489, + "created_at": 1719485736.5569642, "depends_on": { "macros": [ "macro.dbt.default__array_append" @@ -325,14 +311,12 @@ "patch_path": null, "path": "macros/utils/array_append.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.array_append" }, "macro.dbt.array_concat": { "arguments": [], - "created_at": 1696458269.792314, + "created_at": 1719485736.5521111, "depends_on": { "macros": [ "macro.dbt.default__array_concat" @@ -351,14 +335,12 @@ "patch_path": null, "path": "macros/utils/array_concat.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.array_concat" }, "macro.dbt.array_construct": { "arguments": [], - "created_at": 1696458269.798442, + "created_at": 1719485736.556025, "depends_on": { "macros": [ "macro.dbt.default__array_construct" @@ -377,14 +359,38 @@ "patch_path": null, "path": "macros/utils/array_construct.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.array_construct" }, + "macro.dbt.assert_columns_equivalent": { + "arguments": [], + "created_at": 1719485736.5031989, + "depends_on": { + "macros": [ + "macro.dbt.get_column_schema_from_query", + "macro.dbt.get_empty_schema_sql", + "macro.dbt.format_columns" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro assert_columns_equivalent(sql) %}\n\n {#-- First ensure the user has defined 'columns' in yaml specification --#}\n {%- set user_defined_columns = model['columns'] -%}\n {%- if not user_defined_columns -%}\n {{ exceptions.raise_contract_error([], []) }}\n {%- endif -%}\n\n {#-- Obtain the column schema provided by sql file. #}\n {%- set sql_file_provided_columns = get_column_schema_from_query(sql, config.get('sql_header', none)) -%}\n {#--Obtain the column schema provided by the schema file by generating an 'empty schema' query from the model's columns. #}\n {%- set schema_file_provided_columns = get_column_schema_from_query(get_empty_schema_sql(user_defined_columns)) -%}\n\n {#-- create dictionaries with name and formatted data type and strings for exception #}\n {%- set sql_columns = format_columns(sql_file_provided_columns) -%}\n {%- set yaml_columns = format_columns(schema_file_provided_columns) -%}\n\n {%- if sql_columns|length != yaml_columns|length -%}\n {%- do exceptions.raise_contract_error(yaml_columns, sql_columns) -%}\n {%- endif -%}\n\n {%- for sql_col in sql_columns -%}\n {%- set yaml_col = [] -%}\n {%- for this_col in yaml_columns -%}\n {%- if this_col['name'] == sql_col['name'] -%}\n {%- do yaml_col.append(this_col) -%}\n {%- break -%}\n {%- endif -%}\n {%- endfor -%}\n {%- if not yaml_col -%}\n {#-- Column with name not found in yaml #}\n {%- do exceptions.raise_contract_error(yaml_columns, sql_columns) -%}\n {%- endif -%}\n {%- if sql_col['formatted'] != yaml_col[0]['formatted'] -%}\n {#-- Column data types don't match #}\n {%- do exceptions.raise_contract_error(yaml_columns, sql_columns) -%}\n {%- endif -%}\n {%- endfor -%}\n\n{% endmacro %}", + "meta": {}, + "name": "assert_columns_equivalent", + "original_file_path": "macros/relations/column/columns_spec_ddl.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/relations/column/columns_spec_ddl.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.assert_columns_equivalent" + }, "macro.dbt.before_begin": { "arguments": [], - "created_at": 1696458269.591393, + "created_at": 1719485736.336732, "depends_on": { "macros": [ "macro.dbt.make_hook_config" @@ -403,14 +409,12 @@ "patch_path": null, "path": "macros/materializations/hooks.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.before_begin" }, "macro.dbt.bool_or": { "arguments": [], - "created_at": 1696458269.7931142, + "created_at": 1719485736.5525389, "depends_on": { "macros": [ "macro.dbt.default__bool_or" @@ -429,14 +433,12 @@ "patch_path": null, "path": "macros/utils/bool_or.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.bool_or" }, "macro.dbt.build_config_dict": { "arguments": [], - "created_at": 1696458269.852396, + "created_at": 1719485736.617571, "depends_on": { "macros": [] }, @@ -445,7 +447,7 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro build_config_dict(model) %}\n {%- set config_dict = {} -%}\n {%- for key in model.config.config_keys_used -%}\n {# weird type testing with enum, would be much easier to write this logic in Python! #}\n {%- if key == 'language' -%}\n {%- set value = 'python' -%}\n {%- endif -%}\n {%- set value = model.config[key] -%}\n {%- do config_dict.update({key: value}) -%}\n {%- endfor -%}\nconfig_dict = {{ config_dict }}\n{% endmacro %}", + "macro_sql": "{% macro build_config_dict(model) %}\n {%- set config_dict = {} -%}\n {% set config_dbt_used = zip(model.config.config_keys_used, model.config.config_keys_defaults) | list %}\n {%- for key, default in config_dbt_used -%}\n {# weird type testing with enum, would be much easier to write this logic in Python! #}\n {%- if key == \"language\" -%}\n {%- set value = \"python\" -%}\n {%- endif -%}\n {%- set value = model.config.get(key, default) -%}\n {%- do config_dict.update({key: value}) -%}\n {%- endfor -%}\nconfig_dict = {{ config_dict }}\n{% endmacro %}", "meta": {}, "name": "build_config_dict", "original_file_path": "macros/python_model/python.sql", @@ -453,23 +455,23 @@ "patch_path": null, "path": "macros/python_model/python.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.build_config_dict" }, "macro.dbt.build_ref_function": { "arguments": [], - "created_at": 1696458269.850849, + "created_at": 1719485736.616483, "depends_on": { - "macros": [] + "macros": [ + "macro.dbt.resolve_model_name" + ] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro build_ref_function(model) %}\n\n {%- set ref_dict = {} -%}\n {%- for _ref in model.refs -%}\n {%- set resolved = ref(*_ref) -%}\n {%- do ref_dict.update({_ref | join(\".\"): resolved.quote(database=False, schema=False, identifier=False) | string}) -%}\n {%- endfor -%}\n\ndef ref(*args,dbt_load_df_function):\n refs = {{ ref_dict | tojson }}\n key = \".\".join(args)\n return dbt_load_df_function(refs[key])\n\n{% endmacro %}", + "macro_sql": "{% macro build_ref_function(model) %}\n\n {%- set ref_dict = {} -%}\n {%- for _ref in model.refs -%}\n {% set _ref_args = [_ref.get('package'), _ref['name']] if _ref.get('package') else [_ref['name'],] %}\n {%- set resolved = ref(*_ref_args, v=_ref.get('version')) -%}\n {%- if _ref.get('version') -%}\n {% do _ref_args.extend([\"v\" ~ _ref['version']]) %}\n {%- endif -%}\n {%- do ref_dict.update({_ref_args | join('.'): resolve_model_name(resolved)}) -%}\n {%- endfor -%}\n\ndef ref(*args, **kwargs):\n refs = {{ ref_dict | tojson }}\n key = '.'.join(args)\n version = kwargs.get(\"v\") or kwargs.get(\"version\")\n if version:\n key += f\".v{version}\"\n dbt_load_df_function = kwargs.get(\"dbt_load_df_function\")\n return dbt_load_df_function(refs[key])\n\n{% endmacro %}", "meta": {}, "name": "build_ref_function", "original_file_path": "macros/python_model/python.sql", @@ -477,14 +479,12 @@ "patch_path": null, "path": "macros/python_model/python.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.build_ref_function" }, "macro.dbt.build_snapshot_staging_table": { "arguments": [], - "created_at": 1696458269.620571, + "created_at": 1719485736.358976, "depends_on": { "macros": [ "macro.dbt.make_temp_relation", @@ -506,14 +506,12 @@ "patch_path": null, "path": "macros/materializations/snapshots/helpers.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.build_snapshot_staging_table" }, "macro.dbt.build_snapshot_table": { "arguments": [], - "created_at": 1696458269.619425, + "created_at": 1719485736.3569238, "depends_on": { "macros": [ "macro.dbt.default__build_snapshot_table" @@ -532,23 +530,23 @@ "patch_path": null, "path": "macros/materializations/snapshots/helpers.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.build_snapshot_table" }, "macro.dbt.build_source_function": { "arguments": [], - "created_at": 1696458269.851639, + "created_at": 1719485736.616947, "depends_on": { - "macros": [] + "macros": [ + "macro.dbt.resolve_model_name" + ] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro build_source_function(model) %}\n\n {%- set source_dict = {} -%}\n {%- for _source in model.sources -%}\n {%- set resolved = source(*_source) -%}\n {%- do source_dict.update({_source | join(\".\"): resolved.quote(database=False, schema=False, identifier=False) | string}) -%}\n {%- endfor -%}\n\ndef source(*args, dbt_load_df_function):\n sources = {{ source_dict | tojson }}\n key = \".\".join(args)\n return dbt_load_df_function(sources[key])\n\n{% endmacro %}", + "macro_sql": "{% macro build_source_function(model) %}\n\n {%- set source_dict = {} -%}\n {%- for _source in model.sources -%}\n {%- set resolved = source(*_source) -%}\n {%- do source_dict.update({_source | join('.'): resolve_model_name(resolved)}) -%}\n {%- endfor -%}\n\ndef source(*args, dbt_load_df_function):\n sources = {{ source_dict | tojson }}\n key = '.'.join(args)\n return dbt_load_df_function(sources[key])\n\n{% endmacro %}", "meta": {}, "name": "build_source_function", "original_file_path": "macros/python_model/python.sql", @@ -556,14 +554,12 @@ "patch_path": null, "path": "macros/python_model/python.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.build_source_function" }, "macro.dbt.call_dcl_statements": { "arguments": [], - "created_at": 1696458269.827903, + "created_at": 1719485736.576986, "depends_on": { "macros": [ "macro.dbt.default__call_dcl_statements" @@ -582,14 +578,60 @@ "patch_path": null, "path": "macros/adapters/apply_grants.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.call_dcl_statements" }, + "macro.dbt.can_clone_table": { + "arguments": [], + "created_at": 1719485736.4429, + "depends_on": { + "macros": [ + "macro.dbt.default__can_clone_table" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro can_clone_table() %}\n {{ return(adapter.dispatch('can_clone_table', 'dbt')()) }}\n{% endmacro %}", + "meta": {}, + "name": "can_clone_table", + "original_file_path": "macros/materializations/models/clone/can_clone_table.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/materializations/models/clone/can_clone_table.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.can_clone_table" + }, + "macro.dbt.cast": { + "arguments": [], + "created_at": 1719485736.542182, + "depends_on": { + "macros": [ + "macro.dbt.default__cast" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro cast(field, type) %}\n {{ return(adapter.dispatch('cast', 'dbt') (field, type)) }}\n{% endmacro %}", + "meta": {}, + "name": "cast", + "original_file_path": "macros/utils/cast.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/utils/cast.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.cast" + }, "macro.dbt.cast_bool_to_text": { "arguments": [], - "created_at": 1696458269.783756, + "created_at": 1719485736.541704, "depends_on": { "macros": [ "macro.dbt.default__cast_bool_to_text" @@ -608,14 +650,12 @@ "patch_path": null, "path": "macros/utils/cast_bool_to_text.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.cast_bool_to_text" }, "macro.dbt.check_for_schema_changes": { "arguments": [], - "created_at": 1696458269.71107, + "created_at": 1719485736.43984, "depends_on": { "macros": [ "macro.dbt.diff_columns", @@ -635,14 +675,12 @@ "patch_path": null, "path": "macros/materializations/models/incremental/on_schema_change.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.check_for_schema_changes" }, "macro.dbt.check_schema_exists": { "arguments": [], - "created_at": 1696458269.8388941, + "created_at": 1719485736.591485, "depends_on": { "macros": [ "macro.dbt_postgres.postgres__check_schema_exists" @@ -661,14 +699,12 @@ "patch_path": null, "path": "macros/adapters/metadata.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.check_schema_exists" }, "macro.dbt.collect_freshness": { "arguments": [], - "created_at": 1696458269.819403, + "created_at": 1719485736.569225, "depends_on": { "macros": [ "macro.dbt.default__collect_freshness" @@ -687,14 +723,12 @@ "patch_path": null, "path": "macros/adapters/freshness.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.collect_freshness" }, "macro.dbt.concat": { "arguments": [], - "created_at": 1696458269.774415, + "created_at": 1719485736.533403, "depends_on": { "macros": [ "macro.dbt.default__concat" @@ -713,14 +747,12 @@ "patch_path": null, "path": "macros/utils/concat.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.concat" }, "macro.dbt.convert_datetime": { "arguments": [], - "created_at": 1696458269.768708, + "created_at": 1719485736.522349, "depends_on": { "macros": [] }, @@ -737,14 +769,12 @@ "patch_path": null, "path": "macros/etc/datetime.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.convert_datetime" }, "macro.dbt.copy_grants": { "arguments": [], - "created_at": 1696458269.822963, + "created_at": 1719485736.5725539, "depends_on": { "macros": [ "macro.dbt_postgres.postgres__copy_grants" @@ -763,14 +793,12 @@ "patch_path": null, "path": "macros/adapters/apply_grants.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.copy_grants" }, "macro.dbt.create_columns": { "arguments": [], - "created_at": 1696458269.6159968, + "created_at": 1719485736.354676, "depends_on": { "macros": [ "macro.dbt.default__create_columns" @@ -789,14 +817,12 @@ "patch_path": null, "path": "macros/materializations/snapshots/helpers.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.create_columns" }, "macro.dbt.create_csv_table": { "arguments": [], - "created_at": 1696458269.7461991, + "created_at": 1719485736.45865, "depends_on": { "macros": [ "macro.dbt.default__create_csv_table" @@ -815,14 +841,12 @@ "patch_path": null, "path": "macros/materializations/seeds/helpers.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.create_csv_table" }, "macro.dbt.create_indexes": { "arguments": [], - "created_at": 1696458269.8056178, + "created_at": 1719485736.561525, "depends_on": { "macros": [ "macro.dbt.default__create_indexes" @@ -841,14 +865,36 @@ "patch_path": null, "path": "macros/adapters/indexes.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.create_indexes" }, + "macro.dbt.create_or_replace_clone": { + "arguments": [], + "created_at": 1719485736.443349, + "depends_on": { + "macros": [ + "macro.dbt.default__create_or_replace_clone" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro create_or_replace_clone(this_relation, defer_relation) %}\n {{ return(adapter.dispatch('create_or_replace_clone', 'dbt')(this_relation, defer_relation)) }}\n{% endmacro %}", + "meta": {}, + "name": "create_or_replace_clone", + "original_file_path": "macros/materializations/models/clone/create_or_replace_clone.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/materializations/models/clone/create_or_replace_clone.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.create_or_replace_clone" + }, "macro.dbt.create_or_replace_view": { "arguments": [], - "created_at": 1696458269.730096, + "created_at": 1719485736.513715, "depends_on": { "macros": [ "macro.dbt.run_hooks", @@ -865,22 +911,20 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro create_or_replace_view() %}\n {%- set identifier = model['alias'] -%}\n\n {%- set old_relation = adapter.get_relation(database=database, schema=schema, identifier=identifier) -%}\n {%- set exists_as_view = (old_relation is not none and old_relation.is_view) -%}\n\n {%- set target_relation = api.Relation.create(\n identifier=identifier, schema=schema, database=database,\n type='view') -%}\n {% set grant_config = config.get('grants') %}\n\n {{ run_hooks(pre_hooks) }}\n\n -- If there's a table with the same name and we weren't told to full refresh,\n -- that's an error. If we were told to full refresh, drop it. This behavior differs\n -- for Snowflake and BigQuery, so multiple dispatch is used.\n {%- if old_relation is not none and old_relation.is_table -%}\n {{ handle_existing_table(should_full_refresh(), old_relation) }}\n {%- endif -%}\n\n -- build model\n {% call statement('main') -%}\n {{ get_create_view_as_sql(target_relation, sql) }}\n {%- endcall %}\n\n {% set should_revoke = should_revoke(exists_as_view, full_refresh_mode=True) %}\n {% do apply_grants(target_relation, grant_config, should_revoke=True) %}\n\n {{ run_hooks(post_hooks) }}\n\n {{ return({'relations': [target_relation]}) }}\n\n{% endmacro %}", + "macro_sql": "{% macro create_or_replace_view() %}\n {%- set identifier = model['alias'] -%}\n\n {%- set old_relation = adapter.get_relation(database=database, schema=schema, identifier=identifier) -%}\n {%- set exists_as_view = (old_relation is not none and old_relation.is_view) -%}\n\n {%- set target_relation = api.Relation.create(\n identifier=identifier, schema=schema, database=database,\n type='view') -%}\n {% set grant_config = config.get('grants') %}\n\n {{ run_hooks(pre_hooks) }}\n\n -- If there's a table with the same name and we weren't told to full refresh,\n -- that's an error. If we were told to full refresh, drop it. This behavior differs\n -- for Snowflake and BigQuery, so multiple dispatch is used.\n {%- if old_relation is not none and old_relation.is_table -%}\n {{ handle_existing_table(should_full_refresh(), old_relation) }}\n {%- endif -%}\n\n -- build model\n {% call statement('main') -%}\n {{ get_create_view_as_sql(target_relation, sql) }}\n {%- endcall %}\n\n {% set should_revoke = should_revoke(exists_as_view, full_refresh_mode=True) %}\n {% do apply_grants(target_relation, grant_config, should_revoke=should_revoke) %}\n\n {{ run_hooks(post_hooks) }}\n\n {{ return({'relations': [target_relation]}) }}\n\n{% endmacro %}", "meta": {}, "name": "create_or_replace_view", - "original_file_path": "macros/materializations/models/view/create_or_replace_view.sql", + "original_file_path": "macros/relations/view/replace.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/models/view/create_or_replace_view.sql", + "path": "macros/relations/view/replace.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.create_or_replace_view" }, "macro.dbt.create_schema": { "arguments": [], - "created_at": 1696458269.800509, + "created_at": 1719485736.558099, "depends_on": { "macros": [ "macro.dbt_postgres.postgres__create_schema" @@ -899,14 +943,12 @@ "patch_path": null, "path": "macros/adapters/schema.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.create_schema" }, "macro.dbt.create_table_as": { "arguments": [], - "created_at": 1696458269.720823, + "created_at": 1719485736.508673, "depends_on": { "macros": [ "macro.dbt_postgres.postgres__create_table_as" @@ -920,19 +962,17 @@ "macro_sql": "{% macro create_table_as(temporary, relation, compiled_code, language='sql') -%}\n {# backward compatibility for create_table_as that does not support language #}\n {% if language == \"sql\" %}\n {{ adapter.dispatch('create_table_as', 'dbt')(temporary, relation, compiled_code)}}\n {% else %}\n {{ adapter.dispatch('create_table_as', 'dbt')(temporary, relation, compiled_code, language) }}\n {% endif %}\n\n{%- endmacro %}", "meta": {}, "name": "create_table_as", - "original_file_path": "macros/materializations/models/table/create_table_as.sql", + "original_file_path": "macros/relations/table/create.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/models/table/create_table_as.sql", + "path": "macros/relations/table/create.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.create_table_as" }, "macro.dbt.create_view_as": { "arguments": [], - "created_at": 1696458269.731384, + "created_at": 1719485736.515506, "depends_on": { "macros": [ "macro.dbt.default__create_view_as" @@ -946,19 +986,17 @@ "macro_sql": "{% macro create_view_as(relation, sql) -%}\n {{ adapter.dispatch('create_view_as', 'dbt')(relation, sql) }}\n{%- endmacro %}", "meta": {}, "name": "create_view_as", - "original_file_path": "macros/materializations/models/view/create_view_as.sql", + "original_file_path": "macros/relations/view/create.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/models/view/create_view_as.sql", + "path": "macros/relations/view/create.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.create_view_as" }, "macro.dbt.current_timestamp": { "arguments": [], - "created_at": 1696458269.802294, + "created_at": 1719485736.55921, "depends_on": { "macros": [ "macro.dbt_postgres.postgres__current_timestamp" @@ -977,14 +1015,12 @@ "patch_path": null, "path": "macros/adapters/timestamps.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.current_timestamp" }, "macro.dbt.current_timestamp_backcompat": { "arguments": [], - "created_at": 1696458269.8032, + "created_at": 1719485736.559824, "depends_on": { "macros": [ "macro.dbt_postgres.postgres__current_timestamp_backcompat" @@ -1003,14 +1039,12 @@ "patch_path": null, "path": "macros/adapters/timestamps.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.current_timestamp_backcompat" }, "macro.dbt.current_timestamp_in_utc_backcompat": { "arguments": [], - "created_at": 1696458269.803582, + "created_at": 1719485736.5600908, "depends_on": { "macros": [ "macro.dbt_postgres.postgres__current_timestamp_in_utc_backcompat" @@ -1029,14 +1063,60 @@ "patch_path": null, "path": "macros/adapters/timestamps.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.current_timestamp_in_utc_backcompat" }, + "macro.dbt.date": { + "arguments": [], + "created_at": 1719485736.531044, + "depends_on": { + "macros": [ + "macro.dbt.default__date" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro date(year, month, day) %}\n {{ return(adapter.dispatch('date', 'dbt') (year, month, day)) }}\n{% endmacro %}", + "meta": {}, + "name": "date", + "original_file_path": "macros/utils/date.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/utils/date.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.date" + }, + "macro.dbt.date_spine": { + "arguments": [], + "created_at": 1719485736.529869, + "depends_on": { + "macros": [ + "macro.dbt.default__date_spine" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro date_spine(datepart, start_date, end_date) %}\n {{ return(adapter.dispatch('date_spine', 'dbt')(datepart, start_date, end_date)) }}\n{%- endmacro %}", + "meta": {}, + "name": "date_spine", + "original_file_path": "macros/utils/date_spine.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/utils/date_spine.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.date_spine" + }, "macro.dbt.date_trunc": { "arguments": [], - "created_at": 1696458269.797339, + "created_at": 1719485736.55547, "depends_on": { "macros": [ "macro.dbt.default__date_trunc" @@ -1055,14 +1135,12 @@ "patch_path": null, "path": "macros/utils/date_trunc.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.date_trunc" }, "macro.dbt.dateadd": { "arguments": [], - "created_at": 1696458269.776004, + "created_at": 1719485736.5368361, "depends_on": { "macros": [ "macro.dbt_postgres.postgres__dateadd" @@ -1081,14 +1159,12 @@ "patch_path": null, "path": "macros/utils/dateadd.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.dateadd" }, "macro.dbt.datediff": { "arguments": [], - "created_at": 1696458269.781129, + "created_at": 1719485736.5402482, "depends_on": { "macros": [ "macro.dbt_postgres.postgres__datediff" @@ -1107,14 +1183,12 @@ "patch_path": null, "path": "macros/utils/datediff.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.datediff" }, "macro.dbt.dates_in_range": { "arguments": [], - "created_at": 1696458269.7706811, + "created_at": 1719485736.524234, "depends_on": { "macros": [ "macro.dbt.convert_datetime" @@ -1125,7 +1199,7 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro dates_in_range(start_date_str, end_date_str=none, in_fmt=\"%Y%m%d\", out_fmt=\"%Y%m%d\") %}\n {% set end_date_str = start_date_str if end_date_str is none else end_date_str %}\n\n {% set start_date = convert_datetime(start_date_str, in_fmt) %}\n {% set end_date = convert_datetime(end_date_str, in_fmt) %}\n\n {% set day_count = (end_date - start_date).days %}\n {% if day_count < 0 %}\n {% set msg -%}\n Partiton start date is after the end date ({{ start_date }}, {{ end_date }})\n {%- endset %}\n\n {{ exceptions.raise_compiler_error(msg, model) }}\n {% endif %}\n\n {% set date_list = [] %}\n {% for i in range(0, day_count + 1) %}\n {% set the_date = (modules.datetime.timedelta(days=i) + start_date) %}\n {% if not out_fmt %}\n {% set _ = date_list.append(the_date) %}\n {% else %}\n {% set _ = date_list.append(the_date.strftime(out_fmt)) %}\n {% endif %}\n {% endfor %}\n\n {{ return(date_list) }}\n{% endmacro %}", + "macro_sql": "{% macro dates_in_range(start_date_str, end_date_str=none, in_fmt=\"%Y%m%d\", out_fmt=\"%Y%m%d\") %}\n {% set end_date_str = start_date_str if end_date_str is none else end_date_str %}\n\n {% set start_date = convert_datetime(start_date_str, in_fmt) %}\n {% set end_date = convert_datetime(end_date_str, in_fmt) %}\n\n {% set day_count = (end_date - start_date).days %}\n {% if day_count < 0 %}\n {% set msg -%}\n Partition start date is after the end date ({{ start_date }}, {{ end_date }})\n {%- endset %}\n\n {{ exceptions.raise_compiler_error(msg, model) }}\n {% endif %}\n\n {% set date_list = [] %}\n {% for i in range(0, day_count + 1) %}\n {% set the_date = (modules.datetime.timedelta(days=i) + start_date) %}\n {% if not out_fmt %}\n {% set _ = date_list.append(the_date) %}\n {% else %}\n {% set _ = date_list.append(the_date.strftime(out_fmt)) %}\n {% endif %}\n {% endfor %}\n\n {{ return(date_list) }}\n{% endmacro %}", "meta": {}, "name": "dates_in_range", "original_file_path": "macros/etc/datetime.sql", @@ -1133,14 +1207,12 @@ "patch_path": null, "path": "macros/etc/datetime.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.dates_in_range" }, "macro.dbt.default__alter_column_comment": { "arguments": [], - "created_at": 1696458269.832395, + "created_at": 1719485736.5851688, "depends_on": { "macros": [] }, @@ -1157,14 +1229,12 @@ "patch_path": null, "path": "macros/adapters/persist_docs.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__alter_column_comment" }, "macro.dbt.default__alter_column_type": { "arguments": [], - "created_at": 1696458269.846581, + "created_at": 1719485736.6003149, "depends_on": { "macros": [ "macro.dbt.statement" @@ -1183,14 +1253,12 @@ "patch_path": null, "path": "macros/adapters/columns.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__alter_column_type" }, "macro.dbt.default__alter_relation_add_remove_columns": { "arguments": [], - "created_at": 1696458269.848277, + "created_at": 1719485736.601542, "depends_on": { "macros": [ "macro.dbt.run_query" @@ -1209,14 +1277,12 @@ "patch_path": null, "path": "macros/adapters/columns.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__alter_relation_add_remove_columns" }, "macro.dbt.default__alter_relation_comment": { "arguments": [], - "created_at": 1696458269.83301, + "created_at": 1719485736.585629, "depends_on": { "macros": [] }, @@ -1233,14 +1299,12 @@ "patch_path": null, "path": "macros/adapters/persist_docs.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__alter_relation_comment" }, "macro.dbt.default__any_value": { "arguments": [], - "created_at": 1696458269.7848792, + "created_at": 1719485736.5430348, "depends_on": { "macros": [] }, @@ -1257,14 +1321,12 @@ "patch_path": null, "path": "macros/utils/any_value.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__any_value" }, "macro.dbt.default__apply_grants": { "arguments": [], - "created_at": 1696458269.830645, + "created_at": 1719485736.580575, "depends_on": { "macros": [ "macro.dbt.run_query", @@ -1286,14 +1348,12 @@ "patch_path": null, "path": "macros/adapters/apply_grants.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__apply_grants" }, "macro.dbt.default__array_append": { "arguments": [], - "created_at": 1696458269.7997708, + "created_at": 1719485736.557557, "depends_on": { "macros": [] }, @@ -1310,14 +1370,12 @@ "patch_path": null, "path": "macros/utils/array_append.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__array_append" }, "macro.dbt.default__array_concat": { "arguments": [], - "created_at": 1696458269.792536, + "created_at": 1719485736.552268, "depends_on": { "macros": [] }, @@ -1334,14 +1392,12 @@ "patch_path": null, "path": "macros/utils/array_concat.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__array_concat" }, "macro.dbt.default__array_construct": { "arguments": [], - "created_at": 1696458269.7988842, + "created_at": 1719485736.556629, "depends_on": { "macros": [] }, @@ -1358,14 +1414,12 @@ "patch_path": null, "path": "macros/utils/array_construct.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__array_construct" }, "macro.dbt.default__bool_or": { "arguments": [], - "created_at": 1696458269.793303, + "created_at": 1719485736.552653, "depends_on": { "macros": [] }, @@ -1382,14 +1436,12 @@ "patch_path": null, "path": "macros/utils/bool_or.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__bool_or" }, "macro.dbt.default__build_snapshot_table": { "arguments": [], - "created_at": 1696458269.619839, + "created_at": 1719485736.3577101, "depends_on": { "macros": [] }, @@ -1406,14 +1458,12 @@ "patch_path": null, "path": "macros/materializations/snapshots/helpers.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__build_snapshot_table" }, "macro.dbt.default__call_dcl_statements": { "arguments": [], - "created_at": 1696458269.828314, + "created_at": 1719485736.577475, "depends_on": { "macros": [ "macro.dbt.statement" @@ -1432,14 +1482,56 @@ "patch_path": null, "path": "macros/adapters/apply_grants.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__call_dcl_statements" }, + "macro.dbt.default__can_clone_table": { + "arguments": [], + "created_at": 1719485736.443028, + "depends_on": { + "macros": [] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro default__can_clone_table() %}\n {{ return(False) }}\n{% endmacro %}", + "meta": {}, + "name": "default__can_clone_table", + "original_file_path": "macros/materializations/models/clone/can_clone_table.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/materializations/models/clone/can_clone_table.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.default__can_clone_table" + }, + "macro.dbt.default__cast": { + "arguments": [], + "created_at": 1719485736.5423732, + "depends_on": { + "macros": [] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro default__cast(field, type) %}\n cast({{field}} as {{type}})\n{% endmacro %}", + "meta": {}, + "name": "default__cast", + "original_file_path": "macros/utils/cast.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/utils/cast.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.default__cast" + }, "macro.dbt.default__cast_bool_to_text": { "arguments": [], - "created_at": 1696458269.784034, + "created_at": 1719485736.541875, "depends_on": { "macros": [] }, @@ -1456,14 +1548,12 @@ "patch_path": null, "path": "macros/utils/cast_bool_to_text.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__cast_bool_to_text" }, "macro.dbt.default__check_schema_exists": { "arguments": [], - "created_at": 1696458269.8393972, + "created_at": 1719485736.591805, "depends_on": { "macros": [ "macro.dbt.replace", @@ -1483,14 +1573,12 @@ "patch_path": null, "path": "macros/adapters/metadata.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__check_schema_exists" }, "macro.dbt.default__collect_freshness": { "arguments": [], - "created_at": 1696458269.820089, + "created_at": 1719485736.569667, "depends_on": { "macros": [ "macro.dbt.statement", @@ -1502,7 +1590,7 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro default__collect_freshness(source, loaded_at_field, filter) %}\n {% call statement('collect_freshness', fetch_result=True, auto_begin=False) -%}\n select\n max({{ loaded_at_field }}) as max_loaded_at,\n {{ current_timestamp() }} as snapshotted_at\n from {{ source }}\n {% if filter %}\n where {{ filter }}\n {% endif %}\n {% endcall %}\n {{ return(load_result('collect_freshness').table) }}\n{% endmacro %}", + "macro_sql": "{% macro default__collect_freshness(source, loaded_at_field, filter) %}\n {% call statement('collect_freshness', fetch_result=True, auto_begin=False) -%}\n select\n max({{ loaded_at_field }}) as max_loaded_at,\n {{ current_timestamp() }} as snapshotted_at\n from {{ source }}\n {% if filter %}\n where {{ filter }}\n {% endif %}\n {% endcall %}\n {{ return(load_result('collect_freshness')) }}\n{% endmacro %}", "meta": {}, "name": "default__collect_freshness", "original_file_path": "macros/adapters/freshness.sql", @@ -1510,14 +1598,12 @@ "patch_path": null, "path": "macros/adapters/freshness.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__collect_freshness" }, "macro.dbt.default__concat": { "arguments": [], - "created_at": 1696458269.774613, + "created_at": 1719485736.5335412, "depends_on": { "macros": [] }, @@ -1534,14 +1620,12 @@ "patch_path": null, "path": "macros/utils/concat.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__concat" }, "macro.dbt.default__copy_grants": { "arguments": [], - "created_at": 1696458269.823154, + "created_at": 1719485736.5727968, "depends_on": { "macros": [] }, @@ -1558,14 +1642,12 @@ "patch_path": null, "path": "macros/adapters/apply_grants.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__copy_grants" }, "macro.dbt.default__create_columns": { "arguments": [], - "created_at": 1696458269.616479, + "created_at": 1719485736.354986, "depends_on": { "macros": [ "macro.dbt.statement" @@ -1584,14 +1666,12 @@ "patch_path": null, "path": "macros/materializations/snapshots/helpers.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__create_columns" }, "macro.dbt.default__create_csv_table": { "arguments": [], - "created_at": 1696458269.747761, + "created_at": 1719485736.4600089, "depends_on": { "macros": [ "macro.dbt.statement" @@ -1610,14 +1690,12 @@ "patch_path": null, "path": "macros/materializations/seeds/helpers.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__create_csv_table" }, "macro.dbt.default__create_indexes": { "arguments": [], - "created_at": 1696458269.806336, + "created_at": 1719485736.562071, "depends_on": { "macros": [ "macro.dbt.get_create_index_sql", @@ -1637,14 +1715,34 @@ "patch_path": null, "path": "macros/adapters/indexes.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__create_indexes" }, + "macro.dbt.default__create_or_replace_clone": { + "arguments": [], + "created_at": 1719485736.4434948, + "depends_on": { + "macros": [] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro default__create_or_replace_clone(this_relation, defer_relation) %}\n create or replace table {{ this_relation }} clone {{ defer_relation }}\n{% endmacro %}", + "meta": {}, + "name": "default__create_or_replace_clone", + "original_file_path": "macros/materializations/models/clone/create_or_replace_clone.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/materializations/models/clone/create_or_replace_clone.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.default__create_or_replace_clone" + }, "macro.dbt.default__create_schema": { "arguments": [], - "created_at": 1696458269.800829, + "created_at": 1719485736.5583, "depends_on": { "macros": [ "macro.dbt.statement" @@ -1663,62 +1761,62 @@ "patch_path": null, "path": "macros/adapters/schema.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__create_schema" }, "macro.dbt.default__create_table_as": { "arguments": [], - "created_at": 1696458269.721521, + "created_at": 1719485736.509567, "depends_on": { - "macros": [] + "macros": [ + "macro.dbt.get_assert_columns_equivalent", + "macro.dbt.get_table_columns_and_constraints", + "macro.dbt.get_select_subquery" + ] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro default__create_table_as(temporary, relation, sql) -%}\n {%- set sql_header = config.get('sql_header', none) -%}\n\n {{ sql_header if sql_header is not none }}\n\n create {% if temporary: -%}temporary{%- endif %} table\n {{ relation.include(database=(not temporary), schema=(not temporary)) }}\n as (\n {{ sql }}\n );\n{%- endmacro %}", + "macro_sql": "{% macro default__create_table_as(temporary, relation, sql) -%}\n {%- set sql_header = config.get('sql_header', none) -%}\n\n {{ sql_header if sql_header is not none }}\n\n create {% if temporary: -%}temporary{%- endif %} table\n {{ relation.include(database=(not temporary), schema=(not temporary)) }}\n {% set contract_config = config.get('contract') %}\n {% if contract_config.enforced and (not temporary) %}\n {{ get_assert_columns_equivalent(sql) }}\n {{ get_table_columns_and_constraints() }}\n {%- set sql = get_select_subquery(sql) %}\n {% endif %}\n as (\n {{ sql }}\n );\n{%- endmacro %}", "meta": {}, "name": "default__create_table_as", - "original_file_path": "macros/materializations/models/table/create_table_as.sql", + "original_file_path": "macros/relations/table/create.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/models/table/create_table_as.sql", + "path": "macros/relations/table/create.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__create_table_as" }, "macro.dbt.default__create_view_as": { "arguments": [], - "created_at": 1696458269.7317991, + "created_at": 1719485736.515955, "depends_on": { - "macros": [] + "macros": [ + "macro.dbt.get_assert_columns_equivalent" + ] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro default__create_view_as(relation, sql) -%}\n {%- set sql_header = config.get('sql_header', none) -%}\n\n {{ sql_header if sql_header is not none }}\n create view {{ relation }} as (\n {{ sql }}\n );\n{%- endmacro %}", + "macro_sql": "{% macro default__create_view_as(relation, sql) -%}\n {%- set sql_header = config.get('sql_header', none) -%}\n\n {{ sql_header if sql_header is not none }}\n create view {{ relation }}\n {% set contract_config = config.get('contract') %}\n {% if contract_config.enforced %}\n {{ get_assert_columns_equivalent(sql) }}\n {%- endif %}\n as (\n {{ sql }}\n );\n{%- endmacro %}", "meta": {}, "name": "default__create_view_as", - "original_file_path": "macros/materializations/models/view/create_view_as.sql", + "original_file_path": "macros/relations/view/create.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/models/view/create_view_as.sql", + "path": "macros/relations/view/create.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__create_view_as" }, "macro.dbt.default__current_timestamp": { "arguments": [], - "created_at": 1696458269.802537, + "created_at": 1719485736.5593758, "depends_on": { "macros": [] }, @@ -1735,14 +1833,12 @@ "patch_path": null, "path": "macros/adapters/timestamps.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__current_timestamp" }, "macro.dbt.default__current_timestamp_backcompat": { "arguments": [], - "created_at": 1696458269.8033202, + "created_at": 1719485736.559911, "depends_on": { "macros": [] }, @@ -1759,14 +1855,12 @@ "patch_path": null, "path": "macros/adapters/timestamps.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__current_timestamp_backcompat" }, "macro.dbt.default__current_timestamp_in_utc_backcompat": { "arguments": [], - "created_at": 1696458269.803849, + "created_at": 1719485736.560272, "depends_on": { "macros": [ "macro.dbt.current_timestamp_backcompat", @@ -1786,14 +1880,60 @@ "patch_path": null, "path": "macros/adapters/timestamps.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__current_timestamp_in_utc_backcompat" }, + "macro.dbt.default__date": { + "arguments": [], + "created_at": 1719485736.531544, + "depends_on": { + "macros": [] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro default__date(year, month, day) -%}\n {%- set dt = modules.datetime.date(year, month, day) -%}\n {%- set iso_8601_formatted_date = dt.strftime('%Y-%m-%d') -%}\n to_date('{{ iso_8601_formatted_date }}', 'YYYY-MM-DD')\n{%- endmacro %}", + "meta": {}, + "name": "default__date", + "original_file_path": "macros/utils/date.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/utils/date.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.default__date" + }, + "macro.dbt.default__date_spine": { + "arguments": [], + "created_at": 1719485736.53054, + "depends_on": { + "macros": [ + "macro.dbt.generate_series", + "macro.dbt.get_intervals_between", + "macro.dbt.dateadd" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro default__date_spine(datepart, start_date, end_date) %}\n\n\n {# call as follows:\n\n date_spine(\n \"day\",\n \"to_date('01/01/2016', 'mm/dd/yyyy')\",\n \"dbt.dateadd(week, 1, current_date)\"\n ) #}\n\n\n with rawdata as (\n\n {{dbt.generate_series(\n dbt.get_intervals_between(start_date, end_date, datepart)\n )}}\n\n ),\n\n all_periods as (\n\n select (\n {{\n dbt.dateadd(\n datepart,\n \"row_number() over (order by 1) - 1\",\n start_date\n )\n }}\n ) as date_{{datepart}}\n from rawdata\n\n ),\n\n filtered as (\n\n select *\n from all_periods\n where date_{{datepart}} <= {{ end_date }}\n\n )\n\n select * from filtered\n\n{% endmacro %}", + "meta": {}, + "name": "default__date_spine", + "original_file_path": "macros/utils/date_spine.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/utils/date_spine.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.default__date_spine" + }, "macro.dbt.default__date_trunc": { "arguments": [], - "created_at": 1696458269.7975512, + "created_at": 1719485736.555618, "depends_on": { "macros": [] }, @@ -1810,14 +1950,12 @@ "patch_path": null, "path": "macros/utils/date_trunc.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__date_trunc" }, "macro.dbt.default__dateadd": { "arguments": [], - "created_at": 1696458269.7762759, + "created_at": 1719485736.537018, "depends_on": { "macros": [] }, @@ -1834,14 +1972,12 @@ "patch_path": null, "path": "macros/utils/dateadd.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__dateadd" }, "macro.dbt.default__datediff": { "arguments": [], - "created_at": 1696458269.7814112, + "created_at": 1719485736.540429, "depends_on": { "macros": [] }, @@ -1858,17 +1994,38 @@ "patch_path": null, "path": "macros/utils/datediff.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__datediff" }, + "macro.dbt.default__drop_materialized_view": { + "arguments": [], + "created_at": 1719485736.4865391, + "depends_on": { + "macros": [] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro default__drop_materialized_view(relation) -%}\n drop materialized view if exists {{ relation }} cascade\n{%- endmacro %}", + "meta": {}, + "name": "default__drop_materialized_view", + "original_file_path": "macros/relations/materialized_view/drop.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/relations/materialized_view/drop.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.default__drop_materialized_view" + }, "macro.dbt.default__drop_relation": { "arguments": [], - "created_at": 1696458269.8147898, + "created_at": 1719485736.4714549, "depends_on": { "macros": [ - "macro.dbt.statement" + "macro.dbt.statement", + "macro.dbt.get_drop_sql" ] }, "description": "", @@ -1876,22 +2033,20 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro default__drop_relation(relation) -%}\n {% call statement('drop_relation', auto_begin=False) -%}\n drop {{ relation.type }} if exists {{ relation }} cascade\n {%- endcall %}\n{% endmacro %}", + "macro_sql": "{% macro default__drop_relation(relation) -%}\n {% call statement('drop_relation', auto_begin=False) -%}\n {{ get_drop_sql(relation) }}\n {%- endcall %}\n{% endmacro %}", "meta": {}, "name": "default__drop_relation", - "original_file_path": "macros/adapters/relation.sql", + "original_file_path": "macros/relations/drop.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/adapters/relation.sql", + "path": "macros/relations/drop.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__drop_relation" }, "macro.dbt.default__drop_schema": { "arguments": [], - "created_at": 1696458269.8013818, + "created_at": 1719485736.558672, "depends_on": { "macros": [ "macro.dbt.statement" @@ -1910,14 +2065,78 @@ "patch_path": null, "path": "macros/adapters/schema.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__drop_schema" }, + "macro.dbt.default__drop_schema_named": { + "arguments": [], + "created_at": 1719485736.476362, + "depends_on": { + "macros": [] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro default__drop_schema_named(schema_name) %}\n {% set schema_relation = api.Relation.create(schema=schema_name) %}\n {{ adapter.drop_schema(schema_relation) }}\n{% endmacro %}", + "meta": {}, + "name": "default__drop_schema_named", + "original_file_path": "macros/relations/schema.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/relations/schema.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.default__drop_schema_named" + }, + "macro.dbt.default__drop_table": { + "arguments": [], + "created_at": 1719485736.505599, + "depends_on": { + "macros": [] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro default__drop_table(relation) -%}\n drop table if exists {{ relation }} cascade\n{%- endmacro %}", + "meta": {}, + "name": "default__drop_table", + "original_file_path": "macros/relations/table/drop.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/relations/table/drop.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.default__drop_table" + }, + "macro.dbt.default__drop_view": { + "arguments": [], + "created_at": 1719485736.511198, + "depends_on": { + "macros": [] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro default__drop_view(relation) -%}\n drop view if exists {{ relation }} cascade\n{%- endmacro %}", + "meta": {}, + "name": "default__drop_view", + "original_file_path": "macros/relations/view/drop.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/relations/view/drop.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.default__drop_view" + }, "macro.dbt.default__escape_single_quotes": { "arguments": [], - "created_at": 1696458269.777675, + "created_at": 1719485736.538025, "depends_on": { "macros": [] }, @@ -1934,14 +2153,12 @@ "patch_path": null, "path": "macros/utils/escape_single_quotes.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__escape_single_quotes" }, "macro.dbt.default__except": { "arguments": [], - "created_at": 1696458269.7729568, + "created_at": 1719485736.526438, "depends_on": { "macros": [] }, @@ -1958,14 +2175,34 @@ "patch_path": null, "path": "macros/utils/except.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__except" }, + "macro.dbt.default__format_column": { + "arguments": [], + "created_at": 1719485736.504833, + "depends_on": { + "macros": [] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro default__format_column(column) -%}\n {% set data_type = column.dtype %}\n {% set formatted = column.column.lower() ~ \" \" ~ data_type %}\n {{ return({'name': column.name, 'data_type': data_type, 'formatted': formatted}) }}\n{%- endmacro -%}", + "meta": {}, + "name": "default__format_column", + "original_file_path": "macros/relations/column/columns_spec_ddl.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/relations/column/columns_spec_ddl.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.default__format_column" + }, "macro.dbt.default__generate_alias_name": { "arguments": [], - "created_at": 1696458269.7549078, + "created_at": 1719485736.466625, "depends_on": { "macros": [] }, @@ -1974,7 +2211,7 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro default__generate_alias_name(custom_alias_name=none, node=none) -%}\n\n {%- if custom_alias_name is none -%}\n\n {{ node.name }}\n\n {%- else -%}\n\n {{ custom_alias_name | trim }}\n\n {%- endif -%}\n\n{%- endmacro %}", + "macro_sql": "{% macro default__generate_alias_name(custom_alias_name=none, node=none) -%}\n\n {%- if custom_alias_name -%}\n\n {{ custom_alias_name | trim }}\n\n {%- elif node.version -%}\n\n {{ return(node.name ~ \"_v\" ~ (node.version | replace(\".\", \"_\"))) }}\n\n {%- else -%}\n\n {{ node.name }}\n\n {%- endif -%}\n\n{%- endmacro %}", "meta": {}, "name": "default__generate_alias_name", "original_file_path": "macros/get_custom_name/get_custom_alias.sql", @@ -1982,14 +2219,12 @@ "patch_path": null, "path": "macros/get_custom_name/get_custom_alias.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__generate_alias_name" }, "macro.dbt.default__generate_database_name": { "arguments": [], - "created_at": 1696458269.758259, + "created_at": 1719485736.469049, "depends_on": { "macros": [] }, @@ -2006,14 +2241,12 @@ "patch_path": null, "path": "macros/get_custom_name/get_custom_database.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__generate_database_name" }, "macro.dbt.default__generate_schema_name": { "arguments": [], - "created_at": 1696458269.756475, + "created_at": 1719485736.4677172, "depends_on": { "macros": [] }, @@ -2030,14 +2263,82 @@ "patch_path": null, "path": "macros/get_custom_name/get_custom_schema.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__generate_schema_name" }, + "macro.dbt.default__generate_series": { + "arguments": [], + "created_at": 1719485736.535907, + "depends_on": { + "macros": [ + "macro.dbt.get_powers_of_two" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro default__generate_series(upper_bound) %}\n\n {% set n = dbt.get_powers_of_two(upper_bound) %}\n\n with p as (\n select 0 as generated_number union all select 1\n ), unioned as (\n\n select\n\n {% for i in range(n) %}\n p{{i}}.generated_number * power(2, {{i}})\n {% if not loop.last %} + {% endif %}\n {% endfor %}\n + 1\n as generated_number\n\n from\n\n {% for i in range(n) %}\n p as p{{i}}\n {% if not loop.last %} cross join {% endif %}\n {% endfor %}\n\n )\n\n select *\n from unioned\n where generated_number <= {{upper_bound}}\n order by generated_number\n\n{% endmacro %}", + "meta": {}, + "name": "default__generate_series", + "original_file_path": "macros/utils/generate_series.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/utils/generate_series.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.default__generate_series" + }, + "macro.dbt.default__get_alter_materialized_view_as_sql": { + "arguments": [], + "created_at": 1719485736.4936142, + "depends_on": { + "macros": [] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro default__get_alter_materialized_view_as_sql(\n relation,\n configuration_changes,\n sql,\n existing_relation,\n backup_relation,\n intermediate_relation\n) %}\n {{ exceptions.raise_compiler_error(\"Materialized views have not been implemented for this adapter.\") }}\n{% endmacro %}", + "meta": {}, + "name": "default__get_alter_materialized_view_as_sql", + "original_file_path": "macros/relations/materialized_view/alter.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/relations/materialized_view/alter.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.default__get_alter_materialized_view_as_sql" + }, + "macro.dbt.default__get_assert_columns_equivalent": { + "arguments": [], + "created_at": 1719485736.501285, + "depends_on": { + "macros": [ + "macro.dbt.assert_columns_equivalent" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro default__get_assert_columns_equivalent(sql) -%}\n {{ return(assert_columns_equivalent(sql)) }}\n{%- endmacro %}", + "meta": {}, + "name": "default__get_assert_columns_equivalent", + "original_file_path": "macros/relations/column/columns_spec_ddl.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/relations/column/columns_spec_ddl.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.default__get_assert_columns_equivalent" + }, "macro.dbt.default__get_batch_size": { "arguments": [], - "created_at": 1696458269.750355, + "created_at": 1719485736.4618182, "depends_on": { "macros": [] }, @@ -2054,14 +2355,12 @@ "patch_path": null, "path": "macros/materializations/seeds/helpers.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__get_batch_size" }, "macro.dbt.default__get_binding_char": { "arguments": [], - "created_at": 1696458269.749901, + "created_at": 1719485736.461474, "depends_on": { "macros": [] }, @@ -2078,14 +2377,12 @@ "patch_path": null, "path": "macros/materializations/seeds/helpers.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__get_binding_char" }, "macro.dbt.default__get_catalog": { "arguments": [], - "created_at": 1696458269.8372722, + "created_at": 1719485736.590454, "depends_on": { "macros": [] }, @@ -2102,17 +2399,60 @@ "patch_path": null, "path": "macros/adapters/metadata.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__get_catalog" }, + "macro.dbt.default__get_catalog_relations": { + "arguments": [], + "created_at": 1719485736.589964, + "depends_on": { + "macros": [] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro default__get_catalog_relations(information_schema, relations) -%}\n {% set typename = adapter.type() %}\n {% set msg -%}\n get_catalog_relations not implemented for {{ typename }}\n {%- endset %}\n\n {{ exceptions.raise_compiler_error(msg) }}\n{%- endmacro %}", + "meta": {}, + "name": "default__get_catalog_relations", + "original_file_path": "macros/adapters/metadata.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/adapters/metadata.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.default__get_catalog_relations" + }, + "macro.dbt.default__get_column_names": { + "arguments": [], + "created_at": 1719485736.5100741, + "depends_on": { + "macros": [] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro default__get_column_names() %}\n {#- loop through user_provided_columns to get column names -#}\n {%- set user_provided_columns = model['columns'] -%}\n {%- for i in user_provided_columns %}\n {%- set col = user_provided_columns[i] -%}\n {%- set col_name = adapter.quote(col['name']) if col.get('quote') else col['name'] -%}\n {{ col_name }}{{ \", \" if not loop.last }}\n {%- endfor -%}\n{% endmacro %}", + "meta": {}, + "name": "default__get_column_names", + "original_file_path": "macros/relations/table/create.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/relations/table/create.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.default__get_column_names" + }, "macro.dbt.default__get_columns_in_query": { "arguments": [], - "created_at": 1696458269.8452191, + "created_at": 1719485736.599448, "depends_on": { "macros": [ - "macro.dbt.statement" + "macro.dbt.statement", + "macro.dbt.get_empty_subquery_sql" ] }, "description": "", @@ -2120,7 +2460,7 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro default__get_columns_in_query(select_sql) %}\n {% call statement('get_columns_in_query', fetch_result=True, auto_begin=False) -%}\n select * from (\n {{ select_sql }}\n ) as __dbt_sbq\n where false\n limit 0\n {% endcall %}\n\n {{ return(load_result('get_columns_in_query').table.columns | map(attribute='name') | list) }}\n{% endmacro %}", + "macro_sql": "{% macro default__get_columns_in_query(select_sql) %}\n {% call statement('get_columns_in_query', fetch_result=True, auto_begin=False) -%}\n {{ get_empty_subquery_sql(select_sql) }}\n {% endcall %}\n {{ return(load_result('get_columns_in_query').table.columns | map(attribute='name') | list) }}\n{% endmacro %}", "meta": {}, "name": "default__get_columns_in_query", "original_file_path": "macros/adapters/columns.sql", @@ -2128,14 +2468,12 @@ "patch_path": null, "path": "macros/adapters/columns.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__get_columns_in_query" }, "macro.dbt.default__get_columns_in_relation": { "arguments": [], - "created_at": 1696458269.843777, + "created_at": 1719485736.59628, "depends_on": { "macros": [] }, @@ -2152,14 +2490,38 @@ "patch_path": null, "path": "macros/adapters/columns.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__get_columns_in_relation" }, + "macro.dbt.default__get_create_backup_sql": { + "arguments": [], + "created_at": 1719485736.47981, + "depends_on": { + "macros": [ + "macro.dbt.make_backup_relation", + "macro.dbt.get_drop_sql", + "macro.dbt.get_rename_sql" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{%- macro default__get_create_backup_sql(relation) -%}\n\n -- get the standard backup name\n {% set backup_relation = make_backup_relation(relation, relation.type) %}\n\n -- drop any pre-existing backup\n {{ get_drop_sql(backup_relation) }};\n\n {{ get_rename_sql(relation, backup_relation.identifier) }}\n\n{%- endmacro -%}", + "meta": {}, + "name": "default__get_create_backup_sql", + "original_file_path": "macros/relations/create_backup.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/relations/create_backup.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.default__get_create_backup_sql" + }, "macro.dbt.default__get_create_index_sql": { "arguments": [], - "created_at": 1696458269.805347, + "created_at": 1719485736.561354, "depends_on": { "macros": [] }, @@ -2176,14 +2538,86 @@ "patch_path": null, "path": "macros/adapters/indexes.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__get_create_index_sql" }, + "macro.dbt.default__get_create_intermediate_sql": { + "arguments": [], + "created_at": 1719485736.475838, + "depends_on": { + "macros": [ + "macro.dbt.make_intermediate_relation", + "macro.dbt.get_drop_sql", + "macro.dbt.get_create_sql" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{%- macro default__get_create_intermediate_sql(relation, sql) -%}\n\n -- get the standard intermediate name\n {% set intermediate_relation = make_intermediate_relation(relation) %}\n\n -- drop any pre-existing intermediate\n {{ get_drop_sql(intermediate_relation) }};\n\n {{ get_create_sql(intermediate_relation, sql) }}\n\n{%- endmacro -%}", + "meta": {}, + "name": "default__get_create_intermediate_sql", + "original_file_path": "macros/relations/create_intermediate.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/relations/create_intermediate.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.default__get_create_intermediate_sql" + }, + "macro.dbt.default__get_create_materialized_view_as_sql": { + "arguments": [], + "created_at": 1719485736.494792, + "depends_on": { + "macros": [] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro default__get_create_materialized_view_as_sql(relation, sql) -%}\n {{ exceptions.raise_compiler_error(\n \"`get_create_materialized_view_as_sql` has not been implemented for this adapter.\"\n ) }}\n{% endmacro %}", + "meta": {}, + "name": "default__get_create_materialized_view_as_sql", + "original_file_path": "macros/relations/materialized_view/create.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/relations/materialized_view/create.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.default__get_create_materialized_view_as_sql" + }, + "macro.dbt.default__get_create_sql": { + "arguments": [], + "created_at": 1719485736.483476, + "depends_on": { + "macros": [ + "macro.dbt.get_create_view_as_sql", + "macro.dbt.get_create_table_as_sql", + "macro.dbt.get_create_materialized_view_as_sql" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{%- macro default__get_create_sql(relation, sql) -%}\n\n {%- if relation.is_view -%}\n {{ get_create_view_as_sql(relation, sql) }}\n\n {%- elif relation.is_table -%}\n {{ get_create_table_as_sql(False, relation, sql) }}\n\n {%- elif relation.is_materialized_view -%}\n {{ get_create_materialized_view_as_sql(relation, sql) }}\n\n {%- else -%}\n {{- exceptions.raise_compiler_error(\"`get_create_sql` has not been implemented for: \" ~ relation.type ) -}}\n\n {%- endif -%}\n\n{%- endmacro -%}", + "meta": {}, + "name": "default__get_create_sql", + "original_file_path": "macros/relations/create.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/relations/create.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.default__get_create_sql" + }, "macro.dbt.default__get_create_table_as_sql": { "arguments": [], - "created_at": 1696458269.720119, + "created_at": 1719485736.508211, "depends_on": { "macros": [ "macro.dbt.create_table_as" @@ -2197,19 +2631,17 @@ "macro_sql": "{% macro default__get_create_table_as_sql(temporary, relation, sql) -%}\n {{ return(create_table_as(temporary, relation, sql)) }}\n{% endmacro %}", "meta": {}, "name": "default__get_create_table_as_sql", - "original_file_path": "macros/materializations/models/table/create_table_as.sql", + "original_file_path": "macros/relations/table/create.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/models/table/create_table_as.sql", + "path": "macros/relations/table/create.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__get_create_table_as_sql" }, "macro.dbt.default__get_create_view_as_sql": { "arguments": [], - "created_at": 1696458269.7310889, + "created_at": 1719485736.515317, "depends_on": { "macros": [ "macro.dbt.create_view_as" @@ -2223,19 +2655,17 @@ "macro_sql": "{% macro default__get_create_view_as_sql(relation, sql) -%}\n {{ return(create_view_as(relation, sql)) }}\n{% endmacro %}", "meta": {}, "name": "default__get_create_view_as_sql", - "original_file_path": "macros/materializations/models/view/create_view_as.sql", + "original_file_path": "macros/relations/view/create.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/models/view/create_view_as.sql", + "path": "macros/relations/view/create.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__get_create_view_as_sql" }, "macro.dbt.default__get_csv_sql": { "arguments": [], - "created_at": 1696458269.749481, + "created_at": 1719485736.461133, "depends_on": { "macros": [] }, @@ -2252,14 +2682,12 @@ "patch_path": null, "path": "macros/materializations/seeds/helpers.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__get_csv_sql" }, "macro.dbt.default__get_dcl_statement_list": { "arguments": [], - "created_at": 1696458269.827488, + "created_at": 1719485736.5756311, "depends_on": { "macros": [ "macro.dbt.support_multiple_grantees_per_dcl_statement" @@ -2278,14 +2706,12 @@ "patch_path": null, "path": "macros/adapters/apply_grants.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__get_dcl_statement_list" }, "macro.dbt.default__get_delete_insert_merge_sql": { "arguments": [], - "created_at": 1696458269.6825862, + "created_at": 1719485736.415282, "depends_on": { "macros": [ "macro.dbt.get_quoted_csv" @@ -2296,7 +2722,7 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro default__get_delete_insert_merge_sql(target, source, unique_key, dest_columns) -%}\n\n {%- set dest_cols_csv = get_quoted_csv(dest_columns | map(attribute=\"name\")) -%}\n\n {% if unique_key %}\n {% if unique_key is sequence and unique_key is not string %}\n delete from {{target }}\n using {{ source }}\n where (\n {% for key in unique_key %}\n {{ source }}.{{ key }} = {{ target }}.{{ key }}\n {{ \"and \" if not loop.last }}\n {% endfor %}\n );\n {% else %}\n delete from {{ target }}\n where (\n {{ unique_key }}) in (\n select ({{ unique_key }})\n from {{ source }}\n );\n\n {% endif %}\n {% endif %}\n\n insert into {{ target }} ({{ dest_cols_csv }})\n (\n select {{ dest_cols_csv }}\n from {{ source }}\n )\n\n{%- endmacro %}", + "macro_sql": "{% macro default__get_delete_insert_merge_sql(target, source, unique_key, dest_columns, incremental_predicates) -%}\n\n {%- set dest_cols_csv = get_quoted_csv(dest_columns | map(attribute=\"name\")) -%}\n\n {% if unique_key %}\n {% if unique_key is sequence and unique_key is not string %}\n delete from {{target }}\n using {{ source }}\n where (\n {% for key in unique_key %}\n {{ source }}.{{ key }} = {{ target }}.{{ key }}\n {{ \"and \" if not loop.last}}\n {% endfor %}\n {% if incremental_predicates %}\n {% for predicate in incremental_predicates %}\n and {{ predicate }}\n {% endfor %}\n {% endif %}\n );\n {% else %}\n delete from {{ target }}\n where (\n {{ unique_key }}) in (\n select ({{ unique_key }})\n from {{ source }}\n )\n {%- if incremental_predicates %}\n {% for predicate in incremental_predicates %}\n and {{ predicate }}\n {% endfor %}\n {%- endif -%};\n\n {% endif %}\n {% endif %}\n\n insert into {{ target }} ({{ dest_cols_csv }})\n (\n select {{ dest_cols_csv }}\n from {{ source }}\n )\n\n{%- endmacro %}", "meta": {}, "name": "default__get_delete_insert_merge_sql", "original_file_path": "macros/materializations/models/incremental/merge.sql", @@ -2304,38 +2730,153 @@ "patch_path": null, "path": "macros/materializations/models/incremental/merge.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__get_delete_insert_merge_sql" }, - "macro.dbt.default__get_grant_sql": { + "macro.dbt.default__get_drop_backup_sql": { "arguments": [], - "created_at": 1696458269.825314, + "created_at": 1719485736.476908, "depends_on": { - "macros": [] + "macros": [ + "macro.dbt.make_backup_relation", + "macro.dbt.get_drop_sql" + ] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "\n\n{%- macro default__get_grant_sql(relation, privilege, grantees) -%}\n grant {{ privilege }} on {{ relation }} to {{ grantees | join(', ') }}\n{%- endmacro -%}\n\n\n", + "macro_sql": "{%- macro default__get_drop_backup_sql(relation) -%}\n\n -- get the standard backup name\n {% set backup_relation = make_backup_relation(relation, relation.type) %}\n\n {{ get_drop_sql(backup_relation) }}\n\n{%- endmacro -%}", "meta": {}, - "name": "default__get_grant_sql", - "original_file_path": "macros/adapters/apply_grants.sql", + "name": "default__get_drop_backup_sql", + "original_file_path": "macros/relations/drop_backup.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/adapters/apply_grants.sql", + "path": "macros/relations/drop_backup.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.default__get_drop_backup_sql" + }, + "macro.dbt.default__get_drop_index_sql": { + "arguments": [], + "created_at": 1719485736.562772, + "depends_on": { + "macros": [] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro default__get_drop_index_sql(relation, index_name) -%}\n {{ exceptions.raise_compiler_error(\"`get_drop_index_sql has not been implemented for this adapter.\") }}\n{%- endmacro %}", + "meta": {}, + "name": "default__get_drop_index_sql", + "original_file_path": "macros/adapters/indexes.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/adapters/indexes.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.default__get_drop_index_sql" + }, + "macro.dbt.default__get_drop_sql": { + "arguments": [], + "created_at": 1719485736.471034, + "depends_on": { + "macros": [ + "macro.dbt.drop_view", + "macro.dbt.drop_table", + "macro.dbt.drop_materialized_view" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{%- macro default__get_drop_sql(relation) -%}\n\n {%- if relation.is_view -%}\n {{ drop_view(relation) }}\n\n {%- elif relation.is_table -%}\n {{ drop_table(relation) }}\n\n {%- elif relation.is_materialized_view -%}\n {{ drop_materialized_view(relation) }}\n\n {%- else -%}\n drop {{ relation.type }} if exists {{ relation }} cascade\n\n {%- endif -%}\n\n{%- endmacro -%}\n\n\n", + "meta": {}, + "name": "default__get_drop_sql", + "original_file_path": "macros/relations/drop.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/relations/drop.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.default__get_drop_sql" + }, + "macro.dbt.default__get_empty_schema_sql": { + "arguments": [], + "created_at": 1719485736.5985012, + "depends_on": { + "macros": [ + "macro.dbt.cast" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro default__get_empty_schema_sql(columns) %}\n {%- set col_err = [] -%}\n {%- set col_naked_numeric = [] -%}\n select\n {% for i in columns %}\n {%- set col = columns[i] -%}\n {%- if col['data_type'] is not defined -%}\n {%- do col_err.append(col['name']) -%}\n {#-- If this column's type is just 'numeric' then it is missing precision/scale, raise a warning --#}\n {%- elif col['data_type'].strip().lower() in ('numeric', 'decimal', 'number') -%}\n {%- do col_naked_numeric.append(col['name']) -%}\n {%- endif -%}\n {% set col_name = adapter.quote(col['name']) if col.get('quote') else col['name'] %}\n {{ cast('null', col['data_type']) }} as {{ col_name }}{{ \", \" if not loop.last }}\n {%- endfor -%}\n {%- if (col_err | length) > 0 -%}\n {{ exceptions.column_type_missing(column_names=col_err) }}\n {%- elif (col_naked_numeric | length) > 0 -%}\n {{ exceptions.warn(\"Detected columns with numeric type and unspecified precision/scale, this can lead to unintended rounding: \" ~ col_naked_numeric ~ \"`\") }}\n {%- endif -%}\n{% endmacro %}", + "meta": {}, + "name": "default__get_empty_schema_sql", + "original_file_path": "macros/adapters/columns.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/adapters/columns.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.default__get_empty_schema_sql" + }, + "macro.dbt.default__get_empty_subquery_sql": { + "arguments": [], + "created_at": 1719485736.597066, + "depends_on": { + "macros": [] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro default__get_empty_subquery_sql(select_sql, select_sql_header=none) %}\n {%- if select_sql_header is not none -%}\n {{ select_sql_header }}\n {%- endif -%}\n select * from (\n {{ select_sql }}\n ) as __dbt_sbq\n where false\n limit 0\n{% endmacro %}", + "meta": {}, + "name": "default__get_empty_subquery_sql", + "original_file_path": "macros/adapters/columns.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/adapters/columns.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.default__get_empty_subquery_sql" + }, + "macro.dbt.default__get_grant_sql": { + "arguments": [], + "created_at": 1719485736.5742211, + "depends_on": { + "macros": [] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "\n\n{%- macro default__get_grant_sql(relation, privilege, grantees) -%}\n grant {{ privilege }} on {{ relation }} to {{ grantees | join(', ') }}\n{%- endmacro -%}\n\n\n", + "meta": {}, + "name": "default__get_grant_sql", + "original_file_path": "macros/adapters/apply_grants.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/adapters/apply_grants.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__get_grant_sql" }, "macro.dbt.default__get_incremental_append_sql": { "arguments": [], - "created_at": 1696458269.687379, + "created_at": 1719485736.418623, "depends_on": { "macros": [ "macro.dbt.get_insert_into_sql" @@ -2354,14 +2895,12 @@ "patch_path": null, "path": "macros/materializations/models/incremental/strategies.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__get_incremental_append_sql" }, "macro.dbt.default__get_incremental_default_sql": { "arguments": [], - "created_at": 1696458269.690251, + "created_at": 1719485736.4215739, "depends_on": { "macros": [ "macro.dbt.get_incremental_append_sql" @@ -2380,14 +2919,12 @@ "patch_path": null, "path": "macros/materializations/models/incremental/strategies.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__get_incremental_default_sql" }, "macro.dbt.default__get_incremental_delete_insert_sql": { "arguments": [], - "created_at": 1696458269.688123, + "created_at": 1719485736.419171, "depends_on": { "macros": [ "macro.dbt.get_delete_insert_merge_sql" @@ -2398,7 +2935,7 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro default__get_incremental_delete_insert_sql(arg_dict) %}\n\n {% do return(get_delete_insert_merge_sql(arg_dict[\"target_relation\"], arg_dict[\"temp_relation\"], arg_dict[\"unique_key\"], arg_dict[\"dest_columns\"])) %}\n\n{% endmacro %}", + "macro_sql": "{% macro default__get_incremental_delete_insert_sql(arg_dict) %}\n\n {% do return(get_delete_insert_merge_sql(arg_dict[\"target_relation\"], arg_dict[\"temp_relation\"], arg_dict[\"unique_key\"], arg_dict[\"dest_columns\"], arg_dict[\"incremental_predicates\"])) %}\n\n{% endmacro %}", "meta": {}, "name": "default__get_incremental_delete_insert_sql", "original_file_path": "macros/materializations/models/incremental/strategies.sql", @@ -2406,14 +2943,12 @@ "patch_path": null, "path": "macros/materializations/models/incremental/strategies.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__get_incremental_delete_insert_sql" }, "macro.dbt.default__get_incremental_insert_overwrite_sql": { "arguments": [], - "created_at": 1696458269.689686, + "created_at": 1719485736.421212, "depends_on": { "macros": [ "macro.dbt.get_insert_overwrite_merge_sql" @@ -2424,7 +2959,7 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro default__get_incremental_insert_overwrite_sql(arg_dict) %}\n\n {% do return(get_insert_overwrite_merge_sql(arg_dict[\"target_relation\"], arg_dict[\"temp_relation\"], arg_dict[\"dest_columns\"], arg_dict[\"predicates\"])) %}\n\n{% endmacro %}", + "macro_sql": "{% macro default__get_incremental_insert_overwrite_sql(arg_dict) %}\n\n {% do return(get_insert_overwrite_merge_sql(arg_dict[\"target_relation\"], arg_dict[\"temp_relation\"], arg_dict[\"dest_columns\"], arg_dict[\"incremental_predicates\"])) %}\n\n{% endmacro %}", "meta": {}, "name": "default__get_incremental_insert_overwrite_sql", "original_file_path": "macros/materializations/models/incremental/strategies.sql", @@ -2432,14 +2967,12 @@ "patch_path": null, "path": "macros/materializations/models/incremental/strategies.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__get_incremental_insert_overwrite_sql" }, "macro.dbt.default__get_incremental_merge_sql": { "arguments": [], - "created_at": 1696458269.6889029, + "created_at": 1719485736.420272, "depends_on": { "macros": [ "macro.dbt.get_merge_sql" @@ -2450,7 +2983,7 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro default__get_incremental_merge_sql(arg_dict) %}\n\n {% do return(get_merge_sql(arg_dict[\"target_relation\"], arg_dict[\"temp_relation\"], arg_dict[\"unique_key\"], arg_dict[\"dest_columns\"])) %}\n\n{% endmacro %}", + "macro_sql": "{% macro default__get_incremental_merge_sql(arg_dict) %}\n\n {% do return(get_merge_sql(arg_dict[\"target_relation\"], arg_dict[\"temp_relation\"], arg_dict[\"unique_key\"], arg_dict[\"dest_columns\"], arg_dict[\"incremental_predicates\"])) %}\n\n{% endmacro %}", "meta": {}, "name": "default__get_incremental_merge_sql", "original_file_path": "macros/materializations/models/incremental/strategies.sql", @@ -2458,14 +2991,12 @@ "patch_path": null, "path": "macros/materializations/models/incremental/strategies.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__get_incremental_merge_sql" }, "macro.dbt.default__get_insert_overwrite_merge_sql": { "arguments": [], - "created_at": 1696458269.684117, + "created_at": 1719485736.416304, "depends_on": { "macros": [ "macro.dbt.get_quoted_csv" @@ -2484,14 +3015,81 @@ "patch_path": null, "path": "macros/materializations/models/incremental/merge.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__get_insert_overwrite_merge_sql" }, + "macro.dbt.default__get_intervals_between": { + "arguments": [], + "created_at": 1719485736.5290911, + "depends_on": { + "macros": [ + "macro.dbt.statement", + "macro.dbt.datediff" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro default__get_intervals_between(start_date, end_date, datepart) -%}\n {%- call statement('get_intervals_between', fetch_result=True) %}\n\n select {{ dbt.datediff(start_date, end_date, datepart) }}\n\n {%- endcall -%}\n\n {%- set value_list = load_result('get_intervals_between') -%}\n\n {%- if value_list and value_list['data'] -%}\n {%- set values = value_list['data'] | map(attribute=0) | list %}\n {{ return(values[0]) }}\n {%- else -%}\n {{ return(1) }}\n {%- endif -%}\n\n{%- endmacro %}", + "meta": {}, + "name": "default__get_intervals_between", + "original_file_path": "macros/utils/date_spine.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/utils/date_spine.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.default__get_intervals_between" + }, + "macro.dbt.default__get_limit_subquery_sql": { + "arguments": [], + "created_at": 1719485736.581942, + "depends_on": { + "macros": [] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro default__get_limit_subquery_sql(sql, limit) %}\n select *\n from (\n {{ sql }}\n ) as model_limit_subq\n limit {{ limit }}\n{% endmacro %}", + "meta": {}, + "name": "default__get_limit_subquery_sql", + "original_file_path": "macros/adapters/show.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/adapters/show.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.default__get_limit_subquery_sql" + }, + "macro.dbt.default__get_materialized_view_configuration_changes": { + "arguments": [], + "created_at": 1719485736.4943428, + "depends_on": { + "macros": [] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro default__get_materialized_view_configuration_changes(existing_relation, new_config) %}\n {{ exceptions.raise_compiler_error(\"Materialized views have not been implemented for this adapter.\") }}\n{% endmacro %}", + "meta": {}, + "name": "default__get_materialized_view_configuration_changes", + "original_file_path": "macros/relations/materialized_view/alter.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/relations/materialized_view/alter.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.default__get_materialized_view_configuration_changes" + }, "macro.dbt.default__get_merge_sql": { "arguments": [], - "created_at": 1696458269.680794, + "created_at": 1719485736.413476, "depends_on": { "macros": [ "macro.dbt.get_quoted_csv", @@ -2503,7 +3101,7 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro default__get_merge_sql(target, source, unique_key, dest_columns, predicates) -%}\n {%- set predicates = [] if predicates is none else [] + predicates -%}\n {%- set dest_cols_csv = get_quoted_csv(dest_columns | map(attribute=\"name\")) -%}\n {%- set merge_update_columns = config.get('merge_update_columns') -%}\n {%- set merge_exclude_columns = config.get('merge_exclude_columns') -%}\n {%- set update_columns = get_merge_update_columns(merge_update_columns, merge_exclude_columns, dest_columns) -%}\n {%- set sql_header = config.get('sql_header', none) -%}\n\n {% if unique_key %}\n {% if unique_key is sequence and unique_key is not mapping and unique_key is not string %}\n {% for key in unique_key %}\n {% set this_key_match %}\n DBT_INTERNAL_SOURCE.{{ key }} = DBT_INTERNAL_DEST.{{ key }}\n {% endset %}\n {% do predicates.append(this_key_match) %}\n {% endfor %}\n {% else %}\n {% set unique_key_match %}\n DBT_INTERNAL_SOURCE.{{ unique_key }} = DBT_INTERNAL_DEST.{{ unique_key }}\n {% endset %}\n {% do predicates.append(unique_key_match) %}\n {% endif %}\n {% else %}\n {% do predicates.append('FALSE') %}\n {% endif %}\n\n {{ sql_header if sql_header is not none }}\n\n merge into {{ target }} as DBT_INTERNAL_DEST\n using {{ source }} as DBT_INTERNAL_SOURCE\n on {{ predicates | join(' and ') }}\n\n {% if unique_key %}\n when matched then update set\n {% for column_name in update_columns -%}\n {{ column_name }} = DBT_INTERNAL_SOURCE.{{ column_name }}\n {%- if not loop.last %}, {%- endif %}\n {%- endfor %}\n {% endif %}\n\n when not matched then insert\n ({{ dest_cols_csv }})\n values\n ({{ dest_cols_csv }})\n\n{% endmacro %}", + "macro_sql": "{% macro default__get_merge_sql(target, source, unique_key, dest_columns, incremental_predicates=none) -%}\n {%- set predicates = [] if incremental_predicates is none else [] + incremental_predicates -%}\n {%- set dest_cols_csv = get_quoted_csv(dest_columns | map(attribute=\"name\")) -%}\n {%- set merge_update_columns = config.get('merge_update_columns') -%}\n {%- set merge_exclude_columns = config.get('merge_exclude_columns') -%}\n {%- set update_columns = get_merge_update_columns(merge_update_columns, merge_exclude_columns, dest_columns) -%}\n {%- set sql_header = config.get('sql_header', none) -%}\n\n {% if unique_key %}\n {% if unique_key is sequence and unique_key is not mapping and unique_key is not string %}\n {% for key in unique_key %}\n {% set this_key_match %}\n DBT_INTERNAL_SOURCE.{{ key }} = DBT_INTERNAL_DEST.{{ key }}\n {% endset %}\n {% do predicates.append(this_key_match) %}\n {% endfor %}\n {% else %}\n {% set unique_key_match %}\n DBT_INTERNAL_SOURCE.{{ unique_key }} = DBT_INTERNAL_DEST.{{ unique_key }}\n {% endset %}\n {% do predicates.append(unique_key_match) %}\n {% endif %}\n {% else %}\n {% do predicates.append('FALSE') %}\n {% endif %}\n\n {{ sql_header if sql_header is not none }}\n\n merge into {{ target }} as DBT_INTERNAL_DEST\n using {{ source }} as DBT_INTERNAL_SOURCE\n on {{\"(\" ~ predicates | join(\") and (\") ~ \")\"}}\n\n {% if unique_key %}\n when matched then update set\n {% for column_name in update_columns -%}\n {{ column_name }} = DBT_INTERNAL_SOURCE.{{ column_name }}\n {%- if not loop.last %}, {%- endif %}\n {%- endfor %}\n {% endif %}\n\n when not matched then insert\n ({{ dest_cols_csv }})\n values\n ({{ dest_cols_csv }})\n\n{% endmacro %}", "meta": {}, "name": "default__get_merge_sql", "original_file_path": "macros/materializations/models/incremental/merge.sql", @@ -2511,14 +3109,12 @@ "patch_path": null, "path": "macros/materializations/models/incremental/merge.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__get_merge_sql" }, "macro.dbt.default__get_merge_update_columns": { "arguments": [], - "created_at": 1696458269.67032, + "created_at": 1719485736.399054, "depends_on": { "macros": [] }, @@ -2535,14 +3131,12 @@ "patch_path": null, "path": "macros/materializations/models/incremental/column_helpers.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__get_merge_update_columns" }, "macro.dbt.default__get_or_create_relation": { "arguments": [], - "created_at": 1696458269.817579, + "created_at": 1719485736.568331, "depends_on": { "macros": [] }, @@ -2559,14 +3153,12 @@ "patch_path": null, "path": "macros/adapters/relation.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt.default__get_or_create_relation" }, - "macro.dbt.default__get_revoke_sql": { + "macro.dbt.default__get_powers_of_two": { "arguments": [], - "created_at": 1696458269.8259919, + "created_at": 1719485736.534994, "depends_on": { "macros": [] }, @@ -2575,22 +3167,20 @@ "node_color": null, "show": true }, - "macro_sql": "\n\n{%- macro default__get_revoke_sql(relation, privilege, grantees) -%}\n revoke {{ privilege }} on {{ relation }} from {{ grantees | join(', ') }}\n{%- endmacro -%}\n\n\n", + "macro_sql": "{% macro default__get_powers_of_two(upper_bound) %}\n\n {% if upper_bound <= 0 %}\n {{ exceptions.raise_compiler_error(\"upper bound must be positive\") }}\n {% endif %}\n\n {% for _ in range(1, 100) %}\n {% if upper_bound <= 2 ** loop.index %}{{ return(loop.index) }}{% endif %}\n {% endfor %}\n\n{% endmacro %}", "meta": {}, - "name": "default__get_revoke_sql", - "original_file_path": "macros/adapters/apply_grants.sql", + "name": "default__get_powers_of_two", + "original_file_path": "macros/utils/generate_series.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/adapters/apply_grants.sql", + "path": "macros/utils/generate_series.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__get_revoke_sql" + "unique_id": "macro.dbt.default__get_powers_of_two" }, - "macro.dbt.default__get_show_grant_sql": { + "macro.dbt.default__get_relation_last_modified": { "arguments": [], - "created_at": 1696458269.824637, + "created_at": 1719485736.5935519, "depends_on": { "macros": [] }, @@ -2599,22 +3189,20 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro default__get_show_grant_sql(relation) %}\n show grants on {{ relation }}\n{% endmacro %}", + "macro_sql": "{% macro default__get_relation_last_modified(information_schema, relations) %}\n {{ exceptions.raise_not_implemented(\n 'get_relation_last_modified macro not implemented for adapter ' + adapter.type()) }}\n{% endmacro %}", "meta": {}, - "name": "default__get_show_grant_sql", - "original_file_path": "macros/adapters/apply_grants.sql", + "name": "default__get_relation_last_modified", + "original_file_path": "macros/adapters/metadata.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/adapters/apply_grants.sql", + "path": "macros/adapters/metadata.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__get_show_grant_sql" + "unique_id": "macro.dbt.default__get_relation_last_modified" }, - "macro.dbt.default__get_test_sql": { + "macro.dbt.default__get_relations": { "arguments": [], - "created_at": 1696458269.662534, + "created_at": 1719485736.592618, "depends_on": { "macros": [] }, @@ -2623,46 +3211,45 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro default__get_test_sql(main_sql, fail_calc, warn_if, error_if, limit) -%}\n select\n {{ fail_calc }} as failures,\n {{ fail_calc }} {{ warn_if }} as should_warn,\n {{ fail_calc }} {{ error_if }} as should_error\n from (\n {{ main_sql }}\n {{ \"limit \" ~ limit if limit != none }}\n ) dbt_internal_test\n{%- endmacro %}", + "macro_sql": "{% macro default__get_relations() %}\n {{ exceptions.raise_not_implemented(\n 'get_relations macro not implemented for adapter '+adapter.type()) }}\n{% endmacro %}", "meta": {}, - "name": "default__get_test_sql", - "original_file_path": "macros/materializations/tests/helpers.sql", + "name": "default__get_relations", + "original_file_path": "macros/adapters/metadata.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/tests/helpers.sql", + "path": "macros/adapters/metadata.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__get_test_sql" + "unique_id": "macro.dbt.default__get_relations" }, - "macro.dbt.default__get_true_sql": { + "macro.dbt.default__get_rename_intermediate_sql": { "arguments": [], - "created_at": 1696458269.6173341, + "created_at": 1719485736.485399, "depends_on": { - "macros": [] + "macros": [ + "macro.dbt.make_intermediate_relation", + "macro.dbt.get_rename_sql" + ] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro default__get_true_sql() %}\n {{ return('TRUE') }}\n{% endmacro %}", + "macro_sql": "{%- macro default__get_rename_intermediate_sql(relation) -%}\n\n -- get the standard intermediate name\n {% set intermediate_relation = make_intermediate_relation(relation) %}\n\n {{ get_rename_sql(intermediate_relation, relation.identifier) }}\n\n{%- endmacro -%}", "meta": {}, - "name": "default__get_true_sql", - "original_file_path": "macros/materializations/snapshots/helpers.sql", + "name": "default__get_rename_intermediate_sql", + "original_file_path": "macros/relations/rename_intermediate.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/snapshots/helpers.sql", + "path": "macros/relations/rename_intermediate.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__get_true_sql" + "unique_id": "macro.dbt.default__get_rename_intermediate_sql" }, - "macro.dbt.default__get_where_subquery": { + "macro.dbt.default__get_rename_materialized_view_sql": { "arguments": [], - "created_at": 1696458269.6639218, + "created_at": 1719485736.491203, "depends_on": { "macros": [] }, @@ -2671,46 +3258,46 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro default__get_where_subquery(relation) -%}\n {% set where = config.get('where', '') %}\n {% if where %}\n {%- set filtered -%}\n (select * from {{ relation }} where {{ where }}) dbt_subquery\n {%- endset -%}\n {% do return(filtered) %}\n {%- else -%}\n {% do return(relation) %}\n {%- endif -%}\n{%- endmacro %}", + "macro_sql": "{% macro default__get_rename_materialized_view_sql(relation, new_name) %}\n {{ exceptions.raise_compiler_error(\n \"`get_rename_materialized_view_sql` has not been implemented for this adapter.\"\n ) }}\n{% endmacro %}", "meta": {}, - "name": "default__get_where_subquery", - "original_file_path": "macros/materializations/tests/where_subquery.sql", + "name": "default__get_rename_materialized_view_sql", + "original_file_path": "macros/relations/materialized_view/rename.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/tests/where_subquery.sql", + "path": "macros/relations/materialized_view/rename.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__get_where_subquery" + "unique_id": "macro.dbt.default__get_rename_materialized_view_sql" }, - "macro.dbt.default__handle_existing_table": { + "macro.dbt.default__get_rename_sql": { "arguments": [], - "created_at": 1696458269.727338, + "created_at": 1719485736.4781098, "depends_on": { - "macros": [] + "macros": [ + "macro.dbt.get_rename_view_sql", + "macro.dbt.get_rename_table_sql", + "macro.dbt.get_rename_materialized_view_sql" + ] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro default__handle_existing_table(full_refresh, old_relation) %}\n {{ log(\"Dropping relation \" ~ old_relation ~ \" because it is of type \" ~ old_relation.type) }}\n {{ adapter.drop_relation(old_relation) }}\n{% endmacro %}", + "macro_sql": "{%- macro default__get_rename_sql(relation, new_name) -%}\n\n {%- if relation.is_view -%}\n {{ get_rename_view_sql(relation, new_name) }}\n\n {%- elif relation.is_table -%}\n {{ get_rename_table_sql(relation, new_name) }}\n\n {%- elif relation.is_materialized_view -%}\n {{ get_rename_materialized_view_sql(relation, new_name) }}\n\n {%- else -%}\n {{- exceptions.raise_compiler_error(\"`get_rename_sql` has not been implemented for: \" ~ relation.type ) -}}\n\n {%- endif -%}\n\n{%- endmacro -%}\n\n\n", "meta": {}, - "name": "default__handle_existing_table", - "original_file_path": "macros/materializations/models/view/helpers.sql", + "name": "default__get_rename_sql", + "original_file_path": "macros/relations/rename.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/models/view/helpers.sql", + "path": "macros/relations/rename.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__handle_existing_table" + "unique_id": "macro.dbt.default__get_rename_sql" }, - "macro.dbt.default__hash": { + "macro.dbt.default__get_rename_table_sql": { "arguments": [], - "created_at": 1696458269.783129, + "created_at": 1719485736.507071, "depends_on": { "macros": [] }, @@ -2719,22 +3306,20 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro default__hash(field) -%}\n md5(cast({{ field }} as {{ api.Column.translate_type('string') }}))\n{%- endmacro %}", + "macro_sql": "{% macro default__get_rename_table_sql(relation, new_name) %}\n {{ exceptions.raise_compiler_error(\n \"`get_rename_table_sql` has not been implemented for this adapter.\"\n ) }}\n{% endmacro %}", "meta": {}, - "name": "default__hash", - "original_file_path": "macros/utils/hash.sql", + "name": "default__get_rename_table_sql", + "original_file_path": "macros/relations/table/rename.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/utils/hash.sql", + "path": "macros/relations/table/rename.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__hash" + "unique_id": "macro.dbt.default__get_rename_table_sql" }, - "macro.dbt.default__information_schema_name": { + "macro.dbt.default__get_rename_view_sql": { "arguments": [], - "created_at": 1696458269.8378282, + "created_at": 1719485736.514697, "depends_on": { "macros": [] }, @@ -2743,22 +3328,20 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro default__information_schema_name(database) -%}\n {%- if database -%}\n {{ database }}.INFORMATION_SCHEMA\n {%- else -%}\n INFORMATION_SCHEMA\n {%- endif -%}\n{%- endmacro %}", + "macro_sql": "{% macro default__get_rename_view_sql(relation, new_name) %}\n {{ exceptions.raise_compiler_error(\n \"`get_rename_view_sql` has not been implemented for this adapter.\"\n ) }}\n{% endmacro %}", "meta": {}, - "name": "default__information_schema_name", - "original_file_path": "macros/adapters/metadata.sql", + "name": "default__get_rename_view_sql", + "original_file_path": "macros/relations/view/rename.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/adapters/metadata.sql", + "path": "macros/relations/view/rename.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__information_schema_name" + "unique_id": "macro.dbt.default__get_rename_view_sql" }, - "macro.dbt.default__intersect": { + "macro.dbt.default__get_replace_materialized_view_sql": { "arguments": [], - "created_at": 1696458269.776896, + "created_at": 1719485736.4885828, "depends_on": { "macros": [] }, @@ -2767,25 +3350,31 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro default__intersect() %}\n\n intersect\n\n{% endmacro %}", + "macro_sql": "{% macro default__get_replace_materialized_view_sql(relation, sql) %}\n {{ exceptions.raise_compiler_error(\n \"`get_replace_materialized_view_sql` has not been implemented for this adapter.\"\n ) }}\n{% endmacro %}", "meta": {}, - "name": "default__intersect", - "original_file_path": "macros/utils/intersect.sql", + "name": "default__get_replace_materialized_view_sql", + "original_file_path": "macros/relations/materialized_view/replace.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/utils/intersect.sql", + "path": "macros/relations/materialized_view/replace.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__intersect" + "unique_id": "macro.dbt.default__get_replace_materialized_view_sql" }, - "macro.dbt.default__last_day": { + "macro.dbt.default__get_replace_sql": { "arguments": [], - "created_at": 1696458269.794904, + "created_at": 1719485736.475151, "depends_on": { "macros": [ - "macro.dbt.default_last_day" + "macro.dbt.get_replace_view_sql", + "macro.dbt.get_replace_table_sql", + "macro.dbt.get_replace_materialized_view_sql", + "macro.dbt.get_create_intermediate_sql", + "macro.dbt.get_create_backup_sql", + "macro.dbt.get_rename_intermediate_sql", + "macro.dbt.get_drop_backup_sql", + "macro.dbt.get_drop_sql", + "macro.dbt.get_create_sql" ] }, "description": "", @@ -2793,22 +3382,20 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro default__last_day(date, datepart) -%}\n {{dbt.default_last_day(date, datepart)}}\n{%- endmacro %}", + "macro_sql": "{% macro default__get_replace_sql(existing_relation, target_relation, sql) %}\n\n {# /* use a create or replace statement if possible */ #}\n\n {% set is_replaceable = existing_relation.type == target_relation_type and existing_relation.can_be_replaced %}\n\n {% if is_replaceable and existing_relation.is_view %}\n {{ get_replace_view_sql(target_relation, sql) }}\n\n {% elif is_replaceable and existing_relation.is_table %}\n {{ get_replace_table_sql(target_relation, sql) }}\n\n {% elif is_replaceable and existing_relation.is_materialized_view %}\n {{ get_replace_materialized_view_sql(target_relation, sql) }}\n\n {# /* a create or replace statement is not possible, so try to stage and/or backup to be safe */ #}\n\n {# /* create target_relation as an intermediate relation, then swap it out with the existing one using a backup */ #}\n {%- elif target_relation.can_be_renamed and existing_relation.can_be_renamed -%}\n {{ get_create_intermediate_sql(target_relation, sql) }};\n {{ get_create_backup_sql(existing_relation) }};\n {{ get_rename_intermediate_sql(target_relation) }};\n {{ get_drop_backup_sql(existing_relation) }}\n\n {# /* create target_relation as an intermediate relation, then swap it out with the existing one without using a backup */ #}\n {%- elif target_relation.can_be_renamed -%}\n {{ get_create_intermediate_sql(target_relation, sql) }};\n {{ get_drop_sql(existing_relation) }};\n {{ get_rename_intermediate_sql(target_relation) }}\n\n {# /* create target_relation in place by first backing up the existing relation */ #}\n {%- elif existing_relation.can_be_renamed -%}\n {{ get_create_backup_sql(existing_relation) }};\n {{ get_create_sql(target_relation, sql) }};\n {{ get_drop_backup_sql(existing_relation) }}\n\n {# /* no renaming is allowed, so just drop and create */ #}\n {%- else -%}\n {{ get_drop_sql(existing_relation) }};\n {{ get_create_sql(target_relation, sql) }}\n\n {%- endif -%}\n\n{% endmacro %}", "meta": {}, - "name": "default__last_day", - "original_file_path": "macros/utils/last_day.sql", + "name": "default__get_replace_sql", + "original_file_path": "macros/relations/replace.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/utils/last_day.sql", + "path": "macros/relations/replace.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__last_day" + "unique_id": "macro.dbt.default__get_replace_sql" }, - "macro.dbt.default__length": { + "macro.dbt.default__get_replace_table_sql": { "arguments": [], - "created_at": 1696458269.7753239, + "created_at": 1719485736.506342, "depends_on": { "macros": [] }, @@ -2817,22 +3404,20 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro default__length(expression) %}\n\n length(\n {{ expression }}\n )\n\n{%- endmacro -%}", + "macro_sql": "{% macro default__get_replace_table_sql(relation, sql) %}\n {{ exceptions.raise_compiler_error(\n \"`get_replace_table_sql` has not been implemented for this adapter.\"\n ) }}\n{% endmacro %}", "meta": {}, - "name": "default__length", - "original_file_path": "macros/utils/length.sql", + "name": "default__get_replace_table_sql", + "original_file_path": "macros/relations/table/replace.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/utils/length.sql", + "path": "macros/relations/table/replace.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__length" + "unique_id": "macro.dbt.default__get_replace_table_sql" }, - "macro.dbt.default__list_relations_without_caching": { + "macro.dbt.default__get_replace_view_sql": { "arguments": [], - "created_at": 1696458269.839978, + "created_at": 1719485736.512308, "depends_on": { "macros": [] }, @@ -2841,101 +3426,88 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro default__list_relations_without_caching(schema_relation) %}\n {{ exceptions.raise_not_implemented(\n 'list_relations_without_caching macro not implemented for adapter '+adapter.type()) }}\n{% endmacro %}", + "macro_sql": "{% macro default__get_replace_view_sql(relation, sql) %}\n {{ exceptions.raise_compiler_error(\n \"`get_replace_view_sql` has not been implemented for this adapter.\"\n ) }}\n{% endmacro %}", "meta": {}, - "name": "default__list_relations_without_caching", - "original_file_path": "macros/adapters/metadata.sql", + "name": "default__get_replace_view_sql", + "original_file_path": "macros/relations/view/replace.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/adapters/metadata.sql", + "path": "macros/relations/view/replace.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__list_relations_without_caching" + "unique_id": "macro.dbt.default__get_replace_view_sql" }, - "macro.dbt.default__list_schemas": { + "macro.dbt.default__get_revoke_sql": { "arguments": [], - "created_at": 1696458269.83854, + "created_at": 1719485736.574667, "depends_on": { - "macros": [ - "macro.dbt.information_schema_name", - "macro.dbt.run_query" - ] + "macros": [] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro default__list_schemas(database) -%}\n {% set sql %}\n select distinct schema_name\n from {{ information_schema_name(database) }}.SCHEMATA\n where catalog_name ilike '{{ database }}'\n {% endset %}\n {{ return(run_query(sql)) }}\n{% endmacro %}", + "macro_sql": "\n\n{%- macro default__get_revoke_sql(relation, privilege, grantees) -%}\n revoke {{ privilege }} on {{ relation }} from {{ grantees | join(', ') }}\n{%- endmacro -%}\n\n\n", "meta": {}, - "name": "default__list_schemas", - "original_file_path": "macros/adapters/metadata.sql", + "name": "default__get_revoke_sql", + "original_file_path": "macros/adapters/apply_grants.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/adapters/metadata.sql", + "path": "macros/adapters/apply_grants.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__list_schemas" + "unique_id": "macro.dbt.default__get_revoke_sql" }, - "macro.dbt.default__listagg": { + "macro.dbt.default__get_select_subquery": { "arguments": [], - "created_at": 1696458269.780426, + "created_at": 1719485736.510467, "depends_on": { - "macros": [] + "macros": [ + "macro.dbt.default__get_column_names" + ] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro default__listagg(measure, delimiter_text, order_by_clause, limit_num) -%}\n\n {% if limit_num -%}\n array_to_string(\n array_slice(\n array_agg(\n {{ measure }}\n ){% if order_by_clause -%}\n within group ({{ order_by_clause }})\n {%- endif %}\n ,0\n ,{{ limit_num }}\n ),\n {{ delimiter_text }}\n )\n {%- else %}\n listagg(\n {{ measure }},\n {{ delimiter_text }}\n )\n {% if order_by_clause -%}\n within group ({{ order_by_clause }})\n {%- endif %}\n {%- endif %}\n\n{%- endmacro %}", + "macro_sql": "{% macro default__get_select_subquery(sql) %}\n select {{ adapter.dispatch('get_column_names', 'dbt')() }}\n from (\n {{ sql }}\n ) as model_subq\n{%- endmacro %}", "meta": {}, - "name": "default__listagg", - "original_file_path": "macros/utils/listagg.sql", + "name": "default__get_select_subquery", + "original_file_path": "macros/relations/table/create.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/utils/listagg.sql", + "path": "macros/relations/table/create.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__listagg" + "unique_id": "macro.dbt.default__get_select_subquery" }, - "macro.dbt.default__load_csv_rows": { + "macro.dbt.default__get_show_grant_sql": { "arguments": [], - "created_at": 1696458269.753568, + "created_at": 1719485736.573777, "depends_on": { - "macros": [ - "macro.dbt.get_batch_size", - "macro.dbt.get_seed_column_quoted_csv", - "macro.dbt.get_binding_char" - ] + "macros": [] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro default__load_csv_rows(model, agate_table) %}\n\n {% set batch_size = get_batch_size() %}\n\n {% set cols_sql = get_seed_column_quoted_csv(model, agate_table.column_names) %}\n {% set bindings = [] %}\n\n {% set statements = [] %}\n\n {% for chunk in agate_table.rows | batch(batch_size) %}\n {% set bindings = [] %}\n\n {% for row in chunk %}\n {% do bindings.extend(row) %}\n {% endfor %}\n\n {% set sql %}\n insert into {{ this.render() }} ({{ cols_sql }}) values\n {% for row in chunk -%}\n ({%- for column in agate_table.column_names -%}\n {{ get_binding_char() }}\n {%- if not loop.last%},{%- endif %}\n {%- endfor -%})\n {%- if not loop.last%},{%- endif %}\n {%- endfor %}\n {% endset %}\n\n {% do adapter.add_query(sql, bindings=bindings, abridge_sql_log=True) %}\n\n {% if loop.index0 == 0 %}\n {% do statements.append(sql) %}\n {% endif %}\n {% endfor %}\n\n {# Return SQL so we can render it out into the compiled files #}\n {{ return(statements[0]) }}\n{% endmacro %}", + "macro_sql": "{% macro default__get_show_grant_sql(relation) %}\n show grants on {{ relation }}\n{% endmacro %}", "meta": {}, - "name": "default__load_csv_rows", - "original_file_path": "macros/materializations/seeds/helpers.sql", + "name": "default__get_show_grant_sql", + "original_file_path": "macros/adapters/apply_grants.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/seeds/helpers.sql", + "path": "macros/adapters/apply_grants.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__load_csv_rows" + "unique_id": "macro.dbt.default__get_show_grant_sql" }, - "macro.dbt.default__make_backup_relation": { + "macro.dbt.default__get_show_indexes_sql": { "arguments": [], - "created_at": 1696458269.814139, + "created_at": 1719485736.563092, "depends_on": { "macros": [] }, @@ -2944,25 +3516,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro default__make_backup_relation(base_relation, backup_relation_type, suffix) %}\n {%- set backup_identifier = base_relation.identifier ~ suffix -%}\n {%- set backup_relation = base_relation.incorporate(\n path={\"identifier\": backup_identifier},\n type=backup_relation_type\n ) -%}\n {{ return(backup_relation) }}\n{% endmacro %}", + "macro_sql": "{% macro default__get_show_indexes_sql(relation) -%}\n {{ exceptions.raise_compiler_error(\"`get_show_indexes_sql has not been implemented for this adapter.\") }}\n{%- endmacro %}", "meta": {}, - "name": "default__make_backup_relation", - "original_file_path": "macros/adapters/relation.sql", + "name": "default__get_show_indexes_sql", + "original_file_path": "macros/adapters/indexes.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/adapters/relation.sql", + "path": "macros/adapters/indexes.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__make_backup_relation" + "unique_id": "macro.dbt.default__get_show_indexes_sql" }, - "macro.dbt.default__make_intermediate_relation": { + "macro.dbt.default__get_table_columns_and_constraints": { "arguments": [], - "created_at": 1696458269.812382, + "created_at": 1719485736.497637, "depends_on": { "macros": [ - "macro.dbt.default__make_temp_relation" + "macro.dbt.table_columns_and_constraints" ] }, "description": "", @@ -2970,22 +3540,20 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro default__make_intermediate_relation(base_relation, suffix) %}\n {{ return(default__make_temp_relation(base_relation, suffix)) }}\n{% endmacro %}", + "macro_sql": "{% macro default__get_table_columns_and_constraints() -%}\n {{ return(table_columns_and_constraints()) }}\n{%- endmacro %}", "meta": {}, - "name": "default__make_intermediate_relation", - "original_file_path": "macros/adapters/relation.sql", + "name": "default__get_table_columns_and_constraints", + "original_file_path": "macros/relations/column/columns_spec_ddl.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/adapters/relation.sql", + "path": "macros/relations/column/columns_spec_ddl.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__make_intermediate_relation" + "unique_id": "macro.dbt.default__get_table_columns_and_constraints" }, - "macro.dbt.default__make_temp_relation": { + "macro.dbt.default__get_test_sql": { "arguments": [], - "created_at": 1696458269.8132212, + "created_at": 1719485736.372639, "depends_on": { "macros": [] }, @@ -2994,74 +3562,66 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro default__make_temp_relation(base_relation, suffix) %}\n {%- set temp_identifier = base_relation.identifier ~ suffix -%}\n {%- set temp_relation = base_relation.incorporate(\n path={\"identifier\": temp_identifier}) -%}\n\n {{ return(temp_relation) }}\n{% endmacro %}", + "macro_sql": "{% macro default__get_test_sql(main_sql, fail_calc, warn_if, error_if, limit) -%}\n select\n {{ fail_calc }} as failures,\n {{ fail_calc }} {{ warn_if }} as should_warn,\n {{ fail_calc }} {{ error_if }} as should_error\n from (\n {{ main_sql }}\n {{ \"limit \" ~ limit if limit != none }}\n ) dbt_internal_test\n{%- endmacro %}", "meta": {}, - "name": "default__make_temp_relation", - "original_file_path": "macros/adapters/relation.sql", + "name": "default__get_test_sql", + "original_file_path": "macros/materializations/tests/helpers.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/adapters/relation.sql", + "path": "macros/materializations/tests/helpers.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__make_temp_relation" + "unique_id": "macro.dbt.default__get_test_sql" }, - "macro.dbt.default__persist_docs": { + "macro.dbt.default__get_true_sql": { "arguments": [], - "created_at": 1696458269.834239, + "created_at": 1719485736.3555431, "depends_on": { - "macros": [ - "macro.dbt.run_query", - "macro.dbt.alter_relation_comment", - "macro.dbt.alter_column_comment" - ] + "macros": [] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro default__persist_docs(relation, model, for_relation, for_columns) -%}\n {% if for_relation and config.persist_relation_docs() and model.description %}\n {% do run_query(alter_relation_comment(relation, model.description)) %}\n {% endif %}\n\n {% if for_columns and config.persist_column_docs() and model.columns %}\n {% do run_query(alter_column_comment(relation, model.columns)) %}\n {% endif %}\n{% endmacro %}", + "macro_sql": "{% macro default__get_true_sql() %}\n {{ return('TRUE') }}\n{% endmacro %}", "meta": {}, - "name": "default__persist_docs", - "original_file_path": "macros/adapters/persist_docs.sql", + "name": "default__get_true_sql", + "original_file_path": "macros/materializations/snapshots/helpers.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/adapters/persist_docs.sql", + "path": "macros/materializations/snapshots/helpers.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__persist_docs" + "unique_id": "macro.dbt.default__get_true_sql" }, - "macro.dbt.default__position": { + "macro.dbt.default__get_unit_test_sql": { "arguments": [], - "created_at": 1696458269.785729, + "created_at": 1719485736.3736708, "depends_on": { - "macros": [] + "macros": [ + "macro.dbt.string_literal" + ] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro default__position(substring_text, string_text) %}\n\n position(\n {{ substring_text }} in {{ string_text }}\n )\n\n{%- endmacro -%}", + "macro_sql": "{% macro default__get_unit_test_sql(main_sql, expected_fixture_sql, expected_column_names) -%}\n-- Build actual result given inputs\nwith dbt_internal_unit_test_actual as (\n select\n {% for expected_column_name in expected_column_names %}{{expected_column_name}}{% if not loop.last -%},{% endif %}{%- endfor -%}, {{ dbt.string_literal(\"actual\") }} as {{ adapter.quote(\"actual_or_expected\") }}\n from (\n {{ main_sql }}\n ) _dbt_internal_unit_test_actual\n),\n-- Build expected result\ndbt_internal_unit_test_expected as (\n select\n {% for expected_column_name in expected_column_names %}{{expected_column_name}}{% if not loop.last -%}, {% endif %}{%- endfor -%}, {{ dbt.string_literal(\"expected\") }} as {{ adapter.quote(\"actual_or_expected\") }}\n from (\n {{ expected_fixture_sql }}\n ) _dbt_internal_unit_test_expected\n)\n-- Union actual and expected results\nselect * from dbt_internal_unit_test_actual\nunion all\nselect * from dbt_internal_unit_test_expected\n{%- endmacro %}", "meta": {}, - "name": "default__position", - "original_file_path": "macros/utils/position.sql", + "name": "default__get_unit_test_sql", + "original_file_path": "macros/materializations/tests/helpers.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/utils/position.sql", + "path": "macros/materializations/tests/helpers.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__position" + "unique_id": "macro.dbt.default__get_unit_test_sql" }, - "macro.dbt.default__post_snapshot": { + "macro.dbt.default__get_where_subquery": { "arguments": [], - "created_at": 1696458269.6169112, + "created_at": 1719485736.374502, "depends_on": { "macros": [] }, @@ -3070,48 +3630,42 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro default__post_snapshot(staging_relation) %}\n {# no-op #}\n{% endmacro %}", + "macro_sql": "{% macro default__get_where_subquery(relation) -%}\n {% set where = config.get('where', '') %}\n {% if where %}\n {%- set filtered -%}\n (select * from {{ relation }} where {{ where }}) dbt_subquery\n {%- endset -%}\n {% do return(filtered) %}\n {%- else -%}\n {% do return(relation) %}\n {%- endif -%}\n{%- endmacro %}", "meta": {}, - "name": "default__post_snapshot", - "original_file_path": "macros/materializations/snapshots/helpers.sql", + "name": "default__get_where_subquery", + "original_file_path": "macros/materializations/tests/where_subquery.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/snapshots/helpers.sql", + "path": "macros/materializations/tests/where_subquery.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__post_snapshot" + "unique_id": "macro.dbt.default__get_where_subquery" }, - "macro.dbt.default__rename_relation": { + "macro.dbt.default__handle_existing_table": { "arguments": [], - "created_at": 1696458269.8161411, + "created_at": 1719485736.514176, "depends_on": { - "macros": [ - "macro.dbt.statement" - ] + "macros": [] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro default__rename_relation(from_relation, to_relation) -%}\n {% set target_name = adapter.quote_as_configured(to_relation.identifier, 'identifier') %}\n {% call statement('rename_relation') -%}\n alter table {{ from_relation }} rename to {{ target_name }}\n {%- endcall %}\n{% endmacro %}", + "macro_sql": "{% macro default__handle_existing_table(full_refresh, old_relation) %}\n {{ log(\"Dropping relation \" ~ old_relation ~ \" because it is of type \" ~ old_relation.type) }}\n {{ adapter.drop_relation(old_relation) }}\n{% endmacro %}", "meta": {}, - "name": "default__rename_relation", - "original_file_path": "macros/adapters/relation.sql", + "name": "default__handle_existing_table", + "original_file_path": "macros/relations/view/replace.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/adapters/relation.sql", + "path": "macros/relations/view/replace.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__rename_relation" + "unique_id": "macro.dbt.default__handle_existing_table" }, - "macro.dbt.default__replace": { + "macro.dbt.default__hash": { "arguments": [], - "created_at": 1696458269.77388, + "created_at": 1719485736.541434, "depends_on": { "macros": [] }, @@ -3120,48 +3674,42 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro default__replace(field, old_chars, new_chars) %}\n\n replace(\n {{ field }},\n {{ old_chars }},\n {{ new_chars }}\n )\n\n\n{% endmacro %}", + "macro_sql": "{% macro default__hash(field) -%}\n md5(cast({{ field }} as {{ api.Column.translate_type('string') }}))\n{%- endmacro %}", "meta": {}, - "name": "default__replace", - "original_file_path": "macros/utils/replace.sql", + "name": "default__hash", + "original_file_path": "macros/utils/hash.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/utils/replace.sql", + "path": "macros/utils/hash.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__replace" + "unique_id": "macro.dbt.default__hash" }, - "macro.dbt.default__reset_csv_table": { + "macro.dbt.default__information_schema_name": { "arguments": [], - "created_at": 1696458269.748949, + "created_at": 1719485736.590823, "depends_on": { - "macros": [ - "macro.dbt.create_csv_table" - ] + "macros": [] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro default__reset_csv_table(model, full_refresh, old_relation, agate_table) %}\n {% set sql = \"\" %}\n {% if full_refresh %}\n {{ adapter.drop_relation(old_relation) }}\n {% set sql = create_csv_table(model, agate_table) %}\n {% else %}\n {{ adapter.truncate_relation(old_relation) }}\n {% set sql = \"truncate table \" ~ old_relation %}\n {% endif %}\n\n {{ return(sql) }}\n{% endmacro %}", + "macro_sql": "{% macro default__information_schema_name(database) -%}\n {%- if database -%}\n {{ database }}.INFORMATION_SCHEMA\n {%- else -%}\n INFORMATION_SCHEMA\n {%- endif -%}\n{%- endmacro %}", "meta": {}, - "name": "default__reset_csv_table", - "original_file_path": "macros/materializations/seeds/helpers.sql", + "name": "default__information_schema_name", + "original_file_path": "macros/adapters/metadata.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/seeds/helpers.sql", + "path": "macros/adapters/metadata.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__reset_csv_table" + "unique_id": "macro.dbt.default__information_schema_name" }, - "macro.dbt.default__right": { + "macro.dbt.default__intersect": { "arguments": [], - "created_at": 1696458269.778592, + "created_at": 1719485736.537451, "depends_on": { "macros": [] }, @@ -3170,72 +3718,66 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro default__right(string_text, length_expression) %}\n\n right(\n {{ string_text }},\n {{ length_expression }}\n )\n\n{%- endmacro -%}", + "macro_sql": "{% macro default__intersect() %}\n\n intersect\n\n{% endmacro %}", "meta": {}, - "name": "default__right", - "original_file_path": "macros/utils/right.sql", + "name": "default__intersect", + "original_file_path": "macros/utils/intersect.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/utils/right.sql", + "path": "macros/utils/intersect.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__right" + "unique_id": "macro.dbt.default__intersect" }, - "macro.dbt.default__safe_cast": { + "macro.dbt.default__last_day": { "arguments": [], - "created_at": 1696458269.782259, + "created_at": 1719485736.55349, "depends_on": { - "macros": [] + "macros": [ + "macro.dbt.default_last_day" + ] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro default__safe_cast(field, type) %}\n {# most databases don't support this function yet\n so we just need to use cast #}\n cast({{field}} as {{type}})\n{% endmacro %}", + "macro_sql": "{% macro default__last_day(date, datepart) -%}\n {{dbt.default_last_day(date, datepart)}}\n{%- endmacro %}", "meta": {}, - "name": "default__safe_cast", - "original_file_path": "macros/utils/safe_cast.sql", + "name": "default__last_day", + "original_file_path": "macros/utils/last_day.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/utils/safe_cast.sql", + "path": "macros/utils/last_day.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__safe_cast" + "unique_id": "macro.dbt.default__last_day" }, - "macro.dbt.default__snapshot_get_time": { + "macro.dbt.default__length": { "arguments": [], - "created_at": 1696458269.8029382, + "created_at": 1719485736.536426, "depends_on": { - "macros": [ - "macro.dbt.current_timestamp" - ] + "macros": [] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro default__snapshot_get_time() %}\n {{ current_timestamp() }}\n{% endmacro %}", + "macro_sql": "{% macro default__length(expression) %}\n\n length(\n {{ expression }}\n )\n\n{%- endmacro -%}", "meta": {}, - "name": "default__snapshot_get_time", - "original_file_path": "macros/adapters/timestamps.sql", + "name": "default__length", + "original_file_path": "macros/utils/length.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/adapters/timestamps.sql", + "path": "macros/utils/length.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__snapshot_get_time" + "unique_id": "macro.dbt.default__length" }, - "macro.dbt.default__snapshot_hash_arguments": { + "macro.dbt.default__list_relations_without_caching": { "arguments": [], - "created_at": 1696458269.601996, + "created_at": 1719485736.592287, "depends_on": { "macros": [] }, @@ -3244,96 +3786,93 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro default__snapshot_hash_arguments(args) -%}\n md5({%- for arg in args -%}\n coalesce(cast({{ arg }} as varchar ), '')\n {% if not loop.last %} || '|' || {% endif %}\n {%- endfor -%})\n{%- endmacro %}", + "macro_sql": "{% macro default__list_relations_without_caching(schema_relation) %}\n {{ exceptions.raise_not_implemented(\n 'list_relations_without_caching macro not implemented for adapter '+adapter.type()) }}\n{% endmacro %}", "meta": {}, - "name": "default__snapshot_hash_arguments", - "original_file_path": "macros/materializations/snapshots/strategies.sql", + "name": "default__list_relations_without_caching", + "original_file_path": "macros/adapters/metadata.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/snapshots/strategies.sql", + "path": "macros/adapters/metadata.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__snapshot_hash_arguments" + "unique_id": "macro.dbt.default__list_relations_without_caching" }, - "macro.dbt.default__snapshot_merge_sql": { + "macro.dbt.default__list_schemas": { "arguments": [], - "created_at": 1696458269.595372, + "created_at": 1719485736.591269, "depends_on": { - "macros": [] + "macros": [ + "macro.dbt.information_schema_name", + "macro.dbt.run_query" + ] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro default__snapshot_merge_sql(target, source, insert_cols) -%}\n {%- set insert_cols_csv = insert_cols | join(', ') -%}\n\n merge into {{ target }} as DBT_INTERNAL_DEST\n using {{ source }} as DBT_INTERNAL_SOURCE\n on DBT_INTERNAL_SOURCE.dbt_scd_id = DBT_INTERNAL_DEST.dbt_scd_id\n\n when matched\n and DBT_INTERNAL_DEST.dbt_valid_to is null\n and DBT_INTERNAL_SOURCE.dbt_change_type in ('update', 'delete')\n then update\n set dbt_valid_to = DBT_INTERNAL_SOURCE.dbt_valid_to\n\n when not matched\n and DBT_INTERNAL_SOURCE.dbt_change_type = 'insert'\n then insert ({{ insert_cols_csv }})\n values ({{ insert_cols_csv }})\n\n{% endmacro %}", + "macro_sql": "{% macro default__list_schemas(database) -%}\n {% set sql %}\n select distinct schema_name\n from {{ information_schema_name(database) }}.SCHEMATA\n where catalog_name ilike '{{ database }}'\n {% endset %}\n {{ return(run_query(sql)) }}\n{% endmacro %}", "meta": {}, - "name": "default__snapshot_merge_sql", - "original_file_path": "macros/materializations/snapshots/snapshot_merge.sql", + "name": "default__list_schemas", + "original_file_path": "macros/adapters/metadata.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/snapshots/snapshot_merge.sql", + "path": "macros/adapters/metadata.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__snapshot_merge_sql" + "unique_id": "macro.dbt.default__list_schemas" }, - "macro.dbt.default__snapshot_staging_table": { + "macro.dbt.default__listagg": { "arguments": [], - "created_at": 1696458269.619116, + "created_at": 1719485736.539655, "depends_on": { - "macros": [ - "macro.dbt.snapshot_get_time" - ] + "macros": [] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro default__snapshot_staging_table(strategy, source_sql, target_relation) -%}\n\n with snapshot_query as (\n\n {{ source_sql }}\n\n ),\n\n snapshotted_data as (\n\n select *,\n {{ strategy.unique_key }} as dbt_unique_key\n\n from {{ target_relation }}\n where dbt_valid_to is null\n\n ),\n\n insertions_source_data as (\n\n select\n *,\n {{ strategy.unique_key }} as dbt_unique_key,\n {{ strategy.updated_at }} as dbt_updated_at,\n {{ strategy.updated_at }} as dbt_valid_from,\n nullif({{ strategy.updated_at }}, {{ strategy.updated_at }}) as dbt_valid_to,\n {{ strategy.scd_id }} as dbt_scd_id\n\n from snapshot_query\n ),\n\n updates_source_data as (\n\n select\n *,\n {{ strategy.unique_key }} as dbt_unique_key,\n {{ strategy.updated_at }} as dbt_updated_at,\n {{ strategy.updated_at }} as dbt_valid_from,\n {{ strategy.updated_at }} as dbt_valid_to\n\n from snapshot_query\n ),\n\n {%- if strategy.invalidate_hard_deletes %}\n\n deletes_source_data as (\n\n select\n *,\n {{ strategy.unique_key }} as dbt_unique_key\n from snapshot_query\n ),\n {% endif %}\n\n insertions as (\n\n select\n 'insert' as dbt_change_type,\n source_data.*\n\n from insertions_source_data as source_data\n left outer join snapshotted_data on snapshotted_data.dbt_unique_key = source_data.dbt_unique_key\n where snapshotted_data.dbt_unique_key is null\n or (\n snapshotted_data.dbt_unique_key is not null\n and (\n {{ strategy.row_changed }}\n )\n )\n\n ),\n\n updates as (\n\n select\n 'update' as dbt_change_type,\n source_data.*,\n snapshotted_data.dbt_scd_id\n\n from updates_source_data as source_data\n join snapshotted_data on snapshotted_data.dbt_unique_key = source_data.dbt_unique_key\n where (\n {{ strategy.row_changed }}\n )\n )\n\n {%- if strategy.invalidate_hard_deletes -%}\n ,\n\n deletes as (\n\n select\n 'delete' as dbt_change_type,\n source_data.*,\n {{ snapshot_get_time() }} as dbt_valid_from,\n {{ snapshot_get_time() }} as dbt_updated_at,\n {{ snapshot_get_time() }} as dbt_valid_to,\n snapshotted_data.dbt_scd_id\n\n from snapshotted_data\n left join deletes_source_data as source_data on snapshotted_data.dbt_unique_key = source_data.dbt_unique_key\n where source_data.dbt_unique_key is null\n )\n {%- endif %}\n\n select * from insertions\n union all\n select * from updates\n {%- if strategy.invalidate_hard_deletes %}\n union all\n select * from deletes\n {%- endif %}\n\n{%- endmacro %}", + "macro_sql": "{% macro default__listagg(measure, delimiter_text, order_by_clause, limit_num) -%}\n\n {% if limit_num -%}\n array_to_string(\n array_slice(\n array_agg(\n {{ measure }}\n ){% if order_by_clause -%}\n within group ({{ order_by_clause }})\n {%- endif %}\n ,0\n ,{{ limit_num }}\n ),\n {{ delimiter_text }}\n )\n {%- else %}\n listagg(\n {{ measure }},\n {{ delimiter_text }}\n )\n {% if order_by_clause -%}\n within group ({{ order_by_clause }})\n {%- endif %}\n {%- endif %}\n\n{%- endmacro %}", "meta": {}, - "name": "default__snapshot_staging_table", - "original_file_path": "macros/materializations/snapshots/helpers.sql", + "name": "default__listagg", + "original_file_path": "macros/utils/listagg.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/snapshots/helpers.sql", + "path": "macros/utils/listagg.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__snapshot_staging_table" + "unique_id": "macro.dbt.default__listagg" }, - "macro.dbt.default__snapshot_string_as_time": { + "macro.dbt.default__load_csv_rows": { "arguments": [], - "created_at": 1696458269.60375, + "created_at": 1719485736.465408, "depends_on": { - "macros": [] + "macros": [ + "macro.dbt.get_batch_size", + "macro.dbt.get_seed_column_quoted_csv", + "macro.dbt.get_binding_char" + ] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro default__snapshot_string_as_time(timestamp) %}\n {% do exceptions.raise_not_implemented(\n 'snapshot_string_as_time macro not implemented for adapter '+adapter.type()\n ) %}\n{% endmacro %}", + "macro_sql": "{% macro default__load_csv_rows(model, agate_table) %}\n\n {% set batch_size = get_batch_size() %}\n\n {% set cols_sql = get_seed_column_quoted_csv(model, agate_table.column_names) %}\n {% set bindings = [] %}\n\n {% set statements = [] %}\n\n {% for chunk in agate_table.rows | batch(batch_size) %}\n {% set bindings = [] %}\n\n {% for row in chunk %}\n {% do bindings.extend(row) %}\n {% endfor %}\n\n {% set sql %}\n insert into {{ this.render() }} ({{ cols_sql }}) values\n {% for row in chunk -%}\n ({%- for column in agate_table.column_names -%}\n {{ get_binding_char() }}\n {%- if not loop.last%},{%- endif %}\n {%- endfor -%})\n {%- if not loop.last%},{%- endif %}\n {%- endfor %}\n {% endset %}\n\n {% do adapter.add_query(sql, bindings=bindings, abridge_sql_log=True) %}\n\n {% if loop.index0 == 0 %}\n {% do statements.append(sql) %}\n {% endif %}\n {% endfor %}\n\n {# Return SQL so we can render it out into the compiled files #}\n {{ return(statements[0]) }}\n{% endmacro %}", "meta": {}, - "name": "default__snapshot_string_as_time", - "original_file_path": "macros/materializations/snapshots/strategies.sql", + "name": "default__load_csv_rows", + "original_file_path": "macros/materializations/seeds/helpers.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/snapshots/strategies.sql", + "path": "macros/materializations/seeds/helpers.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__snapshot_string_as_time" + "unique_id": "macro.dbt.default__load_csv_rows" }, - "macro.dbt.default__split_part": { + "macro.dbt.default__make_backup_relation": { "arguments": [], - "created_at": 1696458269.796368, + "created_at": 1719485736.5670989, "depends_on": { "macros": [] }, @@ -3342,46 +3881,44 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro default__split_part(string_text, delimiter_text, part_number) %}\n\n split_part(\n {{ string_text }},\n {{ delimiter_text }},\n {{ part_number }}\n )\n\n{% endmacro %}", + "macro_sql": "{% macro default__make_backup_relation(base_relation, backup_relation_type, suffix) %}\n {%- set backup_identifier = base_relation.identifier ~ suffix -%}\n {%- set backup_relation = base_relation.incorporate(\n path={\"identifier\": backup_identifier},\n type=backup_relation_type\n ) -%}\n {{ return(backup_relation) }}\n{% endmacro %}", "meta": {}, - "name": "default__split_part", - "original_file_path": "macros/utils/split_part.sql", + "name": "default__make_backup_relation", + "original_file_path": "macros/adapters/relation.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/utils/split_part.sql", + "path": "macros/adapters/relation.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__split_part" + "unique_id": "macro.dbt.default__make_backup_relation" }, - "macro.dbt.default__string_literal": { + "macro.dbt.default__make_intermediate_relation": { "arguments": [], - "created_at": 1696458269.786437, + "created_at": 1719485736.56591, "depends_on": { - "macros": [] + "macros": [ + "macro.dbt.default__make_temp_relation" + ] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro default__string_literal(value) -%}\n '{{ value }}'\n{%- endmacro %}", + "macro_sql": "{% macro default__make_intermediate_relation(base_relation, suffix) %}\n {{ return(default__make_temp_relation(base_relation, suffix)) }}\n{% endmacro %}", "meta": {}, - "name": "default__string_literal", - "original_file_path": "macros/utils/literal.sql", + "name": "default__make_intermediate_relation", + "original_file_path": "macros/adapters/relation.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/utils/literal.sql", + "path": "macros/adapters/relation.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__string_literal" + "unique_id": "macro.dbt.default__make_intermediate_relation" }, - "macro.dbt.default__support_multiple_grantees_per_dcl_statement": { + "macro.dbt.default__make_temp_relation": { "arguments": [], - "created_at": 1696458269.823607, + "created_at": 1719485736.566462, "depends_on": { "macros": [] }, @@ -3390,72 +3927,68 @@ "node_color": null, "show": true }, - "macro_sql": "\n\n{%- macro default__support_multiple_grantees_per_dcl_statement() -%}\n {{ return(True) }}\n{%- endmacro -%}\n\n\n", + "macro_sql": "{% macro default__make_temp_relation(base_relation, suffix) %}\n {%- set temp_identifier = base_relation.identifier ~ suffix -%}\n {%- set temp_relation = base_relation.incorporate(\n path={\"identifier\": temp_identifier}) -%}\n\n {{ return(temp_relation) }}\n{% endmacro %}", "meta": {}, - "name": "default__support_multiple_grantees_per_dcl_statement", - "original_file_path": "macros/adapters/apply_grants.sql", + "name": "default__make_temp_relation", + "original_file_path": "macros/adapters/relation.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/adapters/apply_grants.sql", + "path": "macros/adapters/relation.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__support_multiple_grantees_per_dcl_statement" + "unique_id": "macro.dbt.default__make_temp_relation" }, - "macro.dbt.default__test_accepted_values": { + "macro.dbt.default__persist_docs": { "arguments": [], - "created_at": 1696458269.761441, + "created_at": 1719485736.586432, "depends_on": { - "macros": [] + "macros": [ + "macro.dbt.run_query", + "macro.dbt.alter_relation_comment", + "macro.dbt.alter_column_comment" + ] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro default__test_accepted_values(model, column_name, values, quote=True) %}\n\nwith all_values as (\n\n select\n {{ column_name }} as value_field,\n count(*) as n_records\n\n from {{ model }}\n group by {{ column_name }}\n\n)\n\nselect *\nfrom all_values\nwhere value_field not in (\n {% for value in values -%}\n {% if quote -%}\n '{{ value }}'\n {%- else -%}\n {{ value }}\n {%- endif -%}\n {%- if not loop.last -%},{%- endif %}\n {%- endfor %}\n)\n\n{% endmacro %}", + "macro_sql": "{% macro default__persist_docs(relation, model, for_relation, for_columns) -%}\n {% if for_relation and config.persist_relation_docs() and model.description %}\n {% do run_query(alter_relation_comment(relation, model.description)) %}\n {% endif %}\n\n {% if for_columns and config.persist_column_docs() and model.columns %}\n {% do run_query(alter_column_comment(relation, model.columns)) %}\n {% endif %}\n{% endmacro %}", "meta": {}, - "name": "default__test_accepted_values", - "original_file_path": "macros/generic_test_sql/accepted_values.sql", + "name": "default__persist_docs", + "original_file_path": "macros/adapters/persist_docs.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/generic_test_sql/accepted_values.sql", + "path": "macros/adapters/persist_docs.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__test_accepted_values" + "unique_id": "macro.dbt.default__persist_docs" }, - "macro.dbt.default__test_not_null": { + "macro.dbt.default__position": { "arguments": [], - "created_at": 1696458269.7597172, + "created_at": 1719485736.543534, "depends_on": { - "macros": [ - "macro.dbt.should_store_failures" - ] + "macros": [] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro default__test_not_null(model, column_name) %}\n\n{% set column_list = '*' if should_store_failures() else column_name %}\n\nselect {{ column_list }}\nfrom {{ model }}\nwhere {{ column_name }} is null\n\n{% endmacro %}", + "macro_sql": "{% macro default__position(substring_text, string_text) %}\n\n position(\n {{ substring_text }} in {{ string_text }}\n )\n\n{%- endmacro -%}", "meta": {}, - "name": "default__test_not_null", - "original_file_path": "macros/generic_test_sql/not_null.sql", + "name": "default__position", + "original_file_path": "macros/utils/position.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/generic_test_sql/not_null.sql", + "path": "macros/utils/position.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__test_not_null" + "unique_id": "macro.dbt.default__position" }, - "macro.dbt.default__test_relationships": { + "macro.dbt.default__post_snapshot": { "arguments": [], - "created_at": 1696458269.759, + "created_at": 1719485736.3552608, "depends_on": { "macros": [] }, @@ -3464,22 +3997,20 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro default__test_relationships(model, column_name, to, field) %}\n\nwith child as (\n select {{ column_name }} as from_field\n from {{ model }}\n where {{ column_name }} is not null\n),\n\nparent as (\n select {{ field }} as to_field\n from {{ to }}\n)\n\nselect\n from_field\n\nfrom child\nleft join parent\n on child.from_field = parent.to_field\n\nwhere parent.to_field is null\n\n{% endmacro %}", + "macro_sql": "{% macro default__post_snapshot(staging_relation) %}\n {# no-op #}\n{% endmacro %}", "meta": {}, - "name": "default__test_relationships", - "original_file_path": "macros/generic_test_sql/relationships.sql", + "name": "default__post_snapshot", + "original_file_path": "macros/materializations/snapshots/helpers.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/generic_test_sql/relationships.sql", + "path": "macros/materializations/snapshots/helpers.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__test_relationships" + "unique_id": "macro.dbt.default__post_snapshot" }, - "macro.dbt.default__test_unique": { + "macro.dbt.default__refresh_materialized_view": { "arguments": [], - "created_at": 1696458269.760334, + "created_at": 1719485736.49013, "depends_on": { "macros": [] }, @@ -3488,22 +4019,20 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro default__test_unique(model, column_name) %}\n\nselect\n {{ column_name }} as unique_field,\n count(*) as n_records\n\nfrom {{ model }}\nwhere {{ column_name }} is not null\ngroup by {{ column_name }}\nhaving count(*) > 1\n\n{% endmacro %}", + "macro_sql": "{% macro default__refresh_materialized_view(relation) %}\n {{ exceptions.raise_compiler_error(\"`refresh_materialized_view` has not been implemented for this adapter.\") }}\n{% endmacro %}", "meta": {}, - "name": "default__test_unique", - "original_file_path": "macros/generic_test_sql/unique.sql", + "name": "default__refresh_materialized_view", + "original_file_path": "macros/relations/materialized_view/refresh.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/generic_test_sql/unique.sql", + "path": "macros/relations/materialized_view/refresh.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__test_unique" + "unique_id": "macro.dbt.default__refresh_materialized_view" }, - "macro.dbt.default__truncate_relation": { + "macro.dbt.default__rename_relation": { "arguments": [], - "created_at": 1696458269.815354, + "created_at": 1719485736.4786189, "depends_on": { "macros": [ "macro.dbt.statement" @@ -3514,22 +4043,20 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro default__truncate_relation(relation) -%}\n {% call statement('truncate_relation') -%}\n truncate table {{ relation }}\n {%- endcall %}\n{% endmacro %}", + "macro_sql": "{% macro default__rename_relation(from_relation, to_relation) -%}\n {% set target_name = adapter.quote_as_configured(to_relation.identifier, 'identifier') %}\n {% call statement('rename_relation') -%}\n alter table {{ from_relation }} rename to {{ target_name }}\n {%- endcall %}\n{% endmacro %}", "meta": {}, - "name": "default__truncate_relation", - "original_file_path": "macros/adapters/relation.sql", + "name": "default__rename_relation", + "original_file_path": "macros/relations/rename.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/adapters/relation.sql", + "path": "macros/relations/rename.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__truncate_relation" + "unique_id": "macro.dbt.default__rename_relation" }, - "macro.dbt.default__type_bigint": { + "macro.dbt.default__replace": { "arguments": [], - "created_at": 1696458269.790592, + "created_at": 1719485736.533091, "depends_on": { "macros": [] }, @@ -3538,46 +4065,44 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro default__type_bigint() %}\n {{ return(api.Column.translate_type(\"bigint\")) }}\n{% endmacro %}", + "macro_sql": "{% macro default__replace(field, old_chars, new_chars) %}\n\n replace(\n {{ field }},\n {{ old_chars }},\n {{ new_chars }}\n )\n\n\n{% endmacro %}", "meta": {}, - "name": "default__type_bigint", - "original_file_path": "macros/utils/data_types.sql", + "name": "default__replace", + "original_file_path": "macros/utils/replace.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/utils/data_types.sql", + "path": "macros/utils/replace.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__type_bigint" + "unique_id": "macro.dbt.default__replace" }, - "macro.dbt.default__type_boolean": { + "macro.dbt.default__reset_csv_table": { "arguments": [], - "created_at": 1696458269.791612, + "created_at": 1719485736.460783, "depends_on": { - "macros": [] + "macros": [ + "macro.dbt.create_csv_table" + ] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{%- macro default__type_boolean() -%}\n {{ return(api.Column.translate_type(\"boolean\")) }}\n{%- endmacro -%}\n\n", + "macro_sql": "{% macro default__reset_csv_table(model, full_refresh, old_relation, agate_table) %}\n {% set sql = \"\" %}\n {% if full_refresh %}\n {{ adapter.drop_relation(old_relation) }}\n {% set sql = create_csv_table(model, agate_table) %}\n {% else %}\n {{ adapter.truncate_relation(old_relation) }}\n {% set sql = \"truncate table \" ~ old_relation %}\n {% endif %}\n\n {{ return(sql) }}\n{% endmacro %}", "meta": {}, - "name": "default__type_boolean", - "original_file_path": "macros/utils/data_types.sql", + "name": "default__reset_csv_table", + "original_file_path": "macros/materializations/seeds/helpers.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/utils/data_types.sql", + "path": "macros/materializations/seeds/helpers.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__type_boolean" + "unique_id": "macro.dbt.default__reset_csv_table" }, - "macro.dbt.default__type_float": { + "macro.dbt.default__resolve_model_name": { "arguments": [], - "created_at": 1696458269.789509, + "created_at": 1719485736.615371, "depends_on": { "macros": [] }, @@ -3586,22 +4111,20 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro default__type_float() %}\n {{ return(api.Column.translate_type(\"float\")) }}\n{% endmacro %}", + "macro_sql": "\n\n{%- macro default__resolve_model_name(input_model_name) -%}\n {{ input_model_name | string | replace('\"', '\\\"') }}\n{%- endmacro -%}\n\n", "meta": {}, - "name": "default__type_float", - "original_file_path": "macros/utils/data_types.sql", + "name": "default__resolve_model_name", + "original_file_path": "macros/python_model/python.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/utils/data_types.sql", + "path": "macros/python_model/python.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__type_float" + "unique_id": "macro.dbt.default__resolve_model_name" }, - "macro.dbt.default__type_int": { + "macro.dbt.default__right": { "arguments": [], - "created_at": 1696458269.7910988, + "created_at": 1719485736.538501, "depends_on": { "macros": [] }, @@ -3610,22 +4133,20 @@ "node_color": null, "show": true }, - "macro_sql": "{%- macro default__type_int() -%}\n {{ return(api.Column.translate_type(\"integer\")) }}\n{%- endmacro -%}\n\n", + "macro_sql": "{% macro default__right(string_text, length_expression) %}\n\n right(\n {{ string_text }},\n {{ length_expression }}\n )\n\n{%- endmacro -%}", "meta": {}, - "name": "default__type_int", - "original_file_path": "macros/utils/data_types.sql", + "name": "default__right", + "original_file_path": "macros/utils/right.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/utils/data_types.sql", + "path": "macros/utils/right.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__type_int" + "unique_id": "macro.dbt.default__right" }, - "macro.dbt.default__type_numeric": { + "macro.dbt.default__safe_cast": { "arguments": [], - "created_at": 1696458269.790067, + "created_at": 1719485736.540907, "depends_on": { "macros": [] }, @@ -3634,46 +4155,44 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro default__type_numeric() %}\n {{ return(api.Column.numeric_type(\"numeric\", 28, 6)) }}\n{% endmacro %}", + "macro_sql": "{% macro default__safe_cast(field, type) %}\n {# most databases don't support this function yet\n so we just need to use cast #}\n cast({{field}} as {{type}})\n{% endmacro %}", "meta": {}, - "name": "default__type_numeric", - "original_file_path": "macros/utils/data_types.sql", + "name": "default__safe_cast", + "original_file_path": "macros/utils/safe_cast.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/utils/data_types.sql", + "path": "macros/utils/safe_cast.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__type_numeric" + "unique_id": "macro.dbt.default__safe_cast" }, - "macro.dbt.default__type_string": { + "macro.dbt.default__snapshot_get_time": { "arguments": [], - "created_at": 1696458269.788338, + "created_at": 1719485736.559646, "depends_on": { - "macros": [] + "macros": [ + "macro.dbt.current_timestamp" + ] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro default__type_string() %}\n {{ return(api.Column.translate_type(\"string\")) }}\n{% endmacro %}", + "macro_sql": "{% macro default__snapshot_get_time() %}\n {{ current_timestamp() }}\n{% endmacro %}", "meta": {}, - "name": "default__type_string", - "original_file_path": "macros/utils/data_types.sql", + "name": "default__snapshot_get_time", + "original_file_path": "macros/adapters/timestamps.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/utils/data_types.sql", + "path": "macros/adapters/timestamps.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__type_string" + "unique_id": "macro.dbt.default__snapshot_get_time" }, - "macro.dbt.default__type_timestamp": { + "macro.dbt.default__snapshot_hash_arguments": { "arguments": [], - "created_at": 1696458269.788992, + "created_at": 1719485736.345648, "depends_on": { "macros": [] }, @@ -3682,73 +4201,66 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro default__type_timestamp() %}\n {{ return(api.Column.translate_type(\"timestamp\")) }}\n{% endmacro %}", + "macro_sql": "{% macro default__snapshot_hash_arguments(args) -%}\n md5({%- for arg in args -%}\n coalesce(cast({{ arg }} as varchar ), '')\n {% if not loop.last %} || '|' || {% endif %}\n {%- endfor -%})\n{%- endmacro %}", "meta": {}, - "name": "default__type_timestamp", - "original_file_path": "macros/utils/data_types.sql", + "name": "default__snapshot_hash_arguments", + "original_file_path": "macros/materializations/snapshots/strategies.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/utils/data_types.sql", + "path": "macros/materializations/snapshots/strategies.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default__type_timestamp" + "unique_id": "macro.dbt.default__snapshot_hash_arguments" }, - "macro.dbt.default_last_day": { + "macro.dbt.default__snapshot_merge_sql": { "arguments": [], - "created_at": 1696458269.7946408, + "created_at": 1719485736.340331, "depends_on": { - "macros": [ - "macro.dbt.dateadd", - "macro.dbt.date_trunc" - ] + "macros": [] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "\n\n{%- macro default_last_day(date, datepart) -%}\n cast(\n {{dbt.dateadd('day', '-1',\n dbt.dateadd(datepart, '1', dbt.date_trunc(datepart, date))\n )}}\n as date)\n{%- endmacro -%}\n\n", + "macro_sql": "{% macro default__snapshot_merge_sql(target, source, insert_cols) -%}\n {%- set insert_cols_csv = insert_cols | join(', ') -%}\n\n merge into {{ target }} as DBT_INTERNAL_DEST\n using {{ source }} as DBT_INTERNAL_SOURCE\n on DBT_INTERNAL_SOURCE.dbt_scd_id = DBT_INTERNAL_DEST.dbt_scd_id\n\n when matched\n and DBT_INTERNAL_DEST.dbt_valid_to is null\n and DBT_INTERNAL_SOURCE.dbt_change_type in ('update', 'delete')\n then update\n set dbt_valid_to = DBT_INTERNAL_SOURCE.dbt_valid_to\n\n when not matched\n and DBT_INTERNAL_SOURCE.dbt_change_type = 'insert'\n then insert ({{ insert_cols_csv }})\n values ({{ insert_cols_csv }})\n\n{% endmacro %}", "meta": {}, - "name": "default_last_day", - "original_file_path": "macros/utils/last_day.sql", + "name": "default__snapshot_merge_sql", + "original_file_path": "macros/materializations/snapshots/snapshot_merge.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/utils/last_day.sql", + "path": "macros/materializations/snapshots/snapshot_merge.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.default_last_day" + "unique_id": "macro.dbt.default__snapshot_merge_sql" }, - "macro.dbt.diff_column_data_types": { + "macro.dbt.default__snapshot_staging_table": { "arguments": [], - "created_at": 1696458269.6687229, + "created_at": 1719485736.356719, "depends_on": { - "macros": [] + "macros": [ + "macro.dbt.snapshot_get_time" + ] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro diff_column_data_types(source_columns, target_columns) %}\n\n {% set result = [] %}\n {% for sc in source_columns %}\n {% set tc = target_columns | selectattr(\"name\", \"equalto\", sc.name) | list | first %}\n {% if tc %}\n {% if sc.data_type != tc.data_type and not sc.can_expand_to(other_column=tc) %}\n {{ result.append( { 'column_name': tc.name, 'new_type': sc.data_type } ) }}\n {% endif %}\n {% endif %}\n {% endfor %}\n\n {{ return(result) }}\n\n{% endmacro %}", + "macro_sql": "{% macro default__snapshot_staging_table(strategy, source_sql, target_relation) -%}\n\n with snapshot_query as (\n\n {{ source_sql }}\n\n ),\n\n snapshotted_data as (\n\n select *,\n {{ strategy.unique_key }} as dbt_unique_key\n\n from {{ target_relation }}\n where dbt_valid_to is null\n\n ),\n\n insertions_source_data as (\n\n select\n *,\n {{ strategy.unique_key }} as dbt_unique_key,\n {{ strategy.updated_at }} as dbt_updated_at,\n {{ strategy.updated_at }} as dbt_valid_from,\n nullif({{ strategy.updated_at }}, {{ strategy.updated_at }}) as dbt_valid_to,\n {{ strategy.scd_id }} as dbt_scd_id\n\n from snapshot_query\n ),\n\n updates_source_data as (\n\n select\n *,\n {{ strategy.unique_key }} as dbt_unique_key,\n {{ strategy.updated_at }} as dbt_updated_at,\n {{ strategy.updated_at }} as dbt_valid_from,\n {{ strategy.updated_at }} as dbt_valid_to\n\n from snapshot_query\n ),\n\n {%- if strategy.invalidate_hard_deletes %}\n\n deletes_source_data as (\n\n select\n *,\n {{ strategy.unique_key }} as dbt_unique_key\n from snapshot_query\n ),\n {% endif %}\n\n insertions as (\n\n select\n 'insert' as dbt_change_type,\n source_data.*\n\n from insertions_source_data as source_data\n left outer join snapshotted_data on snapshotted_data.dbt_unique_key = source_data.dbt_unique_key\n where snapshotted_data.dbt_unique_key is null\n or (\n snapshotted_data.dbt_unique_key is not null\n and (\n {{ strategy.row_changed }}\n )\n )\n\n ),\n\n updates as (\n\n select\n 'update' as dbt_change_type,\n source_data.*,\n snapshotted_data.dbt_scd_id\n\n from updates_source_data as source_data\n join snapshotted_data on snapshotted_data.dbt_unique_key = source_data.dbt_unique_key\n where (\n {{ strategy.row_changed }}\n )\n )\n\n {%- if strategy.invalidate_hard_deletes -%}\n ,\n\n deletes as (\n\n select\n 'delete' as dbt_change_type,\n source_data.*,\n {{ snapshot_get_time() }} as dbt_valid_from,\n {{ snapshot_get_time() }} as dbt_updated_at,\n {{ snapshot_get_time() }} as dbt_valid_to,\n snapshotted_data.dbt_scd_id\n\n from snapshotted_data\n left join deletes_source_data as source_data on snapshotted_data.dbt_unique_key = source_data.dbt_unique_key\n where source_data.dbt_unique_key is null\n )\n {%- endif %}\n\n select * from insertions\n union all\n select * from updates\n {%- if strategy.invalidate_hard_deletes %}\n union all\n select * from deletes\n {%- endif %}\n\n{%- endmacro %}", "meta": {}, - "name": "diff_column_data_types", - "original_file_path": "macros/materializations/models/incremental/column_helpers.sql", + "name": "default__snapshot_staging_table", + "original_file_path": "macros/materializations/snapshots/helpers.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/models/incremental/column_helpers.sql", + "path": "macros/materializations/snapshots/helpers.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.diff_column_data_types" + "unique_id": "macro.dbt.default__snapshot_staging_table" }, - "macro.dbt.diff_columns": { + "macro.dbt.default__snapshot_string_as_time": { "arguments": [], - "created_at": 1696458269.667535, + "created_at": 1719485736.346766, "depends_on": { "macros": [] }, @@ -3757,48 +4269,42 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro diff_columns(source_columns, target_columns) %}\n\n {% set result = [] %}\n {% set source_names = source_columns | map(attribute = 'column') | list %}\n {% set target_names = target_columns | map(attribute = 'column') | list %}\n\n {# --check whether the name attribute exists in the target - this does not perform a data type check #}\n {% for sc in source_columns %}\n {% if sc.name not in target_names %}\n {{ result.append(sc) }}\n {% endif %}\n {% endfor %}\n\n {{ return(result) }}\n\n{% endmacro %}", + "macro_sql": "{% macro default__snapshot_string_as_time(timestamp) %}\n {% do exceptions.raise_not_implemented(\n 'snapshot_string_as_time macro not implemented for adapter '+adapter.type()\n ) %}\n{% endmacro %}", "meta": {}, - "name": "diff_columns", - "original_file_path": "macros/materializations/models/incremental/column_helpers.sql", + "name": "default__snapshot_string_as_time", + "original_file_path": "macros/materializations/snapshots/strategies.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/models/incremental/column_helpers.sql", + "path": "macros/materializations/snapshots/strategies.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.diff_columns" + "unique_id": "macro.dbt.default__snapshot_string_as_time" }, - "macro.dbt.drop_relation": { + "macro.dbt.default__split_part": { "arguments": [], - "created_at": 1696458269.814443, + "created_at": 1719485736.554402, "depends_on": { - "macros": [ - "macro.dbt.default__drop_relation" - ] + "macros": [] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro drop_relation(relation) -%}\n {{ return(adapter.dispatch('drop_relation', 'dbt')(relation)) }}\n{% endmacro %}", + "macro_sql": "{% macro default__split_part(string_text, delimiter_text, part_number) %}\n\n split_part(\n {{ string_text }},\n {{ delimiter_text }},\n {{ part_number }}\n )\n\n{% endmacro %}", "meta": {}, - "name": "drop_relation", - "original_file_path": "macros/adapters/relation.sql", + "name": "default__split_part", + "original_file_path": "macros/utils/split_part.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/adapters/relation.sql", + "path": "macros/utils/split_part.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.drop_relation" + "unique_id": "macro.dbt.default__split_part" }, - "macro.dbt.drop_relation_if_exists": { + "macro.dbt.default__string_literal": { "arguments": [], - "created_at": 1696458269.8185081, + "created_at": 1719485736.544776, "depends_on": { "macros": [] }, @@ -3807,77 +4313,67 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro drop_relation_if_exists(relation) %}\n {% if relation is not none %}\n {{ adapter.drop_relation(relation) }}\n {% endif %}\n{% endmacro %}", + "macro_sql": "{% macro default__string_literal(value) -%}\n '{{ value }}'\n{%- endmacro %}", "meta": {}, - "name": "drop_relation_if_exists", - "original_file_path": "macros/adapters/relation.sql", + "name": "default__string_literal", + "original_file_path": "macros/utils/literal.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/adapters/relation.sql", + "path": "macros/utils/literal.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.drop_relation_if_exists" + "unique_id": "macro.dbt.default__string_literal" }, - "macro.dbt.drop_schema": { + "macro.dbt.default__support_multiple_grantees_per_dcl_statement": { "arguments": [], - "created_at": 1696458269.8010921, + "created_at": 1719485736.573099, "depends_on": { - "macros": [ - "macro.dbt_postgres.postgres__drop_schema" - ] + "macros": [] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro drop_schema(relation) -%}\n {{ adapter.dispatch('drop_schema', 'dbt')(relation) }}\n{% endmacro %}", + "macro_sql": "\n\n{%- macro default__support_multiple_grantees_per_dcl_statement() -%}\n {{ return(True) }}\n{%- endmacro -%}\n\n\n", "meta": {}, - "name": "drop_schema", - "original_file_path": "macros/adapters/schema.sql", + "name": "default__support_multiple_grantees_per_dcl_statement", + "original_file_path": "macros/adapters/apply_grants.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/adapters/schema.sql", + "path": "macros/adapters/apply_grants.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.drop_schema" + "unique_id": "macro.dbt.default__support_multiple_grantees_per_dcl_statement" }, - "macro.dbt.escape_single_quotes": { + "macro.dbt.default__test_accepted_values": { "arguments": [], - "created_at": 1696458269.777445, + "created_at": 1719485736.517527, "depends_on": { - "macros": [ - "macro.dbt.default__escape_single_quotes" - ] + "macros": [] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro escape_single_quotes(expression) %}\n {{ return(adapter.dispatch('escape_single_quotes', 'dbt') (expression)) }}\n{% endmacro %}", + "macro_sql": "{% macro default__test_accepted_values(model, column_name, values, quote=True) %}\n\nwith all_values as (\n\n select\n {{ column_name }} as value_field,\n count(*) as n_records\n\n from {{ model }}\n group by {{ column_name }}\n\n)\n\nselect *\nfrom all_values\nwhere value_field not in (\n {% for value in values -%}\n {% if quote -%}\n '{{ value }}'\n {%- else -%}\n {{ value }}\n {%- endif -%}\n {%- if not loop.last -%},{%- endif %}\n {%- endfor %}\n)\n\n{% endmacro %}", "meta": {}, - "name": "escape_single_quotes", - "original_file_path": "macros/utils/escape_single_quotes.sql", + "name": "default__test_accepted_values", + "original_file_path": "macros/generic_test_sql/accepted_values.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/utils/escape_single_quotes.sql", + "path": "macros/generic_test_sql/accepted_values.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.escape_single_quotes" + "unique_id": "macro.dbt.default__test_accepted_values" }, - "macro.dbt.except": { + "macro.dbt.default__test_not_null": { "arguments": [], - "created_at": 1696458269.7728372, + "created_at": 1719485736.516648, "depends_on": { "macros": [ - "macro.dbt.default__except" + "macro.dbt.should_store_failures" ] }, "description": "", @@ -3885,77 +4381,67 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro except() %}\n {{ return(adapter.dispatch('except', 'dbt')()) }}\n{% endmacro %}", + "macro_sql": "{% macro default__test_not_null(model, column_name) %}\n\n{% set column_list = '*' if should_store_failures() else column_name %}\n\nselect {{ column_list }}\nfrom {{ model }}\nwhere {{ column_name }} is null\n\n{% endmacro %}", "meta": {}, - "name": "except", - "original_file_path": "macros/utils/except.sql", + "name": "default__test_not_null", + "original_file_path": "macros/generic_test_sql/not_null.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/utils/except.sql", + "path": "macros/generic_test_sql/not_null.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.except" + "unique_id": "macro.dbt.default__test_not_null" }, - "macro.dbt.generate_alias_name": { + "macro.dbt.default__test_relationships": { "arguments": [], - "created_at": 1696458269.7544892, + "created_at": 1719485736.516331, "depends_on": { - "macros": [ - "macro.dbt.default__generate_alias_name" - ] + "macros": [] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro generate_alias_name(custom_alias_name=none, node=none) -%}\n {% do return(adapter.dispatch('generate_alias_name', 'dbt')(custom_alias_name, node)) %}\n{%- endmacro %}", + "macro_sql": "{% macro default__test_relationships(model, column_name, to, field) %}\n\nwith child as (\n select {{ column_name }} as from_field\n from {{ model }}\n where {{ column_name }} is not null\n),\n\nparent as (\n select {{ field }} as to_field\n from {{ to }}\n)\n\nselect\n from_field\n\nfrom child\nleft join parent\n on child.from_field = parent.to_field\n\nwhere parent.to_field is null\n\n{% endmacro %}", "meta": {}, - "name": "generate_alias_name", - "original_file_path": "macros/get_custom_name/get_custom_alias.sql", + "name": "default__test_relationships", + "original_file_path": "macros/generic_test_sql/relationships.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/get_custom_name/get_custom_alias.sql", + "path": "macros/generic_test_sql/relationships.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.generate_alias_name" + "unique_id": "macro.dbt.default__test_relationships" }, - "macro.dbt.generate_database_name": { + "macro.dbt.default__test_unique": { "arguments": [], - "created_at": 1696458269.757828, + "created_at": 1719485736.5169122, "depends_on": { - "macros": [ - "macro.dbt.default__generate_database_name" - ] + "macros": [] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro generate_database_name(custom_database_name=none, node=none) -%}\n {% do return(adapter.dispatch('generate_database_name', 'dbt')(custom_database_name, node)) %}\n{%- endmacro %}", + "macro_sql": "{% macro default__test_unique(model, column_name) %}\n\nselect\n {{ column_name }} as unique_field,\n count(*) as n_records\n\nfrom {{ model }}\nwhere {{ column_name }} is not null\ngroup by {{ column_name }}\nhaving count(*) > 1\n\n{% endmacro %}", "meta": {}, - "name": "generate_database_name", - "original_file_path": "macros/get_custom_name/get_custom_database.sql", + "name": "default__test_unique", + "original_file_path": "macros/generic_test_sql/unique.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/get_custom_name/get_custom_database.sql", + "path": "macros/generic_test_sql/unique.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.generate_database_name" + "unique_id": "macro.dbt.default__test_unique" }, - "macro.dbt.generate_schema_name": { + "macro.dbt.default__truncate_relation": { "arguments": [], - "created_at": 1696458269.756017, + "created_at": 1719485736.5674748, "depends_on": { "macros": [ - "macro.dbt.default__generate_schema_name" + "macro.dbt.statement" ] }, "description": "", @@ -3963,22 +4449,20 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro generate_schema_name(custom_schema_name=none, node=none) -%}\n {{ return(adapter.dispatch('generate_schema_name', 'dbt')(custom_schema_name, node)) }}\n{% endmacro %}", + "macro_sql": "{% macro default__truncate_relation(relation) -%}\n {% call statement('truncate_relation') -%}\n truncate table {{ relation }}\n {%- endcall %}\n{% endmacro %}", "meta": {}, - "name": "generate_schema_name", - "original_file_path": "macros/get_custom_name/get_custom_schema.sql", + "name": "default__truncate_relation", + "original_file_path": "macros/adapters/relation.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/get_custom_name/get_custom_schema.sql", + "path": "macros/adapters/relation.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.generate_schema_name" + "unique_id": "macro.dbt.default__truncate_relation" }, - "macro.dbt.generate_schema_name_for_env": { + "macro.dbt.default__type_bigint": { "arguments": [], - "created_at": 1696458269.756963, + "created_at": 1719485736.5486028, "depends_on": { "macros": [] }, @@ -3987,181 +4471,155 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro generate_schema_name_for_env(custom_schema_name, node) -%}\n\n {%- set default_schema = target.schema -%}\n {%- if target.name == 'prod' and custom_schema_name is not none -%}\n\n {{ custom_schema_name | trim }}\n\n {%- else -%}\n\n {{ default_schema }}\n\n {%- endif -%}\n\n{%- endmacro %}", + "macro_sql": "{% macro default__type_bigint() %}\n {{ return(api.Column.translate_type(\"bigint\")) }}\n{% endmacro %}", "meta": {}, - "name": "generate_schema_name_for_env", - "original_file_path": "macros/get_custom_name/get_custom_schema.sql", + "name": "default__type_bigint", + "original_file_path": "macros/utils/data_types.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/get_custom_name/get_custom_schema.sql", + "path": "macros/utils/data_types.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.generate_schema_name_for_env" + "unique_id": "macro.dbt.default__type_bigint" }, - "macro.dbt.get_batch_size": { + "macro.dbt.default__type_boolean": { "arguments": [], - "created_at": 1696458269.750157, + "created_at": 1719485736.550734, "depends_on": { - "macros": [ - "macro.dbt.default__get_batch_size" - ] + "macros": [] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro get_batch_size() -%}\n {{ return(adapter.dispatch('get_batch_size', 'dbt')()) }}\n{%- endmacro %}", + "macro_sql": "{%- macro default__type_boolean() -%}\n {{ return(api.Column.translate_type(\"boolean\")) }}\n{%- endmacro -%}\n\n", "meta": {}, - "name": "get_batch_size", - "original_file_path": "macros/materializations/seeds/helpers.sql", + "name": "default__type_boolean", + "original_file_path": "macros/utils/data_types.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/seeds/helpers.sql", + "path": "macros/utils/data_types.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.get_batch_size" + "unique_id": "macro.dbt.default__type_boolean" }, - "macro.dbt.get_binding_char": { + "macro.dbt.default__type_float": { "arguments": [], - "created_at": 1696458269.749717, + "created_at": 1719485736.5472598, "depends_on": { - "macros": [ - "macro.dbt.default__get_binding_char" - ] + "macros": [] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro get_binding_char() -%}\n {{ adapter.dispatch('get_binding_char', 'dbt')() }}\n{%- endmacro %}", + "macro_sql": "{% macro default__type_float() %}\n {{ return(api.Column.translate_type(\"float\")) }}\n{% endmacro %}", "meta": {}, - "name": "get_binding_char", - "original_file_path": "macros/materializations/seeds/helpers.sql", + "name": "default__type_float", + "original_file_path": "macros/utils/data_types.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/seeds/helpers.sql", + "path": "macros/utils/data_types.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.get_binding_char" + "unique_id": "macro.dbt.default__type_float" }, - "macro.dbt.get_catalog": { + "macro.dbt.default__type_int": { "arguments": [], - "created_at": 1696458269.836847, + "created_at": 1719485736.549174, "depends_on": { - "macros": [ - "macro.dbt_postgres.postgres__get_catalog" - ] + "macros": [] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro get_catalog(information_schema, schemas) -%}\n {{ return(adapter.dispatch('get_catalog', 'dbt')(information_schema, schemas)) }}\n{%- endmacro %}", + "macro_sql": "{%- macro default__type_int() -%}\n {{ return(api.Column.translate_type(\"integer\")) }}\n{%- endmacro -%}\n\n", "meta": {}, - "name": "get_catalog", - "original_file_path": "macros/adapters/metadata.sql", + "name": "default__type_int", + "original_file_path": "macros/utils/data_types.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/adapters/metadata.sql", + "path": "macros/utils/data_types.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.get_catalog" + "unique_id": "macro.dbt.default__type_int" }, - "macro.dbt.get_columns_in_query": { + "macro.dbt.default__type_numeric": { "arguments": [], - "created_at": 1696458269.844657, + "created_at": 1719485736.547792, "depends_on": { - "macros": [ - "macro.dbt.default__get_columns_in_query" - ] + "macros": [] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro get_columns_in_query(select_sql) -%}\n {{ return(adapter.dispatch('get_columns_in_query', 'dbt')(select_sql)) }}\n{% endmacro %}", + "macro_sql": "{% macro default__type_numeric() %}\n {{ return(api.Column.numeric_type(\"numeric\", 28, 6)) }}\n{% endmacro %}", "meta": {}, - "name": "get_columns_in_query", - "original_file_path": "macros/adapters/columns.sql", + "name": "default__type_numeric", + "original_file_path": "macros/utils/data_types.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/adapters/columns.sql", + "path": "macros/utils/data_types.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.get_columns_in_query" + "unique_id": "macro.dbt.default__type_numeric" }, - "macro.dbt.get_columns_in_relation": { + "macro.dbt.default__type_string": { "arguments": [], - "created_at": 1696458269.843055, + "created_at": 1719485736.5461628, "depends_on": { - "macros": [ - "macro.dbt_postgres.postgres__get_columns_in_relation" - ] + "macros": [] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro get_columns_in_relation(relation) -%}\n {{ return(adapter.dispatch('get_columns_in_relation', 'dbt')(relation)) }}\n{% endmacro %}", + "macro_sql": "{% macro default__type_string() %}\n {{ return(api.Column.translate_type(\"string\")) }}\n{% endmacro %}", "meta": {}, - "name": "get_columns_in_relation", - "original_file_path": "macros/adapters/columns.sql", + "name": "default__type_string", + "original_file_path": "macros/utils/data_types.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/adapters/columns.sql", + "path": "macros/utils/data_types.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.get_columns_in_relation" + "unique_id": "macro.dbt.default__type_string" }, - "macro.dbt.get_create_index_sql": { + "macro.dbt.default__type_timestamp": { "arguments": [], - "created_at": 1696458269.8050802, + "created_at": 1719485736.5468001, "depends_on": { - "macros": [ - "macro.dbt_postgres.postgres__get_create_index_sql" - ] + "macros": [] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro get_create_index_sql(relation, index_dict) -%}\n {{ return(adapter.dispatch('get_create_index_sql', 'dbt')(relation, index_dict)) }}\n{% endmacro %}", + "macro_sql": "{% macro default__type_timestamp() %}\n {{ return(api.Column.translate_type(\"timestamp\")) }}\n{% endmacro %}", "meta": {}, - "name": "get_create_index_sql", - "original_file_path": "macros/adapters/indexes.sql", + "name": "default__type_timestamp", + "original_file_path": "macros/utils/data_types.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/adapters/indexes.sql", + "path": "macros/utils/data_types.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.get_create_index_sql" + "unique_id": "macro.dbt.default__type_timestamp" }, - "macro.dbt.get_create_table_as_sql": { + "macro.dbt.default__validate_sql": { "arguments": [], - "created_at": 1696458269.719821, + "created_at": 1719485736.570561, "depends_on": { "macros": [ - "macro.dbt.default__get_create_table_as_sql" + "macro.dbt.statement" ] }, "description": "", @@ -4169,25 +4627,24 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro get_create_table_as_sql(temporary, relation, sql) -%}\n {{ adapter.dispatch('get_create_table_as_sql', 'dbt')(temporary, relation, sql) }}\n{%- endmacro %}", + "macro_sql": "{% macro default__validate_sql(sql) -%}\n {% call statement('validate_sql') -%}\n explain {{ sql }}\n {% endcall %}\n {{ return(load_result('validate_sql')) }}\n{% endmacro %}", "meta": {}, - "name": "get_create_table_as_sql", - "original_file_path": "macros/materializations/models/table/create_table_as.sql", + "name": "default__validate_sql", + "original_file_path": "macros/adapters/validate_sql.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/models/table/create_table_as.sql", + "path": "macros/adapters/validate_sql.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.get_create_table_as_sql" + "unique_id": "macro.dbt.default__validate_sql" }, - "macro.dbt.get_create_view_as_sql": { + "macro.dbt.default_last_day": { "arguments": [], - "created_at": 1696458269.730824, + "created_at": 1719485736.5533261, "depends_on": { "macros": [ - "macro.dbt.default__get_create_view_as_sql" + "macro.dbt.dateadd", + "macro.dbt.date_trunc" ] }, "description": "", @@ -4195,77 +4652,67 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro get_create_view_as_sql(relation, sql) -%}\n {{ adapter.dispatch('get_create_view_as_sql', 'dbt')(relation, sql) }}\n{%- endmacro %}", + "macro_sql": "\n\n{%- macro default_last_day(date, datepart) -%}\n cast(\n {{dbt.dateadd('day', '-1',\n dbt.dateadd(datepart, '1', dbt.date_trunc(datepart, date))\n )}}\n as date)\n{%- endmacro -%}\n\n", "meta": {}, - "name": "get_create_view_as_sql", - "original_file_path": "macros/materializations/models/view/create_view_as.sql", + "name": "default_last_day", + "original_file_path": "macros/utils/last_day.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/models/view/create_view_as.sql", + "path": "macros/utils/last_day.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.get_create_view_as_sql" + "unique_id": "macro.dbt.default_last_day" }, - "macro.dbt.get_csv_sql": { + "macro.dbt.diff_column_data_types": { "arguments": [], - "created_at": 1696458269.749263, + "created_at": 1719485736.397441, "depends_on": { - "macros": [ - "macro.dbt.default__get_csv_sql" - ] + "macros": [] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro get_csv_sql(create_or_truncate_sql, insert_sql) %}\n {{ adapter.dispatch('get_csv_sql', 'dbt')(create_or_truncate_sql, insert_sql) }}\n{% endmacro %}", + "macro_sql": "{% macro diff_column_data_types(source_columns, target_columns) %}\n\n {% set result = [] %}\n {% for sc in source_columns %}\n {% set tc = target_columns | selectattr(\"name\", \"equalto\", sc.name) | list | first %}\n {% if tc %}\n {% if sc.data_type != tc.data_type and not sc.can_expand_to(other_column=tc) %}\n {{ result.append( { 'column_name': tc.name, 'new_type': sc.data_type } ) }}\n {% endif %}\n {% endif %}\n {% endfor %}\n\n {{ return(result) }}\n\n{% endmacro %}", "meta": {}, - "name": "get_csv_sql", - "original_file_path": "macros/materializations/seeds/helpers.sql", + "name": "diff_column_data_types", + "original_file_path": "macros/materializations/models/incremental/column_helpers.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/seeds/helpers.sql", + "path": "macros/materializations/models/incremental/column_helpers.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.get_csv_sql" + "unique_id": "macro.dbt.diff_column_data_types" }, - "macro.dbt.get_dcl_statement_list": { + "macro.dbt.diff_columns": { "arguments": [], - "created_at": 1696458269.826365, + "created_at": 1719485736.396736, "depends_on": { - "macros": [ - "macro.dbt.default__get_dcl_statement_list" - ] + "macros": [] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro get_dcl_statement_list(relation, grant_config, get_dcl_macro) %}\n {{ return(adapter.dispatch('get_dcl_statement_list', 'dbt')(relation, grant_config, get_dcl_macro)) }}\n{% endmacro %}", + "macro_sql": "{% macro diff_columns(source_columns, target_columns) %}\n\n {% set result = [] %}\n {% set source_names = source_columns | map(attribute = 'column') | list %}\n {% set target_names = target_columns | map(attribute = 'column') | list %}\n\n {# --check whether the name attribute exists in the target - this does not perform a data type check #}\n {% for sc in source_columns %}\n {% if sc.name not in target_names %}\n {{ result.append(sc) }}\n {% endif %}\n {% endfor %}\n\n {{ return(result) }}\n\n{% endmacro %}", "meta": {}, - "name": "get_dcl_statement_list", - "original_file_path": "macros/adapters/apply_grants.sql", + "name": "diff_columns", + "original_file_path": "macros/materializations/models/incremental/column_helpers.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/adapters/apply_grants.sql", + "path": "macros/materializations/models/incremental/column_helpers.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.get_dcl_statement_list" + "unique_id": "macro.dbt.diff_columns" }, - "macro.dbt.get_delete_insert_merge_sql": { + "macro.dbt.drop_materialized_view": { "arguments": [], - "created_at": 1696458269.6812491, + "created_at": 1719485736.486141, "depends_on": { "macros": [ - "macro.dbt.default__get_delete_insert_merge_sql" + "macro.dbt_postgres.postgres__drop_materialized_view" ] }, "description": "", @@ -4273,25 +4720,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro get_delete_insert_merge_sql(target, source, unique_key, dest_columns) -%}\n {{ adapter.dispatch('get_delete_insert_merge_sql', 'dbt')(target, source, unique_key, dest_columns) }}\n{%- endmacro %}", + "macro_sql": "{% macro drop_materialized_view(relation) -%}\n {{- adapter.dispatch('drop_materialized_view', 'dbt')(relation) -}}\n{%- endmacro %}", "meta": {}, - "name": "get_delete_insert_merge_sql", - "original_file_path": "macros/materializations/models/incremental/merge.sql", + "name": "drop_materialized_view", + "original_file_path": "macros/relations/materialized_view/drop.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/models/incremental/merge.sql", + "path": "macros/relations/materialized_view/drop.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.get_delete_insert_merge_sql" + "unique_id": "macro.dbt.drop_materialized_view" }, - "macro.dbt.get_grant_sql": { + "macro.dbt.drop_relation": { "arguments": [], - "created_at": 1696458269.8250072, + "created_at": 1719485736.471232, "depends_on": { "macros": [ - "macro.dbt.default__get_grant_sql" + "macro.dbt.default__drop_relation" ] }, "description": "", @@ -4299,51 +4744,45 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro get_grant_sql(relation, privilege, grantees) %}\n {{ return(adapter.dispatch('get_grant_sql', 'dbt')(relation, privilege, grantees)) }}\n{% endmacro %}", + "macro_sql": "{% macro drop_relation(relation) -%}\n {{ return(adapter.dispatch('drop_relation', 'dbt')(relation)) }}\n{% endmacro %}", "meta": {}, - "name": "get_grant_sql", - "original_file_path": "macros/adapters/apply_grants.sql", + "name": "drop_relation", + "original_file_path": "macros/relations/drop.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/adapters/apply_grants.sql", + "path": "macros/relations/drop.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.get_grant_sql" + "unique_id": "macro.dbt.drop_relation" }, - "macro.dbt.get_incremental_append_sql": { + "macro.dbt.drop_relation_if_exists": { "arguments": [], - "created_at": 1696458269.686978, + "created_at": 1719485736.471669, "depends_on": { - "macros": [ - "macro.dbt.default__get_incremental_append_sql" - ] + "macros": [] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro get_incremental_append_sql(arg_dict) %}\n\n {{ return(adapter.dispatch('get_incremental_append_sql', 'dbt')(arg_dict)) }}\n\n{% endmacro %}", + "macro_sql": "{% macro drop_relation_if_exists(relation) %}\n {% if relation is not none %}\n {{ adapter.drop_relation(relation) }}\n {% endif %}\n{% endmacro %}", "meta": {}, - "name": "get_incremental_append_sql", - "original_file_path": "macros/materializations/models/incremental/strategies.sql", + "name": "drop_relation_if_exists", + "original_file_path": "macros/relations/drop.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/models/incremental/strategies.sql", + "path": "macros/relations/drop.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.get_incremental_append_sql" + "unique_id": "macro.dbt.drop_relation_if_exists" }, - "macro.dbt.get_incremental_default_sql": { + "macro.dbt.drop_schema": { "arguments": [], - "created_at": 1696458269.690001, + "created_at": 1719485736.558478, "depends_on": { "macros": [ - "macro.dbt_postgres.postgres__get_incremental_default_sql" + "macro.dbt_postgres.postgres__drop_schema" ] }, "description": "", @@ -4351,25 +4790,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro get_incremental_default_sql(arg_dict) %}\n\n {{ return(adapter.dispatch('get_incremental_default_sql', 'dbt')(arg_dict)) }}\n\n{% endmacro %}", + "macro_sql": "{% macro drop_schema(relation) -%}\n {{ adapter.dispatch('drop_schema', 'dbt')(relation) }}\n{% endmacro %}", "meta": {}, - "name": "get_incremental_default_sql", - "original_file_path": "macros/materializations/models/incremental/strategies.sql", + "name": "drop_schema", + "original_file_path": "macros/adapters/schema.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/models/incremental/strategies.sql", + "path": "macros/adapters/schema.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.get_incremental_default_sql" + "unique_id": "macro.dbt.drop_schema" }, - "macro.dbt.get_incremental_delete_insert_sql": { + "macro.dbt.drop_schema_named": { "arguments": [], - "created_at": 1696458269.6876879, + "created_at": 1719485736.4761379, "depends_on": { "macros": [ - "macro.dbt.default__get_incremental_delete_insert_sql" + "macro.dbt.default__drop_schema_named" ] }, "description": "", @@ -4377,25 +4814,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro get_incremental_delete_insert_sql(arg_dict) %}\n\n {{ return(adapter.dispatch('get_incremental_delete_insert_sql', 'dbt')(arg_dict)) }}\n\n{% endmacro %}", + "macro_sql": "{% macro drop_schema_named(schema_name) %}\n {{ return(adapter.dispatch('drop_schema_named', 'dbt') (schema_name)) }}\n{% endmacro %}", "meta": {}, - "name": "get_incremental_delete_insert_sql", - "original_file_path": "macros/materializations/models/incremental/strategies.sql", + "name": "drop_schema_named", + "original_file_path": "macros/relations/schema.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/models/incremental/strategies.sql", + "path": "macros/relations/schema.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.get_incremental_delete_insert_sql" + "unique_id": "macro.dbt.drop_schema_named" }, - "macro.dbt.get_incremental_insert_overwrite_sql": { + "macro.dbt.drop_table": { "arguments": [], - "created_at": 1696458269.6892319, + "created_at": 1719485736.505447, "depends_on": { "macros": [ - "macro.dbt.default__get_incremental_insert_overwrite_sql" + "macro.dbt_postgres.postgres__drop_table" ] }, "description": "", @@ -4403,25 +4838,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro get_incremental_insert_overwrite_sql(arg_dict) %}\n\n {{ return(adapter.dispatch('get_incremental_insert_overwrite_sql', 'dbt')(arg_dict)) }}\n\n{% endmacro %}", + "macro_sql": "{% macro drop_table(relation) -%}\n {{- adapter.dispatch('drop_table', 'dbt')(relation) -}}\n{%- endmacro %}", "meta": {}, - "name": "get_incremental_insert_overwrite_sql", - "original_file_path": "macros/materializations/models/incremental/strategies.sql", + "name": "drop_table", + "original_file_path": "macros/relations/table/drop.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/models/incremental/strategies.sql", + "path": "macros/relations/table/drop.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.get_incremental_insert_overwrite_sql" + "unique_id": "macro.dbt.drop_table" }, - "macro.dbt.get_incremental_merge_sql": { + "macro.dbt.drop_view": { "arguments": [], - "created_at": 1696458269.688441, + "created_at": 1719485736.511067, "depends_on": { "macros": [ - "macro.dbt.default__get_incremental_merge_sql" + "macro.dbt_postgres.postgres__drop_view" ] }, "description": "", @@ -4429,25 +4862,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro get_incremental_merge_sql(arg_dict) %}\n\n {{ return(adapter.dispatch('get_incremental_merge_sql', 'dbt')(arg_dict)) }}\n\n{% endmacro %}", + "macro_sql": "{% macro drop_view(relation) -%}\n {{- adapter.dispatch('drop_view', 'dbt')(relation) -}}\n{%- endmacro %}", "meta": {}, - "name": "get_incremental_merge_sql", - "original_file_path": "macros/materializations/models/incremental/strategies.sql", + "name": "drop_view", + "original_file_path": "macros/relations/view/drop.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/models/incremental/strategies.sql", + "path": "macros/relations/view/drop.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.get_incremental_merge_sql" + "unique_id": "macro.dbt.drop_view" }, - "macro.dbt.get_insert_into_sql": { + "macro.dbt.escape_single_quotes": { "arguments": [], - "created_at": 1696458269.6907241, + "created_at": 1719485736.537864, "depends_on": { "macros": [ - "macro.dbt.get_quoted_csv" + "macro.dbt.default__escape_single_quotes" ] }, "description": "", @@ -4455,25 +4886,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro get_insert_into_sql(target_relation, temp_relation, dest_columns) %}\n\n {%- set dest_cols_csv = get_quoted_csv(dest_columns | map(attribute=\"name\")) -%}\n\n insert into {{ target_relation }} ({{ dest_cols_csv }})\n (\n select {{ dest_cols_csv }}\n from {{ temp_relation }}\n )\n\n{% endmacro %}", + "macro_sql": "{% macro escape_single_quotes(expression) %}\n {{ return(adapter.dispatch('escape_single_quotes', 'dbt') (expression)) }}\n{% endmacro %}", "meta": {}, - "name": "get_insert_into_sql", - "original_file_path": "macros/materializations/models/incremental/strategies.sql", + "name": "escape_single_quotes", + "original_file_path": "macros/utils/escape_single_quotes.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/models/incremental/strategies.sql", + "path": "macros/utils/escape_single_quotes.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.get_insert_into_sql" + "unique_id": "macro.dbt.escape_single_quotes" }, - "macro.dbt.get_insert_overwrite_merge_sql": { + "macro.dbt.except": { "arguments": [], - "created_at": 1696458269.683062, + "created_at": 1719485736.5260952, "depends_on": { "macros": [ - "macro.dbt.default__get_insert_overwrite_merge_sql" + "macro.dbt.default__except" ] }, "description": "", @@ -4481,25 +4910,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro get_insert_overwrite_merge_sql(target, source, dest_columns, predicates, include_sql_header=false) -%}\n {{ adapter.dispatch('get_insert_overwrite_merge_sql', 'dbt')(target, source, dest_columns, predicates, include_sql_header) }}\n{%- endmacro %}", + "macro_sql": "{% macro except() %}\n {{ return(adapter.dispatch('except', 'dbt')()) }}\n{% endmacro %}", "meta": {}, - "name": "get_insert_overwrite_merge_sql", - "original_file_path": "macros/materializations/models/incremental/merge.sql", + "name": "except", + "original_file_path": "macros/utils/except.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/models/incremental/merge.sql", + "path": "macros/utils/except.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.get_insert_overwrite_merge_sql" + "unique_id": "macro.dbt.except" }, - "macro.dbt.get_merge_sql": { + "macro.dbt.format_columns": { "arguments": [], - "created_at": 1696458269.67798, + "created_at": 1719485736.504094, "depends_on": { "macros": [ - "macro.dbt.default__get_merge_sql" + "macro.dbt.default__format_column" ] }, "description": "", @@ -4507,25 +4934,25 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro get_merge_sql(target, source, unique_key, dest_columns, predicates=none) -%}\n {{ adapter.dispatch('get_merge_sql', 'dbt')(target, source, unique_key, dest_columns, predicates) }}\n{%- endmacro %}", + "macro_sql": "{% macro format_columns(columns) %}\n {% set formatted_columns = [] %}\n {% for column in columns %}\n {%- set formatted_column = adapter.dispatch('format_column', 'dbt')(column) -%}\n {%- do formatted_columns.append(formatted_column) -%}\n {% endfor %}\n {{ return(formatted_columns) }}\n{% endmacro %}", "meta": {}, - "name": "get_merge_sql", - "original_file_path": "macros/materializations/models/incremental/merge.sql", + "name": "format_columns", + "original_file_path": "macros/relations/column/columns_spec_ddl.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/models/incremental/merge.sql", + "path": "macros/relations/column/columns_spec_ddl.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.get_merge_sql" + "unique_id": "macro.dbt.format_columns" }, - "macro.dbt.get_merge_update_columns": { + "macro.dbt.format_row": { "arguments": [], - "created_at": 1696458269.6691232, + "created_at": 1719485736.611443, "depends_on": { "macros": [ - "macro.dbt.default__get_merge_update_columns" + "macro.dbt.string_literal", + "macro.dbt.escape_single_quotes", + "macro.dbt.safe_cast" ] }, "description": "", @@ -4533,25 +4960,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro get_merge_update_columns(merge_update_columns, merge_exclude_columns, dest_columns) %}\n {{ return(adapter.dispatch('get_merge_update_columns', 'dbt')(merge_update_columns, merge_exclude_columns, dest_columns)) }}\n{% endmacro %}", + "macro_sql": "\n\n{%- macro format_row(row, column_name_to_data_types) -%}\n {#-- generate case-insensitive formatted row --#}\n {% set formatted_row = {} %}\n {%- for column_name, column_value in row.items() -%}\n {% set column_name = column_name|lower %}\n\n {%- if column_name not in column_name_to_data_types %}\n {#-- if user-provided row contains column name that relation does not contain, raise an error --#}\n {% set fixture_name = \"expected output\" if model.resource_type == 'unit_test' else (\"'\" ~ model.name ~ \"'\") %}\n {{ exceptions.raise_compiler_error(\n \"Invalid column name: '\" ~ column_name ~ \"' in unit test fixture for \" ~ fixture_name ~ \".\"\n \"\\nAccepted columns for \" ~ fixture_name ~ \" are: \" ~ (column_name_to_data_types.keys()|list)\n ) }}\n {%- endif -%}\n\n {%- set column_type = column_name_to_data_types[column_name] %}\n\n {#-- sanitize column_value: wrap yaml strings in quotes, apply cast --#}\n {%- set column_value_clean = column_value -%}\n {%- if column_value is string -%}\n {%- set column_value_clean = dbt.string_literal(dbt.escape_single_quotes(column_value)) -%}\n {%- elif column_value is none -%}\n {%- set column_value_clean = 'null' -%}\n {%- endif -%}\n\n {%- set row_update = {column_name: safe_cast(column_value_clean, column_type) } -%}\n {%- do formatted_row.update(row_update) -%}\n {%- endfor -%}\n {{ return(formatted_row) }}\n{%- endmacro -%}", "meta": {}, - "name": "get_merge_update_columns", - "original_file_path": "macros/materializations/models/incremental/column_helpers.sql", + "name": "format_row", + "original_file_path": "macros/unit_test_sql/get_fixture_sql.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/models/incremental/column_helpers.sql", + "path": "macros/unit_test_sql/get_fixture_sql.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.get_merge_update_columns" + "unique_id": "macro.dbt.format_row" }, - "macro.dbt.get_or_create_relation": { + "macro.dbt.generate_alias_name": { "arguments": [], - "created_at": 1696458269.816565, + "created_at": 1719485736.466082, "depends_on": { "macros": [ - "macro.dbt.default__get_or_create_relation" + "macro.dbt.default__generate_alias_name" ] }, "description": "", @@ -4559,49 +4984,47 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro get_or_create_relation(database, schema, identifier, type) -%}\n {{ return(adapter.dispatch('get_or_create_relation', 'dbt')(database, schema, identifier, type)) }}\n{% endmacro %}", + "macro_sql": "{% macro generate_alias_name(custom_alias_name=none, node=none) -%}\n {% do return(adapter.dispatch('generate_alias_name', 'dbt')(custom_alias_name, node)) %}\n{%- endmacro %}", "meta": {}, - "name": "get_or_create_relation", - "original_file_path": "macros/adapters/relation.sql", + "name": "generate_alias_name", + "original_file_path": "macros/get_custom_name/get_custom_alias.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/adapters/relation.sql", + "path": "macros/get_custom_name/get_custom_alias.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.get_or_create_relation" + "unique_id": "macro.dbt.generate_alias_name" }, - "macro.dbt.get_quoted_csv": { + "macro.dbt.generate_database_name": { "arguments": [], - "created_at": 1696458269.666624, + "created_at": 1719485736.468485, "depends_on": { - "macros": [] + "macros": [ + "macro.dbt.default__generate_database_name" + ] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro get_quoted_csv(column_names) %}\n\n {% set quoted = [] %}\n {% for col in column_names -%}\n {%- do quoted.append(adapter.quote(col)) -%}\n {%- endfor %}\n\n {%- set dest_cols_csv = quoted | join(', ') -%}\n {{ return(dest_cols_csv) }}\n\n{% endmacro %}", + "macro_sql": "{% macro generate_database_name(custom_database_name=none, node=none) -%}\n {% do return(adapter.dispatch('generate_database_name', 'dbt')(custom_database_name, node)) %}\n{%- endmacro %}", "meta": {}, - "name": "get_quoted_csv", - "original_file_path": "macros/materializations/models/incremental/column_helpers.sql", + "name": "generate_database_name", + "original_file_path": "macros/get_custom_name/get_custom_database.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/models/incremental/column_helpers.sql", + "path": "macros/get_custom_name/get_custom_database.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.get_quoted_csv" + "unique_id": "macro.dbt.generate_database_name" }, - "macro.dbt.get_revoke_sql": { + "macro.dbt.generate_schema_name": { "arguments": [], - "created_at": 1696458269.825689, + "created_at": 1719485736.467374, "depends_on": { "macros": [ - "macro.dbt.default__get_revoke_sql" + "macro.dbt.default__generate_schema_name" ] }, "description": "", @@ -4609,22 +5032,20 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro get_revoke_sql(relation, privilege, grantees) %}\n {{ return(adapter.dispatch('get_revoke_sql', 'dbt')(relation, privilege, grantees)) }}\n{% endmacro %}", + "macro_sql": "{% macro generate_schema_name(custom_schema_name=none, node=none) -%}\n {{ return(adapter.dispatch('generate_schema_name', 'dbt')(custom_schema_name, node)) }}\n{% endmacro %}", "meta": {}, - "name": "get_revoke_sql", - "original_file_path": "macros/adapters/apply_grants.sql", + "name": "generate_schema_name", + "original_file_path": "macros/get_custom_name/get_custom_schema.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/adapters/apply_grants.sql", + "path": "macros/get_custom_name/get_custom_schema.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.get_revoke_sql" + "unique_id": "macro.dbt.generate_schema_name" }, - "macro.dbt.get_seed_column_quoted_csv": { + "macro.dbt.generate_schema_name_for_env": { "arguments": [], - "created_at": 1696458269.75114, + "created_at": 1719485736.46803, "depends_on": { "macros": [] }, @@ -4633,25 +5054,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro get_seed_column_quoted_csv(model, column_names) %}\n {%- set quote_seed_column = model['config'].get('quote_columns', None) -%}\n {% set quoted = [] %}\n {% for col in column_names -%}\n {%- do quoted.append(adapter.quote_seed_column(col, quote_seed_column)) -%}\n {%- endfor %}\n\n {%- set dest_cols_csv = quoted | join(', ') -%}\n {{ return(dest_cols_csv) }}\n{% endmacro %}", + "macro_sql": "{% macro generate_schema_name_for_env(custom_schema_name, node) -%}\n\n {%- set default_schema = target.schema -%}\n {%- if target.name == 'prod' and custom_schema_name is not none -%}\n\n {{ custom_schema_name | trim }}\n\n {%- else -%}\n\n {{ default_schema }}\n\n {%- endif -%}\n\n{%- endmacro %}", "meta": {}, - "name": "get_seed_column_quoted_csv", - "original_file_path": "macros/materializations/seeds/helpers.sql", + "name": "generate_schema_name_for_env", + "original_file_path": "macros/get_custom_name/get_custom_schema.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/seeds/helpers.sql", + "path": "macros/get_custom_name/get_custom_schema.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.get_seed_column_quoted_csv" + "unique_id": "macro.dbt.generate_schema_name_for_env" }, - "macro.dbt.get_show_grant_sql": { + "macro.dbt.generate_series": { "arguments": [], - "created_at": 1696458269.8244731, + "created_at": 1719485736.5352, "depends_on": { "macros": [ - "macro.dbt_postgres.postgres__get_show_grant_sql" + "macro.dbt.default__generate_series" ] }, "description": "", @@ -4659,25 +5078,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro get_show_grant_sql(relation) %}\n {{ return(adapter.dispatch(\"get_show_grant_sql\", \"dbt\")(relation)) }}\n{% endmacro %}", + "macro_sql": "{% macro generate_series(upper_bound) %}\n {{ return(adapter.dispatch('generate_series', 'dbt')(upper_bound)) }}\n{% endmacro %}", "meta": {}, - "name": "get_show_grant_sql", - "original_file_path": "macros/adapters/apply_grants.sql", + "name": "generate_series", + "original_file_path": "macros/utils/generate_series.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/adapters/apply_grants.sql", + "path": "macros/utils/generate_series.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.get_show_grant_sql" + "unique_id": "macro.dbt.generate_series" }, - "macro.dbt.get_test_sql": { + "macro.dbt.get_alter_materialized_view_as_sql": { "arguments": [], - "created_at": 1696458269.662025, + "created_at": 1719485736.492841, "depends_on": { "macros": [ - "macro.dbt.default__get_test_sql" + "macro.dbt_postgres.postgres__get_alter_materialized_view_as_sql" ] }, "description": "", @@ -4685,25 +5102,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro get_test_sql(main_sql, fail_calc, warn_if, error_if, limit) -%}\n {{ adapter.dispatch('get_test_sql', 'dbt')(main_sql, fail_calc, warn_if, error_if, limit) }}\n{%- endmacro %}", + "macro_sql": "{% macro get_alter_materialized_view_as_sql(\n relation,\n configuration_changes,\n sql,\n existing_relation,\n backup_relation,\n intermediate_relation\n) %}\n {{- log('Applying ALTER to: ' ~ relation) -}}\n {{- adapter.dispatch('get_alter_materialized_view_as_sql', 'dbt')(\n relation,\n configuration_changes,\n sql,\n existing_relation,\n backup_relation,\n intermediate_relation\n ) -}}\n{% endmacro %}", "meta": {}, - "name": "get_test_sql", - "original_file_path": "macros/materializations/tests/helpers.sql", + "name": "get_alter_materialized_view_as_sql", + "original_file_path": "macros/relations/materialized_view/alter.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/tests/helpers.sql", + "path": "macros/relations/materialized_view/alter.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.get_test_sql" + "unique_id": "macro.dbt.get_alter_materialized_view_as_sql" }, - "macro.dbt.get_true_sql": { + "macro.dbt.get_assert_columns_equivalent": { "arguments": [], - "created_at": 1696458269.617147, + "created_at": 1719485736.5009642, "depends_on": { "macros": [ - "macro.dbt.default__get_true_sql" + "macro.dbt.default__get_assert_columns_equivalent" ] }, "description": "", @@ -4711,25 +5126,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro get_true_sql() %}\n {{ adapter.dispatch('get_true_sql', 'dbt')() }}\n{% endmacro %}", + "macro_sql": "\n\n{%- macro get_assert_columns_equivalent(sql) -%}\n {{ adapter.dispatch('get_assert_columns_equivalent', 'dbt')(sql) }}\n{%- endmacro -%}\n\n", "meta": {}, - "name": "get_true_sql", - "original_file_path": "macros/materializations/snapshots/helpers.sql", + "name": "get_assert_columns_equivalent", + "original_file_path": "macros/relations/column/columns_spec_ddl.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/snapshots/helpers.sql", + "path": "macros/relations/column/columns_spec_ddl.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.get_true_sql" + "unique_id": "macro.dbt.get_assert_columns_equivalent" }, - "macro.dbt.get_where_subquery": { + "macro.dbt.get_batch_size": { "arguments": [], - "created_at": 1696458269.663287, + "created_at": 1719485736.461688, "depends_on": { "macros": [ - "macro.dbt.default__get_where_subquery" + "macro.dbt.default__get_batch_size" ] }, "description": "", @@ -4737,25 +5150,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro get_where_subquery(relation) -%}\n {% do return(adapter.dispatch('get_where_subquery', 'dbt')(relation)) %}\n{%- endmacro %}", + "macro_sql": "{% macro get_batch_size() -%}\n {{ return(adapter.dispatch('get_batch_size', 'dbt')()) }}\n{%- endmacro %}", "meta": {}, - "name": "get_where_subquery", - "original_file_path": "macros/materializations/tests/where_subquery.sql", + "name": "get_batch_size", + "original_file_path": "macros/materializations/seeds/helpers.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/tests/where_subquery.sql", + "path": "macros/materializations/seeds/helpers.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.get_where_subquery" + "unique_id": "macro.dbt.get_batch_size" }, - "macro.dbt.handle_existing_table": { + "macro.dbt.get_binding_char": { "arguments": [], - "created_at": 1696458269.7268028, + "created_at": 1719485736.461306, "depends_on": { "macros": [ - "macro.dbt.default__handle_existing_table" + "macro.dbt.default__get_binding_char" ] }, "description": "", @@ -4763,25 +5174,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro handle_existing_table(full_refresh, old_relation) %}\n {{ adapter.dispatch('handle_existing_table', 'dbt')(full_refresh, old_relation) }}\n{% endmacro %}", + "macro_sql": "{% macro get_binding_char() -%}\n {{ adapter.dispatch('get_binding_char', 'dbt')() }}\n{%- endmacro %}", "meta": {}, - "name": "handle_existing_table", - "original_file_path": "macros/materializations/models/view/helpers.sql", + "name": "get_binding_char", + "original_file_path": "macros/materializations/seeds/helpers.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/models/view/helpers.sql", + "path": "macros/materializations/seeds/helpers.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.handle_existing_table" + "unique_id": "macro.dbt.get_binding_char" }, - "macro.dbt.hash": { + "macro.dbt.get_catalog": { "arguments": [], - "created_at": 1696458269.7828329, + "created_at": 1719485736.590185, "depends_on": { "macros": [ - "macro.dbt.default__hash" + "macro.dbt_postgres.postgres__get_catalog" ] }, "description": "", @@ -4789,25 +5198,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro hash(field) -%}\n {{ return(adapter.dispatch('hash', 'dbt') (field)) }}\n{%- endmacro %}", + "macro_sql": "{% macro get_catalog(information_schema, schemas) -%}\n {{ return(adapter.dispatch('get_catalog', 'dbt')(information_schema, schemas)) }}\n{%- endmacro %}", "meta": {}, - "name": "hash", - "original_file_path": "macros/utils/hash.sql", + "name": "get_catalog", + "original_file_path": "macros/adapters/metadata.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/utils/hash.sql", + "path": "macros/adapters/metadata.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.hash" + "unique_id": "macro.dbt.get_catalog" }, - "macro.dbt.in_transaction": { + "macro.dbt.get_catalog_relations": { "arguments": [], - "created_at": 1696458269.591626, + "created_at": 1719485736.5896802, "depends_on": { "macros": [ - "macro.dbt.make_hook_config" + "macro.dbt_postgres.postgres__get_catalog_relations" ] }, "description": "", @@ -4815,49 +5222,47 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro in_transaction(sql) %}\n {{ make_hook_config(sql, inside_transaction=True) }}\n{% endmacro %}", + "macro_sql": "{% macro get_catalog_relations(information_schema, relations) -%}\n {{ return(adapter.dispatch('get_catalog_relations', 'dbt')(information_schema, relations)) }}\n{%- endmacro %}", "meta": {}, - "name": "in_transaction", - "original_file_path": "macros/materializations/hooks.sql", + "name": "get_catalog_relations", + "original_file_path": "macros/adapters/metadata.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/hooks.sql", + "path": "macros/adapters/metadata.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.in_transaction" + "unique_id": "macro.dbt.get_catalog_relations" }, - "macro.dbt.incremental_validate_on_schema_change": { + "macro.dbt.get_column_schema_from_query": { "arguments": [], - "created_at": 1696458269.708918, + "created_at": 1719485736.598866, "depends_on": { - "macros": [] + "macros": [ + "macro.dbt.get_empty_subquery_sql" + ] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro incremental_validate_on_schema_change(on_schema_change, default='ignore') %}\n\n {% if on_schema_change not in ['sync_all_columns', 'append_new_columns', 'fail', 'ignore'] %}\n\n {% set log_message = 'Invalid value for on_schema_change (%s) specified. Setting default value of %s.' % (on_schema_change, default) %}\n {% do log(log_message) %}\n\n {{ return(default) }}\n\n {% else %}\n\n {{ return(on_schema_change) }}\n\n {% endif %}\n\n{% endmacro %}", + "macro_sql": "{% macro get_column_schema_from_query(select_sql, select_sql_header=none) -%}\n {% set columns = [] %}\n {# -- Using an 'empty subquery' here to get the same schema as the given select_sql statement, without necessitating a data scan.#}\n {% set sql = get_empty_subquery_sql(select_sql, select_sql_header) %}\n {% set column_schema = adapter.get_column_schema_from_query(sql) %}\n {{ return(column_schema) }}\n{% endmacro %}", "meta": {}, - "name": "incremental_validate_on_schema_change", - "original_file_path": "macros/materializations/models/incremental/on_schema_change.sql", + "name": "get_column_schema_from_query", + "original_file_path": "macros/adapters/columns.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/models/incremental/on_schema_change.sql", + "path": "macros/adapters/columns.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.incremental_validate_on_schema_change" + "unique_id": "macro.dbt.get_column_schema_from_query" }, - "macro.dbt.information_schema_name": { + "macro.dbt.get_columns_in_query": { "arguments": [], - "created_at": 1696458269.837573, + "created_at": 1719485736.5990841, "depends_on": { "macros": [ - "macro.dbt_postgres.postgres__information_schema_name" + "macro.dbt.default__get_columns_in_query" ] }, "description": "", @@ -4865,25 +5270,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro information_schema_name(database) %}\n {{ return(adapter.dispatch('information_schema_name', 'dbt')(database)) }}\n{% endmacro %}", + "macro_sql": "{% macro get_columns_in_query(select_sql) -%}\n {{ return(adapter.dispatch('get_columns_in_query', 'dbt')(select_sql)) }}\n{% endmacro %}", "meta": {}, - "name": "information_schema_name", - "original_file_path": "macros/adapters/metadata.sql", + "name": "get_columns_in_query", + "original_file_path": "macros/adapters/columns.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/adapters/metadata.sql", + "path": "macros/adapters/columns.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.information_schema_name" + "unique_id": "macro.dbt.get_columns_in_query" }, - "macro.dbt.intersect": { + "macro.dbt.get_columns_in_relation": { "arguments": [], - "created_at": 1696458269.776766, + "created_at": 1719485736.596093, "depends_on": { "macros": [ - "macro.dbt.default__intersect" + "macro.dbt_postgres.postgres__get_columns_in_relation" ] }, "description": "", @@ -4891,25 +5294,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro intersect() %}\n {{ return(adapter.dispatch('intersect', 'dbt')()) }}\n{% endmacro %}", + "macro_sql": "{% macro get_columns_in_relation(relation) -%}\n {{ return(adapter.dispatch('get_columns_in_relation', 'dbt')(relation)) }}\n{% endmacro %}", "meta": {}, - "name": "intersect", - "original_file_path": "macros/utils/intersect.sql", + "name": "get_columns_in_relation", + "original_file_path": "macros/adapters/columns.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/utils/intersect.sql", + "path": "macros/adapters/columns.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.intersect" + "unique_id": "macro.dbt.get_columns_in_relation" }, - "macro.dbt.is_incremental": { + "macro.dbt.get_create_backup_sql": { "arguments": [], - "created_at": 1696458269.6853771, + "created_at": 1719485736.478982, "depends_on": { "macros": [ - "macro.dbt.should_full_refresh" + "macro.dbt.default__get_create_backup_sql" ] }, "description": "", @@ -4917,25 +5318,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro is_incremental() %}\n {#-- do not run introspective queries in parsing #}\n {% if not execute %}\n {{ return(False) }}\n {% else %}\n {% set relation = adapter.get_relation(this.database, this.schema, this.table) %}\n {{ return(relation is not none\n and relation.type == 'table'\n and model.config.materialized == 'incremental'\n and not should_full_refresh()) }}\n {% endif %}\n{% endmacro %}", + "macro_sql": "{%- macro get_create_backup_sql(relation) -%}\n {{- log('Applying CREATE BACKUP to: ' ~ relation) -}}\n {{- adapter.dispatch('get_create_backup_sql', 'dbt')(relation) -}}\n{%- endmacro -%}\n\n\n", "meta": {}, - "name": "is_incremental", - "original_file_path": "macros/materializations/models/incremental/is_incremental.sql", + "name": "get_create_backup_sql", + "original_file_path": "macros/relations/create_backup.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/models/incremental/is_incremental.sql", + "path": "macros/relations/create_backup.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.is_incremental" + "unique_id": "macro.dbt.get_create_backup_sql" }, - "macro.dbt.last_day": { + "macro.dbt.get_create_index_sql": { "arguments": [], - "created_at": 1696458269.794115, + "created_at": 1719485736.561203, "depends_on": { "macros": [ - "macro.dbt_postgres.postgres__last_day" + "macro.dbt_postgres.postgres__get_create_index_sql" ] }, "description": "", @@ -4943,25 +5342,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro last_day(date, datepart) %}\n {{ return(adapter.dispatch('last_day', 'dbt') (date, datepart)) }}\n{% endmacro %}", + "macro_sql": "{% macro get_create_index_sql(relation, index_dict) -%}\n {{ return(adapter.dispatch('get_create_index_sql', 'dbt')(relation, index_dict)) }}\n{% endmacro %}", "meta": {}, - "name": "last_day", - "original_file_path": "macros/utils/last_day.sql", + "name": "get_create_index_sql", + "original_file_path": "macros/adapters/indexes.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/utils/last_day.sql", + "path": "macros/adapters/indexes.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.last_day" + "unique_id": "macro.dbt.get_create_index_sql" }, - "macro.dbt.length": { + "macro.dbt.get_create_intermediate_sql": { "arguments": [], - "created_at": 1696458269.775149, + "created_at": 1719485736.4755762, "depends_on": { "macros": [ - "macro.dbt.default__length" + "macro.dbt.default__get_create_intermediate_sql" ] }, "description": "", @@ -4969,25 +5366,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro length(expression) -%}\n {{ return(adapter.dispatch('length', 'dbt') (expression)) }}\n{% endmacro %}", + "macro_sql": "{%- macro get_create_intermediate_sql(relation, sql) -%}\n {{- log('Applying CREATE INTERMEDIATE to: ' ~ relation) -}}\n {{- adapter.dispatch('get_create_intermediate_sql', 'dbt')(relation, sql) -}}\n{%- endmacro -%}\n\n\n", "meta": {}, - "name": "length", - "original_file_path": "macros/utils/length.sql", + "name": "get_create_intermediate_sql", + "original_file_path": "macros/relations/create_intermediate.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/utils/length.sql", + "path": "macros/relations/create_intermediate.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.length" + "unique_id": "macro.dbt.get_create_intermediate_sql" }, - "macro.dbt.list_relations_without_caching": { + "macro.dbt.get_create_materialized_view_as_sql": { "arguments": [], - "created_at": 1696458269.839705, + "created_at": 1719485736.4946408, "depends_on": { "macros": [ - "macro.dbt_postgres.postgres__list_relations_without_caching" + "macro.dbt_postgres.postgres__get_create_materialized_view_as_sql" ] }, "description": "", @@ -4995,25 +5390,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro list_relations_without_caching(schema_relation) %}\n {{ return(adapter.dispatch('list_relations_without_caching', 'dbt')(schema_relation)) }}\n{% endmacro %}", + "macro_sql": "{% macro get_create_materialized_view_as_sql(relation, sql) -%}\n {{- adapter.dispatch('get_create_materialized_view_as_sql', 'dbt')(relation, sql) -}}\n{%- endmacro %}", "meta": {}, - "name": "list_relations_without_caching", - "original_file_path": "macros/adapters/metadata.sql", + "name": "get_create_materialized_view_as_sql", + "original_file_path": "macros/relations/materialized_view/create.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/adapters/metadata.sql", + "path": "macros/relations/materialized_view/create.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.list_relations_without_caching" + "unique_id": "macro.dbt.get_create_materialized_view_as_sql" }, - "macro.dbt.list_schemas": { + "macro.dbt.get_create_sql": { "arguments": [], - "created_at": 1696458269.838121, + "created_at": 1719485736.481704, "depends_on": { "macros": [ - "macro.dbt_postgres.postgres__list_schemas" + "macro.dbt.default__get_create_sql" ] }, "description": "", @@ -5021,25 +5414,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro list_schemas(database) -%}\n {{ return(adapter.dispatch('list_schemas', 'dbt')(database)) }}\n{% endmacro %}", + "macro_sql": "{%- macro get_create_sql(relation, sql) -%}\n {{- log('Applying CREATE to: ' ~ relation) -}}\n {{- adapter.dispatch('get_create_sql', 'dbt')(relation, sql) -}}\n{%- endmacro -%}\n\n\n", "meta": {}, - "name": "list_schemas", - "original_file_path": "macros/adapters/metadata.sql", + "name": "get_create_sql", + "original_file_path": "macros/relations/create.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/adapters/metadata.sql", + "path": "macros/relations/create.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.list_schemas" + "unique_id": "macro.dbt.get_create_sql" }, - "macro.dbt.listagg": { + "macro.dbt.get_create_table_as_sql": { "arguments": [], - "created_at": 1696458269.779763, + "created_at": 1719485736.50801, "depends_on": { "macros": [ - "macro.dbt_postgres.postgres__listagg" + "macro.dbt.default__get_create_table_as_sql" ] }, "description": "", @@ -5047,49 +5438,47 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro listagg(measure, delimiter_text=\"','\", order_by_clause=none, limit_num=none) -%}\n {{ return(adapter.dispatch('listagg', 'dbt') (measure, delimiter_text, order_by_clause, limit_num)) }}\n{%- endmacro %}", + "macro_sql": "{% macro get_create_table_as_sql(temporary, relation, sql) -%}\n {{ adapter.dispatch('get_create_table_as_sql', 'dbt')(temporary, relation, sql) }}\n{%- endmacro %}", "meta": {}, - "name": "listagg", - "original_file_path": "macros/utils/listagg.sql", + "name": "get_create_table_as_sql", + "original_file_path": "macros/relations/table/create.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/utils/listagg.sql", + "path": "macros/relations/table/create.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.listagg" + "unique_id": "macro.dbt.get_create_table_as_sql" }, - "macro.dbt.load_cached_relation": { + "macro.dbt.get_create_view_as_sql": { "arguments": [], - "created_at": 1696458269.817961, + "created_at": 1719485736.515145, "depends_on": { - "macros": [] + "macros": [ + "macro.dbt.default__get_create_view_as_sql" + ] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro load_cached_relation(relation) %}\n {% do return(adapter.get_relation(\n database=relation.database,\n schema=relation.schema,\n identifier=relation.identifier\n )) -%}\n{% endmacro %}", + "macro_sql": "{% macro get_create_view_as_sql(relation, sql) -%}\n {{ adapter.dispatch('get_create_view_as_sql', 'dbt')(relation, sql) }}\n{%- endmacro %}", "meta": {}, - "name": "load_cached_relation", - "original_file_path": "macros/adapters/relation.sql", + "name": "get_create_view_as_sql", + "original_file_path": "macros/relations/view/create.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/adapters/relation.sql", + "path": "macros/relations/view/create.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.load_cached_relation" + "unique_id": "macro.dbt.get_create_view_as_sql" }, - "macro.dbt.load_csv_rows": { + "macro.dbt.get_csv_sql": { "arguments": [], - "created_at": 1696458269.751443, + "created_at": 1719485736.460989, "depends_on": { "macros": [ - "macro.dbt.default__load_csv_rows" + "macro.dbt.default__get_csv_sql" ] }, "description": "", @@ -5097,25 +5486,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro load_csv_rows(model, agate_table) -%}\n {{ adapter.dispatch('load_csv_rows', 'dbt')(model, agate_table) }}\n{%- endmacro %}", + "macro_sql": "{% macro get_csv_sql(create_or_truncate_sql, insert_sql) %}\n {{ adapter.dispatch('get_csv_sql', 'dbt')(create_or_truncate_sql, insert_sql) }}\n{% endmacro %}", "meta": {}, - "name": "load_csv_rows", + "name": "get_csv_sql", "original_file_path": "macros/materializations/seeds/helpers.sql", "package_name": "dbt", "patch_path": null, "path": "macros/materializations/seeds/helpers.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.load_csv_rows" + "unique_id": "macro.dbt.get_csv_sql" }, - "macro.dbt.load_relation": { + "macro.dbt.get_dcl_statement_list": { "arguments": [], - "created_at": 1696458269.8181918, + "created_at": 1719485736.574908, "depends_on": { "macros": [ - "macro.dbt.load_cached_relation" + "macro.dbt.default__get_dcl_statement_list" ] }, "description": "", @@ -5123,25 +5510,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro load_relation(relation) %}\n {{ return(load_cached_relation(relation)) }}\n{% endmacro %}", + "macro_sql": "{% macro get_dcl_statement_list(relation, grant_config, get_dcl_macro) %}\n {{ return(adapter.dispatch('get_dcl_statement_list', 'dbt')(relation, grant_config, get_dcl_macro)) }}\n{% endmacro %}", "meta": {}, - "name": "load_relation", - "original_file_path": "macros/adapters/relation.sql", + "name": "get_dcl_statement_list", + "original_file_path": "macros/adapters/apply_grants.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/adapters/relation.sql", + "path": "macros/adapters/apply_grants.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.load_relation" + "unique_id": "macro.dbt.get_dcl_statement_list" }, - "macro.dbt.make_backup_relation": { + "macro.dbt.get_delete_insert_merge_sql": { "arguments": [], - "created_at": 1696458269.813613, + "created_at": 1719485736.414207, "depends_on": { "macros": [ - "macro.dbt_postgres.postgres__make_backup_relation" + "macro.dbt.default__get_delete_insert_merge_sql" ] }, "description": "", @@ -5149,49 +5534,47 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro make_backup_relation(base_relation, backup_relation_type, suffix='__dbt_backup') %}\n {{ return(adapter.dispatch('make_backup_relation', 'dbt')(base_relation, backup_relation_type, suffix)) }}\n{% endmacro %}", + "macro_sql": "{% macro get_delete_insert_merge_sql(target, source, unique_key, dest_columns, incremental_predicates) -%}\n {{ adapter.dispatch('get_delete_insert_merge_sql', 'dbt')(target, source, unique_key, dest_columns, incremental_predicates) }}\n{%- endmacro %}", "meta": {}, - "name": "make_backup_relation", - "original_file_path": "macros/adapters/relation.sql", + "name": "get_delete_insert_merge_sql", + "original_file_path": "macros/materializations/models/incremental/merge.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/adapters/relation.sql", + "path": "macros/materializations/models/incremental/merge.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.make_backup_relation" + "unique_id": "macro.dbt.get_delete_insert_merge_sql" }, - "macro.dbt.make_hook_config": { + "macro.dbt.get_drop_backup_sql": { "arguments": [], - "created_at": 1696458269.591149, + "created_at": 1719485736.476695, "depends_on": { - "macros": [] + "macros": [ + "macro.dbt.default__get_drop_backup_sql" + ] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro make_hook_config(sql, inside_transaction) %}\n {{ tojson({\"sql\": sql, \"transaction\": inside_transaction}) }}\n{% endmacro %}", + "macro_sql": "{%- macro get_drop_backup_sql(relation) -%}\n {{- log('Applying DROP BACKUP to: ' ~ relation) -}}\n {{- adapter.dispatch('get_drop_backup_sql', 'dbt')(relation) -}}\n{%- endmacro -%}\n\n\n", "meta": {}, - "name": "make_hook_config", - "original_file_path": "macros/materializations/hooks.sql", + "name": "get_drop_backup_sql", + "original_file_path": "macros/relations/drop_backup.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/hooks.sql", + "path": "macros/relations/drop_backup.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.make_hook_config" + "unique_id": "macro.dbt.get_drop_backup_sql" }, - "macro.dbt.make_intermediate_relation": { + "macro.dbt.get_drop_index_sql": { "arguments": [], - "created_at": 1696458269.8121102, + "created_at": 1719485736.562388, "depends_on": { "macros": [ - "macro.dbt_postgres.postgres__make_intermediate_relation" + "macro.dbt_postgres.postgres__get_drop_index_sql" ] }, "description": "", @@ -5199,25 +5582,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro make_intermediate_relation(base_relation, suffix='__dbt_tmp') %}\n {{ return(adapter.dispatch('make_intermediate_relation', 'dbt')(base_relation, suffix)) }}\n{% endmacro %}", + "macro_sql": "{% macro get_drop_index_sql(relation, index_name) -%}\n {{ adapter.dispatch('get_drop_index_sql', 'dbt')(relation, index_name) }}\n{%- endmacro %}", "meta": {}, - "name": "make_intermediate_relation", - "original_file_path": "macros/adapters/relation.sql", + "name": "get_drop_index_sql", + "original_file_path": "macros/adapters/indexes.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/adapters/relation.sql", + "path": "macros/adapters/indexes.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.make_intermediate_relation" + "unique_id": "macro.dbt.get_drop_index_sql" }, - "macro.dbt.make_temp_relation": { + "macro.dbt.get_drop_sql": { "arguments": [], - "created_at": 1696458269.812738, + "created_at": 1719485736.470603, "depends_on": { "macros": [ - "macro.dbt_postgres.postgres__make_temp_relation" + "macro.dbt.default__get_drop_sql" ] }, "description": "", @@ -5225,40 +5606,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro make_temp_relation(base_relation, suffix='__dbt_tmp') %}\n {{ return(adapter.dispatch('make_temp_relation', 'dbt')(base_relation, suffix)) }}\n{% endmacro %}", + "macro_sql": "{%- macro get_drop_sql(relation) -%}\n {{- log('Applying DROP to: ' ~ relation) -}}\n {{- adapter.dispatch('get_drop_sql', 'dbt')(relation) -}}\n{%- endmacro -%}\n\n\n", "meta": {}, - "name": "make_temp_relation", - "original_file_path": "macros/adapters/relation.sql", + "name": "get_drop_sql", + "original_file_path": "macros/relations/drop.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/adapters/relation.sql", + "path": "macros/relations/drop.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.make_temp_relation" + "unique_id": "macro.dbt.get_drop_sql" }, - "macro.dbt.materialization_incremental_default": { + "macro.dbt.get_empty_schema_sql": { "arguments": [], - "created_at": 1696458269.699362, + "created_at": 1719485736.597257, "depends_on": { "macros": [ - "macro.dbt.load_cached_relation", - "macro.dbt.make_temp_relation", - "macro.dbt.make_intermediate_relation", - "macro.dbt.make_backup_relation", - "macro.dbt.should_full_refresh", - "macro.dbt.incremental_validate_on_schema_change", - "macro.dbt.drop_relation_if_exists", - "macro.dbt.run_hooks", - "macro.dbt.get_create_table_as_sql", - "macro.dbt.run_query", - "macro.dbt.process_schema_changes", - "macro.dbt.statement", - "macro.dbt.should_revoke", - "macro.dbt.apply_grants", - "macro.dbt.persist_docs", - "macro.dbt.create_indexes" + "macro.dbt.default__get_empty_schema_sql" ] }, "description": "", @@ -5266,37 +5630,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% materialization incremental, default -%}\n\n -- relations\n {%- set existing_relation = load_cached_relation(this) -%}\n {%- set target_relation = this.incorporate(type='table') -%}\n {%- set temp_relation = make_temp_relation(target_relation)-%}\n {%- set intermediate_relation = make_intermediate_relation(target_relation)-%}\n {%- set backup_relation_type = 'table' if existing_relation is none else existing_relation.type -%}\n {%- set backup_relation = make_backup_relation(target_relation, backup_relation_type) -%}\n\n -- configs\n {%- set unique_key = config.get('unique_key') -%}\n {%- set full_refresh_mode = (should_full_refresh() or existing_relation.is_view) -%}\n {%- set on_schema_change = incremental_validate_on_schema_change(config.get('on_schema_change'), default='ignore') -%}\n\n -- the temp_ and backup_ relations should not already exist in the database; get_relation\n -- will return None in that case. Otherwise, we get a relation that we can drop\n -- later, before we try to use this name for the current operation. This has to happen before\n -- BEGIN, in a separate transaction\n {%- set preexisting_intermediate_relation = load_cached_relation(intermediate_relation)-%}\n {%- set preexisting_backup_relation = load_cached_relation(backup_relation) -%}\n -- grab current tables grants config for comparison later on\n {% set grant_config = config.get('grants') %}\n {{ drop_relation_if_exists(preexisting_intermediate_relation) }}\n {{ drop_relation_if_exists(preexisting_backup_relation) }}\n\n {{ run_hooks(pre_hooks, inside_transaction=False) }}\n\n -- `BEGIN` happens here:\n {{ run_hooks(pre_hooks, inside_transaction=True) }}\n\n {% set to_drop = [] %}\n\n {% if existing_relation is none %}\n {% set build_sql = get_create_table_as_sql(False, target_relation, sql) %}\n {% elif full_refresh_mode %}\n {% set build_sql = get_create_table_as_sql(False, intermediate_relation, sql) %}\n {% set need_swap = true %}\n {% else %}\n {% do run_query(get_create_table_as_sql(True, temp_relation, sql)) %}\n {% do adapter.expand_target_column_types(\n from_relation=temp_relation,\n to_relation=target_relation) %}\n {#-- Process schema changes. Returns dict of changes if successful. Use source columns for upserting/merging --#}\n {% set dest_columns = process_schema_changes(on_schema_change, temp_relation, existing_relation) %}\n {% if not dest_columns %}\n {% set dest_columns = adapter.get_columns_in_relation(existing_relation) %}\n {% endif %}\n\n {#-- Get the incremental_strategy, the macro to use for the strategy, and build the sql --#}\n {% set incremental_strategy = config.get('incremental_strategy') or 'default' %}\n {% set incremental_predicates = config.get('incremental_predicates', none) %}\n {% set strategy_sql_macro_func = adapter.get_incremental_strategy_macro(context, incremental_strategy) %}\n {% set strategy_arg_dict = ({'target_relation': target_relation, 'temp_relation': temp_relation, 'unique_key': unique_key, 'dest_columns': dest_columns, 'predicates': incremental_predicates }) %}\n {% set build_sql = strategy_sql_macro_func(strategy_arg_dict) %}\n\n {% endif %}\n\n {% call statement(\"main\") %}\n {{ build_sql }}\n {% endcall %}\n\n {% if need_swap %}\n {% do adapter.rename_relation(target_relation, backup_relation) %}\n {% do adapter.rename_relation(intermediate_relation, target_relation) %}\n {% do to_drop.append(backup_relation) %}\n {% endif %}\n\n {% set should_revoke = should_revoke(existing_relation, full_refresh_mode) %}\n {% do apply_grants(target_relation, grant_config, should_revoke=should_revoke) %}\n\n {% do persist_docs(target_relation, model) %}\n\n {% if existing_relation is none or existing_relation.is_view or should_full_refresh() %}\n {% do create_indexes(target_relation) %}\n {% endif %}\n\n {{ run_hooks(post_hooks, inside_transaction=True) }}\n\n -- `COMMIT` happens here\n {% do adapter.commit() %}\n\n {% for rel in to_drop %}\n {% do adapter.drop_relation(rel) %}\n {% endfor %}\n\n {{ run_hooks(post_hooks, inside_transaction=False) }}\n\n {{ return({'relations': [target_relation]}) }}\n\n{%- endmaterialization %}", + "macro_sql": "{% macro get_empty_schema_sql(columns) -%}\n {{ return(adapter.dispatch('get_empty_schema_sql', 'dbt')(columns)) }}\n{% endmacro %}", "meta": {}, - "name": "materialization_incremental_default", - "original_file_path": "macros/materializations/models/incremental/incremental.sql", + "name": "get_empty_schema_sql", + "original_file_path": "macros/adapters/columns.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/models/incremental/incremental.sql", + "path": "macros/adapters/columns.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", - "supported_languages": [ - "sql" - ], - "tags": [], - "unique_id": "macro.dbt.materialization_incremental_default" + "supported_languages": null, + "unique_id": "macro.dbt.get_empty_schema_sql" }, - "macro.dbt.materialization_seed_default": { + "macro.dbt.get_empty_subquery_sql": { "arguments": [], - "created_at": 1696458269.737411, + "created_at": 1719485736.596842, "depends_on": { "macros": [ - "macro.dbt.should_full_refresh", - "macro.dbt.run_hooks", - "macro.dbt.reset_csv_table", - "macro.dbt.create_csv_table", - "macro.dbt.load_csv_rows", - "macro.dbt.noop_statement", - "macro.dbt.get_csv_sql", - "macro.dbt.should_revoke", - "macro.dbt.apply_grants", - "macro.dbt.persist_docs", - "macro.dbt.create_indexes" + "macro.dbt.default__get_empty_subquery_sql" ] }, "description": "", @@ -5304,40 +5654,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% materialization seed, default %}\n\n {%- set identifier = model['alias'] -%}\n {%- set full_refresh_mode = (should_full_refresh()) -%}\n\n {%- set old_relation = adapter.get_relation(database=database, schema=schema, identifier=identifier) -%}\n\n {%- set exists_as_table = (old_relation is not none and old_relation.is_table) -%}\n {%- set exists_as_view = (old_relation is not none and old_relation.is_view) -%}\n\n {%- set grant_config = config.get('grants') -%}\n {%- set agate_table = load_agate_table() -%}\n -- grab current tables grants config for comparison later on\n\n {%- do store_result('agate_table', response='OK', agate_table=agate_table) -%}\n\n {{ run_hooks(pre_hooks, inside_transaction=False) }}\n\n -- `BEGIN` happens here:\n {{ run_hooks(pre_hooks, inside_transaction=True) }}\n\n -- build model\n {% set create_table_sql = \"\" %}\n {% if exists_as_view %}\n {{ exceptions.raise_compiler_error(\"Cannot seed to '{}', it is a view\".format(old_relation)) }}\n {% elif exists_as_table %}\n {% set create_table_sql = reset_csv_table(model, full_refresh_mode, old_relation, agate_table) %}\n {% else %}\n {% set create_table_sql = create_csv_table(model, agate_table) %}\n {% endif %}\n\n {% set code = 'CREATE' if full_refresh_mode else 'INSERT' %}\n {% set rows_affected = (agate_table.rows | length) %}\n {% set sql = load_csv_rows(model, agate_table) %}\n\n {% call noop_statement('main', code ~ ' ' ~ rows_affected, code, rows_affected) %}\n {{ get_csv_sql(create_table_sql, sql) }};\n {% endcall %}\n\n {% set target_relation = this.incorporate(type='table') %}\n\n {% set should_revoke = should_revoke(old_relation, full_refresh_mode) %}\n {% do apply_grants(target_relation, grant_config, should_revoke=should_revoke) %}\n\n {% do persist_docs(target_relation, model) %}\n\n {% if full_refresh_mode or not exists_as_table %}\n {% do create_indexes(target_relation) %}\n {% endif %}\n\n {{ run_hooks(post_hooks, inside_transaction=True) }}\n\n -- `COMMIT` happens here\n {{ adapter.commit() }}\n\n {{ run_hooks(post_hooks, inside_transaction=False) }}\n\n {{ return({'relations': [target_relation]}) }}\n\n{% endmaterialization %}", + "macro_sql": "{% macro get_empty_subquery_sql(select_sql, select_sql_header=none) -%}\n {{ return(adapter.dispatch('get_empty_subquery_sql', 'dbt')(select_sql, select_sql_header)) }}\n{% endmacro %}", "meta": {}, - "name": "materialization_seed_default", - "original_file_path": "macros/materializations/seeds/seed.sql", + "name": "get_empty_subquery_sql", + "original_file_path": "macros/adapters/columns.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/seeds/seed.sql", + "path": "macros/adapters/columns.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", - "supported_languages": [ - "sql" - ], - "tags": [], - "unique_id": "macro.dbt.materialization_seed_default" + "supported_languages": null, + "unique_id": "macro.dbt.get_empty_subquery_sql" }, - "macro.dbt.materialization_snapshot_default": { + "macro.dbt.get_expected_sql": { "arguments": [], - "created_at": 1696458269.65698, + "created_at": 1719485736.608841, "depends_on": { "macros": [ - "macro.dbt.get_or_create_relation", - "macro.dbt.run_hooks", - "macro.dbt.strategy_dispatch", - "macro.dbt.build_snapshot_table", - "macro.dbt.create_table_as", - "macro.dbt.build_snapshot_staging_table", - "macro.dbt.create_columns", - "macro.dbt.snapshot_merge_sql", - "macro.dbt.statement", - "macro.dbt.should_revoke", - "macro.dbt.apply_grants", - "macro.dbt.persist_docs", - "macro.dbt.create_indexes", - "macro.dbt.post_snapshot" + "macro.dbt.format_row" ] }, "description": "", @@ -5345,37 +5678,25 @@ "node_color": null, "show": true }, - "macro_sql": "{% materialization snapshot, default %}\n {%- set config = model['config'] -%}\n\n {%- set target_table = model.get('alias', model.get('name')) -%}\n\n {%- set strategy_name = config.get('strategy') -%}\n {%- set unique_key = config.get('unique_key') %}\n -- grab current tables grants config for comparison later on\n {%- set grant_config = config.get('grants') -%}\n\n {% set target_relation_exists, target_relation = get_or_create_relation(\n database=model.database,\n schema=model.schema,\n identifier=target_table,\n type='table') -%}\n\n {%- if not target_relation.is_table -%}\n {% do exceptions.relation_wrong_type(target_relation, 'table') %}\n {%- endif -%}\n\n\n {{ run_hooks(pre_hooks, inside_transaction=False) }}\n\n {{ run_hooks(pre_hooks, inside_transaction=True) }}\n\n {% set strategy_macro = strategy_dispatch(strategy_name) %}\n {% set strategy = strategy_macro(model, \"snapshotted_data\", \"source_data\", config, target_relation_exists) %}\n\n {% if not target_relation_exists %}\n\n {% set build_sql = build_snapshot_table(strategy, model['compiled_code']) %}\n {% set final_sql = create_table_as(False, target_relation, build_sql) %}\n\n {% else %}\n\n {{ adapter.valid_snapshot_target(target_relation) }}\n\n {% set staging_table = build_snapshot_staging_table(strategy, sql, target_relation) %}\n\n -- this may no-op if the database does not require column expansion\n {% do adapter.expand_target_column_types(from_relation=staging_table,\n to_relation=target_relation) %}\n\n {% set missing_columns = adapter.get_missing_columns(staging_table, target_relation)\n | rejectattr('name', 'equalto', 'dbt_change_type')\n | rejectattr('name', 'equalto', 'DBT_CHANGE_TYPE')\n | rejectattr('name', 'equalto', 'dbt_unique_key')\n | rejectattr('name', 'equalto', 'DBT_UNIQUE_KEY')\n | list %}\n\n {% do create_columns(target_relation, missing_columns) %}\n\n {% set source_columns = adapter.get_columns_in_relation(staging_table)\n | rejectattr('name', 'equalto', 'dbt_change_type')\n | rejectattr('name', 'equalto', 'DBT_CHANGE_TYPE')\n | rejectattr('name', 'equalto', 'dbt_unique_key')\n | rejectattr('name', 'equalto', 'DBT_UNIQUE_KEY')\n | list %}\n\n {% set quoted_source_columns = [] %}\n {% for column in source_columns %}\n {% do quoted_source_columns.append(adapter.quote(column.name)) %}\n {% endfor %}\n\n {% set final_sql = snapshot_merge_sql(\n target = target_relation,\n source = staging_table,\n insert_cols = quoted_source_columns\n )\n %}\n\n {% endif %}\n\n {% call statement('main') %}\n {{ final_sql }}\n {% endcall %}\n\n {% set should_revoke = should_revoke(target_relation_exists, full_refresh_mode=False) %}\n {% do apply_grants(target_relation, grant_config, should_revoke=should_revoke) %}\n\n {% do persist_docs(target_relation, model) %}\n\n {% if not target_relation_exists %}\n {% do create_indexes(target_relation) %}\n {% endif %}\n\n {{ run_hooks(post_hooks, inside_transaction=True) }}\n\n {{ adapter.commit() }}\n\n {% if staging_table is defined %}\n {% do post_snapshot(staging_table) %}\n {% endif %}\n\n {{ run_hooks(post_hooks, inside_transaction=False) }}\n\n {{ return({'relations': [target_relation]}) }}\n\n{% endmaterialization %}", + "macro_sql": "{% macro get_expected_sql(rows, column_name_to_data_types) %}\n\n{%- if (rows | length) == 0 -%}\n select * from dbt_internal_unit_test_actual\n limit 0\n{%- else -%}\n{%- for row in rows -%}\n{%- set formatted_row = format_row(row, column_name_to_data_types) -%}\nselect\n{%- for column_name, column_value in formatted_row.items() %} {{ column_value }} as {{ column_name }}{% if not loop.last -%}, {%- endif %}\n{%- endfor %}\n{%- if not loop.last %}\nunion all\n{% endif %}\n{%- endfor -%}\n{%- endif -%}\n\n{% endmacro %}", "meta": {}, - "name": "materialization_snapshot_default", - "original_file_path": "macros/materializations/snapshots/snapshot.sql", + "name": "get_expected_sql", + "original_file_path": "macros/unit_test_sql/get_fixture_sql.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/snapshots/snapshot.sql", + "path": "macros/unit_test_sql/get_fixture_sql.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", - "supported_languages": [ - "sql" - ], - "tags": [], - "unique_id": "macro.dbt.materialization_snapshot_default" + "supported_languages": null, + "unique_id": "macro.dbt.get_expected_sql" }, - "macro.dbt.materialization_table_default": { + "macro.dbt.get_fixture_sql": { "arguments": [], - "created_at": 1696458269.718855, + "created_at": 1719485736.607797, "depends_on": { "macros": [ - "macro.dbt.load_cached_relation", - "macro.dbt.make_intermediate_relation", - "macro.dbt.make_backup_relation", - "macro.dbt.drop_relation_if_exists", - "macro.dbt.run_hooks", - "macro.dbt.statement", - "macro.dbt.get_create_table_as_sql", - "macro.dbt.create_indexes", - "macro.dbt.should_revoke", - "macro.dbt.apply_grants", - "macro.dbt.persist_docs" + "macro.dbt.load_relation", + "macro.dbt.safe_cast", + "macro.dbt.format_row" ] }, "description": "", @@ -5383,30 +5704,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% materialization table, default %}\n\n {%- set existing_relation = load_cached_relation(this) -%}\n {%- set target_relation = this.incorporate(type='table') %}\n {%- set intermediate_relation = make_intermediate_relation(target_relation) -%}\n -- the intermediate_relation should not already exist in the database; get_relation\n -- will return None in that case. Otherwise, we get a relation that we can drop\n -- later, before we try to use this name for the current operation\n {%- set preexisting_intermediate_relation = load_cached_relation(intermediate_relation) -%}\n /*\n See ../view/view.sql for more information about this relation.\n */\n {%- set backup_relation_type = 'table' if existing_relation is none else existing_relation.type -%}\n {%- set backup_relation = make_backup_relation(target_relation, backup_relation_type) -%}\n -- as above, the backup_relation should not already exist\n {%- set preexisting_backup_relation = load_cached_relation(backup_relation) -%}\n -- grab current tables grants config for comparison later on\n {% set grant_config = config.get('grants') %}\n\n -- drop the temp relations if they exist already in the database\n {{ drop_relation_if_exists(preexisting_intermediate_relation) }}\n {{ drop_relation_if_exists(preexisting_backup_relation) }}\n\n {{ run_hooks(pre_hooks, inside_transaction=False) }}\n\n -- `BEGIN` happens here:\n {{ run_hooks(pre_hooks, inside_transaction=True) }}\n\n -- build model\n {% call statement('main') -%}\n {{ get_create_table_as_sql(False, intermediate_relation, sql) }}\n {%- endcall %}\n\n -- cleanup\n {% if existing_relation is not none %}\n {{ adapter.rename_relation(existing_relation, backup_relation) }}\n {% endif %}\n\n {{ adapter.rename_relation(intermediate_relation, target_relation) }}\n\n {% do create_indexes(target_relation) %}\n\n {{ run_hooks(post_hooks, inside_transaction=True) }}\n\n {% set should_revoke = should_revoke(existing_relation, full_refresh_mode=True) %}\n {% do apply_grants(target_relation, grant_config, should_revoke=should_revoke) %}\n\n {% do persist_docs(target_relation, model) %}\n\n -- `COMMIT` happens here\n {{ adapter.commit() }}\n\n -- finally, drop the existing/backup relation after the commit\n {{ drop_relation_if_exists(backup_relation) }}\n\n {{ run_hooks(post_hooks, inside_transaction=False) }}\n\n {{ return({'relations': [target_relation]}) }}\n{% endmaterialization %}", + "macro_sql": "{% macro get_fixture_sql(rows, column_name_to_data_types) %}\n-- Fixture for {{ model.name }}\n{% set default_row = {} %}\n\n{%- if not column_name_to_data_types -%}\n{#-- Use defer_relation IFF it is available in the manifest and 'this' is missing from the database --#}\n{%- set this_or_defer_relation = defer_relation if (defer_relation and not load_relation(this)) else this -%}\n{%- set columns_in_relation = adapter.get_columns_in_relation(this_or_defer_relation) -%}\n\n{%- set column_name_to_data_types = {} -%}\n{%- for column in columns_in_relation -%}\n{#-- This needs to be a case-insensitive comparison --#}\n{%- do column_name_to_data_types.update({column.name|lower: column.data_type}) -%}\n{%- endfor -%}\n{%- endif -%}\n\n{%- if not column_name_to_data_types -%}\n {{ exceptions.raise_compiler_error(\"Not able to get columns for unit test '\" ~ model.name ~ \"' from relation \" ~ this ~ \" because the relation doesn't exist\") }}\n{%- endif -%}\n\n{%- for column_name, column_type in column_name_to_data_types.items() -%}\n {%- do default_row.update({column_name: (safe_cast(\"null\", column_type) | trim )}) -%}\n{%- endfor -%}\n\n\n{%- for row in rows -%}\n{%- set formatted_row = format_row(row, column_name_to_data_types) -%}\n{%- set default_row_copy = default_row.copy() -%}\n{%- do default_row_copy.update(formatted_row) -%}\nselect\n{%- for column_name, column_value in default_row_copy.items() %} {{ column_value }} as {{ column_name }}{% if not loop.last -%}, {%- endif %}\n{%- endfor %}\n{%- if not loop.last %}\nunion all\n{% endif %}\n{%- endfor -%}\n\n{%- if (rows | length) == 0 -%}\n select\n {%- for column_name, column_value in default_row.items() %} {{ column_value }} as {{ column_name }}{% if not loop.last -%},{%- endif %}\n {%- endfor %}\n limit 0\n{%- endif -%}\n{% endmacro %}", "meta": {}, - "name": "materialization_table_default", - "original_file_path": "macros/materializations/models/table/table.sql", + "name": "get_fixture_sql", + "original_file_path": "macros/unit_test_sql/get_fixture_sql.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/models/table/table.sql", + "path": "macros/unit_test_sql/get_fixture_sql.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", - "supported_languages": [ - "sql" - ], - "tags": [], - "unique_id": "macro.dbt.materialization_table_default" + "supported_languages": null, + "unique_id": "macro.dbt.get_fixture_sql" }, - "macro.dbt.materialization_test_default": { + "macro.dbt.get_grant_sql": { "arguments": [], - "created_at": 1696458269.660993, + "created_at": 1719485736.574021, "depends_on": { "macros": [ - "macro.dbt.should_store_failures", - "macro.dbt.statement", - "macro.dbt.create_table_as", - "macro.dbt.get_test_sql" + "macro.dbt.default__get_grant_sql" ] }, "description": "", @@ -5414,36 +5728,23 @@ "node_color": null, "show": true }, - "macro_sql": "{%- materialization test, default -%}\n\n {% set relations = [] %}\n\n {% if should_store_failures() %}\n\n {% set identifier = model['alias'] %}\n {% set old_relation = adapter.get_relation(database=database, schema=schema, identifier=identifier) %}\n {% set target_relation = api.Relation.create(\n identifier=identifier, schema=schema, database=database, type='table') -%} %}\n\n {% if old_relation %}\n {% do adapter.drop_relation(old_relation) %}\n {% endif %}\n\n {% call statement(auto_begin=True) %}\n {{ create_table_as(False, target_relation, sql) }}\n {% endcall %}\n\n {% do relations.append(target_relation) %}\n\n {% set main_sql %}\n select *\n from {{ target_relation }}\n {% endset %}\n\n {{ adapter.commit() }}\n\n {% else %}\n\n {% set main_sql = sql %}\n\n {% endif %}\n\n {% set limit = config.get('limit') %}\n {% set fail_calc = config.get('fail_calc') %}\n {% set warn_if = config.get('warn_if') %}\n {% set error_if = config.get('error_if') %}\n\n {% call statement('main', fetch_result=True) -%}\n\n {{ get_test_sql(main_sql, fail_calc, warn_if, error_if, limit)}}\n\n {%- endcall %}\n\n {{ return({'relations': relations}) }}\n\n{%- endmaterialization -%}", + "macro_sql": "{% macro get_grant_sql(relation, privilege, grantees) %}\n {{ return(adapter.dispatch('get_grant_sql', 'dbt')(relation, privilege, grantees)) }}\n{% endmacro %}", "meta": {}, - "name": "materialization_test_default", - "original_file_path": "macros/materializations/tests/test.sql", + "name": "get_grant_sql", + "original_file_path": "macros/adapters/apply_grants.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/tests/test.sql", + "path": "macros/adapters/apply_grants.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", - "supported_languages": [ - "sql" - ], - "tags": [], - "unique_id": "macro.dbt.materialization_test_default" + "supported_languages": null, + "unique_id": "macro.dbt.get_grant_sql" }, - "macro.dbt.materialization_view_default": { + "macro.dbt.get_incremental_append_sql": { "arguments": [], - "created_at": 1696458269.726018, + "created_at": 1719485736.418218, "depends_on": { "macros": [ - "macro.dbt.load_cached_relation", - "macro.dbt.make_intermediate_relation", - "macro.dbt.make_backup_relation", - "macro.dbt.run_hooks", - "macro.dbt.drop_relation_if_exists", - "macro.dbt.statement", - "macro.dbt.get_create_view_as_sql", - "macro.dbt.should_revoke", - "macro.dbt.apply_grants", - "macro.dbt.persist_docs" + "macro.dbt.default__get_incremental_append_sql" ] }, "description": "", @@ -5451,51 +5752,47 @@ "node_color": null, "show": true }, - "macro_sql": "{%- materialization view, default -%}\n\n {%- set existing_relation = load_cached_relation(this) -%}\n {%- set target_relation = this.incorporate(type='view') -%}\n {%- set intermediate_relation = make_intermediate_relation(target_relation) -%}\n\n -- the intermediate_relation should not already exist in the database; get_relation\n -- will return None in that case. Otherwise, we get a relation that we can drop\n -- later, before we try to use this name for the current operation\n {%- set preexisting_intermediate_relation = load_cached_relation(intermediate_relation) -%}\n /*\n This relation (probably) doesn't exist yet. If it does exist, it's a leftover from\n a previous run, and we're going to try to drop it immediately. At the end of this\n materialization, we're going to rename the \"existing_relation\" to this identifier,\n and then we're going to drop it. In order to make sure we run the correct one of:\n - drop view ...\n - drop table ...\n\n We need to set the type of this relation to be the type of the existing_relation, if it exists,\n or else \"view\" as a sane default if it does not. Note that if the existing_relation does not\n exist, then there is nothing to move out of the way and subsequentally drop. In that case,\n this relation will be effectively unused.\n */\n {%- set backup_relation_type = 'view' if existing_relation is none else existing_relation.type -%}\n {%- set backup_relation = make_backup_relation(target_relation, backup_relation_type) -%}\n -- as above, the backup_relation should not already exist\n {%- set preexisting_backup_relation = load_cached_relation(backup_relation) -%}\n -- grab current tables grants config for comparison later on\n {% set grant_config = config.get('grants') %}\n\n {{ run_hooks(pre_hooks, inside_transaction=False) }}\n\n -- drop the temp relations if they exist already in the database\n {{ drop_relation_if_exists(preexisting_intermediate_relation) }}\n {{ drop_relation_if_exists(preexisting_backup_relation) }}\n\n -- `BEGIN` happens here:\n {{ run_hooks(pre_hooks, inside_transaction=True) }}\n\n -- build model\n {% call statement('main') -%}\n {{ get_create_view_as_sql(intermediate_relation, sql) }}\n {%- endcall %}\n\n -- cleanup\n -- move the existing view out of the way\n {% if existing_relation is not none %}\n {{ adapter.rename_relation(existing_relation, backup_relation) }}\n {% endif %}\n {{ adapter.rename_relation(intermediate_relation, target_relation) }}\n\n {% set should_revoke = should_revoke(existing_relation, full_refresh_mode=True) %}\n {% do apply_grants(target_relation, grant_config, should_revoke=should_revoke) %}\n\n {% do persist_docs(target_relation, model) %}\n\n {{ run_hooks(post_hooks, inside_transaction=True) }}\n\n {{ adapter.commit() }}\n\n {{ drop_relation_if_exists(backup_relation) }}\n\n {{ run_hooks(post_hooks, inside_transaction=False) }}\n\n {{ return({'relations': [target_relation]}) }}\n\n{%- endmaterialization -%}", + "macro_sql": "{% macro get_incremental_append_sql(arg_dict) %}\n\n {{ return(adapter.dispatch('get_incremental_append_sql', 'dbt')(arg_dict)) }}\n\n{% endmacro %}", "meta": {}, - "name": "materialization_view_default", - "original_file_path": "macros/materializations/models/view/view.sql", + "name": "get_incremental_append_sql", + "original_file_path": "macros/materializations/models/incremental/strategies.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/models/view/view.sql", + "path": "macros/materializations/models/incremental/strategies.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", - "supported_languages": [ - "sql" - ], - "tags": [], - "unique_id": "macro.dbt.materialization_view_default" + "supported_languages": null, + "unique_id": "macro.dbt.get_incremental_append_sql" }, - "macro.dbt.noop_statement": { + "macro.dbt.get_incremental_default_sql": { "arguments": [], - "created_at": 1696458269.7650838, + "created_at": 1719485736.421412, "depends_on": { - "macros": [] + "macros": [ + "macro.dbt_postgres.postgres__get_incremental_default_sql" + ] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro noop_statement(name=None, message=None, code=None, rows_affected=None, res=None) -%}\n {%- set sql = caller() -%}\n\n {%- if name == 'main' -%}\n {{ log('Writing runtime SQL for node \"{}\"'.format(model['unique_id'])) }}\n {{ write(sql) }}\n {%- endif -%}\n\n {%- if name is not none -%}\n {{ store_raw_result(name, message=message, code=code, rows_affected=rows_affected, agate_table=res) }}\n {%- endif -%}\n\n{%- endmacro %}", + "macro_sql": "{% macro get_incremental_default_sql(arg_dict) %}\n\n {{ return(adapter.dispatch('get_incremental_default_sql', 'dbt')(arg_dict)) }}\n\n{% endmacro %}", "meta": {}, - "name": "noop_statement", - "original_file_path": "macros/etc/statement.sql", + "name": "get_incremental_default_sql", + "original_file_path": "macros/materializations/models/incremental/strategies.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/etc/statement.sql", + "path": "macros/materializations/models/incremental/strategies.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.noop_statement" + "unique_id": "macro.dbt.get_incremental_default_sql" }, - "macro.dbt.partition_range": { + "macro.dbt.get_incremental_delete_insert_sql": { "arguments": [], - "created_at": 1696458269.7719522, + "created_at": 1719485736.4188569, "depends_on": { "macros": [ - "macro.dbt.dates_in_range" + "macro.dbt.default__get_incremental_delete_insert_sql" ] }, "description": "", @@ -5503,25 +5800,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro partition_range(raw_partition_date, date_fmt='%Y%m%d') %}\n {% set partition_range = (raw_partition_date | string).split(\",\") %}\n\n {% if (partition_range | length) == 1 %}\n {% set start_date = partition_range[0] %}\n {% set end_date = none %}\n {% elif (partition_range | length) == 2 %}\n {% set start_date = partition_range[0] %}\n {% set end_date = partition_range[1] %}\n {% else %}\n {{ exceptions.raise_compiler_error(\"Invalid partition time. Expected format: {Start Date}[,{End Date}]. Got: \" ~ raw_partition_date) }}\n {% endif %}\n\n {{ return(dates_in_range(start_date, end_date, in_fmt=date_fmt)) }}\n{% endmacro %}", + "macro_sql": "{% macro get_incremental_delete_insert_sql(arg_dict) %}\n\n {{ return(adapter.dispatch('get_incremental_delete_insert_sql', 'dbt')(arg_dict)) }}\n\n{% endmacro %}", "meta": {}, - "name": "partition_range", - "original_file_path": "macros/etc/datetime.sql", + "name": "get_incremental_delete_insert_sql", + "original_file_path": "macros/materializations/models/incremental/strategies.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/etc/datetime.sql", + "path": "macros/materializations/models/incremental/strategies.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.partition_range" + "unique_id": "macro.dbt.get_incremental_delete_insert_sql" }, - "macro.dbt.persist_docs": { + "macro.dbt.get_incremental_insert_overwrite_sql": { "arguments": [], - "created_at": 1696458269.833451, + "created_at": 1719485736.420912, "depends_on": { "macros": [ - "macro.dbt.default__persist_docs" + "macro.dbt.default__get_incremental_insert_overwrite_sql" ] }, "description": "", @@ -5529,25 +5824,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro persist_docs(relation, model, for_relation=true, for_columns=true) -%}\n {{ return(adapter.dispatch('persist_docs', 'dbt')(relation, model, for_relation, for_columns)) }}\n{% endmacro %}", + "macro_sql": "{% macro get_incremental_insert_overwrite_sql(arg_dict) %}\n\n {{ return(adapter.dispatch('get_incremental_insert_overwrite_sql', 'dbt')(arg_dict)) }}\n\n{% endmacro %}", "meta": {}, - "name": "persist_docs", - "original_file_path": "macros/adapters/persist_docs.sql", + "name": "get_incremental_insert_overwrite_sql", + "original_file_path": "macros/materializations/models/incremental/strategies.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/adapters/persist_docs.sql", + "path": "macros/materializations/models/incremental/strategies.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.persist_docs" + "unique_id": "macro.dbt.get_incremental_insert_overwrite_sql" }, - "macro.dbt.position": { + "macro.dbt.get_incremental_merge_sql": { "arguments": [], - "created_at": 1696458269.7855082, + "created_at": 1719485736.419648, "depends_on": { "macros": [ - "macro.dbt.default__position" + "macro.dbt.default__get_incremental_merge_sql" ] }, "description": "", @@ -5555,25 +5848,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro position(substring_text, string_text) -%}\n {{ return(adapter.dispatch('position', 'dbt') (substring_text, string_text)) }}\n{% endmacro %}", + "macro_sql": "{% macro get_incremental_merge_sql(arg_dict) %}\n\n {{ return(adapter.dispatch('get_incremental_merge_sql', 'dbt')(arg_dict)) }}\n\n{% endmacro %}", "meta": {}, - "name": "position", - "original_file_path": "macros/utils/position.sql", + "name": "get_incremental_merge_sql", + "original_file_path": "macros/materializations/models/incremental/strategies.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/utils/position.sql", + "path": "macros/materializations/models/incremental/strategies.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.position" + "unique_id": "macro.dbt.get_incremental_merge_sql" }, - "macro.dbt.post_snapshot": { + "macro.dbt.get_insert_into_sql": { "arguments": [], - "created_at": 1696458269.6167622, + "created_at": 1719485736.4218738, "depends_on": { "macros": [ - "macro.dbt.default__post_snapshot" + "macro.dbt.get_quoted_csv" ] }, "description": "", @@ -5581,26 +5872,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro post_snapshot(staging_relation) %}\n {{ adapter.dispatch('post_snapshot', 'dbt')(staging_relation) }}\n{% endmacro %}", + "macro_sql": "{% macro get_insert_into_sql(target_relation, temp_relation, dest_columns) %}\n\n {%- set dest_cols_csv = get_quoted_csv(dest_columns | map(attribute=\"name\")) -%}\n\n insert into {{ target_relation }} ({{ dest_cols_csv }})\n (\n select {{ dest_cols_csv }}\n from {{ temp_relation }}\n )\n\n{% endmacro %}", "meta": {}, - "name": "post_snapshot", - "original_file_path": "macros/materializations/snapshots/helpers.sql", + "name": "get_insert_into_sql", + "original_file_path": "macros/materializations/models/incremental/strategies.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/snapshots/helpers.sql", + "path": "macros/materializations/models/incremental/strategies.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.post_snapshot" + "unique_id": "macro.dbt.get_insert_into_sql" }, - "macro.dbt.process_schema_changes": { + "macro.dbt.get_insert_overwrite_merge_sql": { "arguments": [], - "created_at": 1696458269.714473, + "created_at": 1719485736.415607, "depends_on": { "macros": [ - "macro.dbt.check_for_schema_changes", - "macro.dbt.sync_column_schemas" + "macro.dbt.default__get_insert_overwrite_merge_sql" ] }, "description": "", @@ -5608,77 +5896,71 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro process_schema_changes(on_schema_change, source_relation, target_relation) %}\n\n {% if on_schema_change == 'ignore' %}\n\n {{ return({}) }}\n\n {% else %}\n\n {% set schema_changes_dict = check_for_schema_changes(source_relation, target_relation) %}\n\n {% if schema_changes_dict['schema_changed'] %}\n\n {% if on_schema_change == 'fail' %}\n\n {% set fail_msg %}\n The source and target schemas on this incremental model are out of sync!\n They can be reconciled in several ways:\n - set the `on_schema_change` config to either append_new_columns or sync_all_columns, depending on your situation.\n - Re-run the incremental model with `full_refresh: True` to update the target schema.\n - update the schema manually and re-run the process.\n\n Additional troubleshooting context:\n Source columns not in target: {{ schema_changes_dict['source_not_in_target'] }}\n Target columns not in source: {{ schema_changes_dict['target_not_in_source'] }}\n New column types: {{ schema_changes_dict['new_target_types'] }}\n {% endset %}\n\n {% do exceptions.raise_compiler_error(fail_msg) %}\n\n {# -- unless we ignore, run the sync operation per the config #}\n {% else %}\n\n {% do sync_column_schemas(on_schema_change, target_relation, schema_changes_dict) %}\n\n {% endif %}\n\n {% endif %}\n\n {{ return(schema_changes_dict['source_columns']) }}\n\n {% endif %}\n\n{% endmacro %}", + "macro_sql": "{% macro get_insert_overwrite_merge_sql(target, source, dest_columns, predicates, include_sql_header=false) -%}\n {{ adapter.dispatch('get_insert_overwrite_merge_sql', 'dbt')(target, source, dest_columns, predicates, include_sql_header) }}\n{%- endmacro %}", "meta": {}, - "name": "process_schema_changes", - "original_file_path": "macros/materializations/models/incremental/on_schema_change.sql", + "name": "get_insert_overwrite_merge_sql", + "original_file_path": "macros/materializations/models/incremental/merge.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/models/incremental/on_schema_change.sql", + "path": "macros/materializations/models/incremental/merge.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.process_schema_changes" + "unique_id": "macro.dbt.get_insert_overwrite_merge_sql" }, - "macro.dbt.py_current_timestring": { + "macro.dbt.get_intervals_between": { "arguments": [], - "created_at": 1696458269.7723362, + "created_at": 1719485736.527589, "depends_on": { - "macros": [] + "macros": [ + "macro.dbt.default__get_intervals_between" + ] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro py_current_timestring() %}\n {% set dt = modules.datetime.datetime.now() %}\n {% do return(dt.strftime(\"%Y%m%d%H%M%S%f\")) %}\n{% endmacro %}", + "macro_sql": "{% macro get_intervals_between(start_date, end_date, datepart) -%}\n {{ return(adapter.dispatch('get_intervals_between', 'dbt')(start_date, end_date, datepart)) }}\n{%- endmacro %}", "meta": {}, - "name": "py_current_timestring", - "original_file_path": "macros/etc/datetime.sql", + "name": "get_intervals_between", + "original_file_path": "macros/utils/date_spine.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/etc/datetime.sql", + "path": "macros/utils/date_spine.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.py_current_timestring" + "unique_id": "macro.dbt.get_intervals_between" }, - "macro.dbt.py_script_comment": { + "macro.dbt.get_limit_subquery_sql": { "arguments": [], - "created_at": 1696458269.853185, + "created_at": 1719485736.58177, "depends_on": { - "macros": [] + "macros": [ + "macro.dbt.default__get_limit_subquery_sql" + ] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{%macro py_script_comment()%}\n{%endmacro%}", + "macro_sql": "{% macro get_limit_subquery_sql(sql, limit) %}\n {{ adapter.dispatch('get_limit_subquery_sql', 'dbt')(sql, limit) }}\n{% endmacro %}", "meta": {}, - "name": "py_script_comment", - "original_file_path": "macros/python_model/python.sql", + "name": "get_limit_subquery_sql", + "original_file_path": "macros/adapters/show.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/python_model/python.sql", + "path": "macros/adapters/show.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.py_script_comment" + "unique_id": "macro.dbt.get_limit_subquery_sql" }, - "macro.dbt.py_script_postfix": { + "macro.dbt.get_materialized_view_configuration_changes": { "arguments": [], - "created_at": 1696458269.85307, + "created_at": 1719485736.494153, "depends_on": { "macros": [ - "macro.dbt.build_ref_function", - "macro.dbt.build_source_function", - "macro.dbt.build_config_dict", - "macro.dbt.is_incremental", - "macro.dbt.py_script_comment" + "macro.dbt_postgres.postgres__get_materialized_view_configuration_changes" ] }, "description": "", @@ -5686,25 +5968,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro py_script_postfix(model) %}\n# This part is user provided model code\n# you will need to copy the next section to run the code\n# COMMAND ----------\n# this part is dbt logic for get ref work, do not modify\n\n{{ build_ref_function(model ) }}\n{{ build_source_function(model ) }}\n{{ build_config_dict(model) }}\n\nclass config:\n def __init__(self, *args, **kwargs):\n pass\n\n @staticmethod\n def get(key, default=None):\n return config_dict.get(key, default)\n\nclass this:\n \"\"\"dbt.this() or dbt.this.identifier\"\"\"\n database = '{{ this.database }}'\n schema = '{{ this.schema }}'\n identifier = '{{ this.identifier }}'\n def __repr__(self):\n return '{{ this }}'\n\n\nclass dbtObj:\n def __init__(self, load_df_function) -> None:\n self.source = lambda *args: source(*args, dbt_load_df_function=load_df_function)\n self.ref = lambda *args: ref(*args, dbt_load_df_function=load_df_function)\n self.config = config\n self.this = this()\n self.is_incremental = {{ is_incremental() }}\n\n# COMMAND ----------\n{{py_script_comment()}}\n{% endmacro %}", + "macro_sql": "{% macro get_materialized_view_configuration_changes(existing_relation, new_config) %}\n /* {#\n It's recommended that configuration changes be formatted as follows:\n {\"\": [{\"action\": \"\", \"context\": ...}]}\n\n For example:\n {\n \"indexes\": [\n {\"action\": \"drop\", \"context\": \"index_abc\"},\n {\"action\": \"create\", \"context\": {\"columns\": [\"column_1\", \"column_2\"], \"type\": \"hash\", \"unique\": True}},\n ],\n }\n\n Either way, `get_materialized_view_configuration_changes` needs to align with `get_alter_materialized_view_as_sql`.\n #} */\n {{- log('Determining configuration changes on: ' ~ existing_relation) -}}\n {%- do return(adapter.dispatch('get_materialized_view_configuration_changes', 'dbt')(existing_relation, new_config)) -%}\n{% endmacro %}", "meta": {}, - "name": "py_script_postfix", - "original_file_path": "macros/python_model/python.sql", + "name": "get_materialized_view_configuration_changes", + "original_file_path": "macros/relations/materialized_view/alter.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/python_model/python.sql", + "path": "macros/relations/materialized_view/alter.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.py_script_postfix" + "unique_id": "macro.dbt.get_materialized_view_configuration_changes" }, - "macro.dbt.rename_relation": { + "macro.dbt.get_merge_sql": { "arguments": [], - "created_at": 1696458269.815678, + "created_at": 1719485736.410901, "depends_on": { "macros": [ - "macro.dbt.default__rename_relation" + "macro.dbt.default__get_merge_sql" ] }, "description": "", @@ -5712,25 +5992,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro rename_relation(from_relation, to_relation) -%}\n {{ return(adapter.dispatch('rename_relation', 'dbt')(from_relation, to_relation)) }}\n{% endmacro %}", + "macro_sql": "{% macro get_merge_sql(target, source, unique_key, dest_columns, incremental_predicates=none) -%}\n -- back compat for old kwarg name\n {% set incremental_predicates = kwargs.get('predicates', incremental_predicates) %}\n {{ adapter.dispatch('get_merge_sql', 'dbt')(target, source, unique_key, dest_columns, incremental_predicates) }}\n{%- endmacro %}", "meta": {}, - "name": "rename_relation", - "original_file_path": "macros/adapters/relation.sql", + "name": "get_merge_sql", + "original_file_path": "macros/materializations/models/incremental/merge.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/adapters/relation.sql", + "path": "macros/materializations/models/incremental/merge.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.rename_relation" + "unique_id": "macro.dbt.get_merge_sql" }, - "macro.dbt.replace": { + "macro.dbt.get_merge_update_columns": { "arguments": [], - "created_at": 1696458269.7736168, + "created_at": 1719485736.397727, "depends_on": { "macros": [ - "macro.dbt.default__replace" + "macro.dbt.default__get_merge_update_columns" ] }, "description": "", @@ -5738,25 +6016,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro replace(field, old_chars, new_chars) -%}\n {{ return(adapter.dispatch('replace', 'dbt') (field, old_chars, new_chars)) }}\n{% endmacro %}", + "macro_sql": "{% macro get_merge_update_columns(merge_update_columns, merge_exclude_columns, dest_columns) %}\n {{ return(adapter.dispatch('get_merge_update_columns', 'dbt')(merge_update_columns, merge_exclude_columns, dest_columns)) }}\n{% endmacro %}", "meta": {}, - "name": "replace", - "original_file_path": "macros/utils/replace.sql", + "name": "get_merge_update_columns", + "original_file_path": "macros/materializations/models/incremental/column_helpers.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/utils/replace.sql", + "path": "macros/materializations/models/incremental/column_helpers.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.replace" + "unique_id": "macro.dbt.get_merge_update_columns" }, - "macro.dbt.reset_csv_table": { + "macro.dbt.get_or_create_relation": { "arguments": [], - "created_at": 1696458269.748157, + "created_at": 1719485736.567741, "depends_on": { "macros": [ - "macro.dbt.default__reset_csv_table" + "macro.dbt.default__get_or_create_relation" ] }, "description": "", @@ -5764,25 +6040,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro reset_csv_table(model, full_refresh, old_relation, agate_table) -%}\n {{ adapter.dispatch('reset_csv_table', 'dbt')(model, full_refresh, old_relation, agate_table) }}\n{%- endmacro %}", + "macro_sql": "{% macro get_or_create_relation(database, schema, identifier, type) -%}\n {{ return(adapter.dispatch('get_or_create_relation', 'dbt')(database, schema, identifier, type)) }}\n{% endmacro %}", "meta": {}, - "name": "reset_csv_table", - "original_file_path": "macros/materializations/seeds/helpers.sql", + "name": "get_or_create_relation", + "original_file_path": "macros/adapters/relation.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/seeds/helpers.sql", + "path": "macros/adapters/relation.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.reset_csv_table" + "unique_id": "macro.dbt.get_or_create_relation" }, - "macro.dbt.right": { + "macro.dbt.get_powers_of_two": { "arguments": [], - "created_at": 1696458269.778283, + "created_at": 1719485736.534465, "depends_on": { "macros": [ - "macro.dbt.default__right" + "macro.dbt.default__get_powers_of_two" ] }, "description": "", @@ -5790,51 +6064,45 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro right(string_text, length_expression) -%}\n {{ return(adapter.dispatch('right', 'dbt') (string_text, length_expression)) }}\n{% endmacro %}", + "macro_sql": "{% macro get_powers_of_two(upper_bound) %}\n {{ return(adapter.dispatch('get_powers_of_two', 'dbt')(upper_bound)) }}\n{% endmacro %}", "meta": {}, - "name": "right", - "original_file_path": "macros/utils/right.sql", + "name": "get_powers_of_two", + "original_file_path": "macros/utils/generate_series.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/utils/right.sql", + "path": "macros/utils/generate_series.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.right" + "unique_id": "macro.dbt.get_powers_of_two" }, - "macro.dbt.run_hooks": { + "macro.dbt.get_quoted_csv": { "arguments": [], - "created_at": 1696458269.590806, + "created_at": 1719485736.395989, "depends_on": { - "macros": [ - "macro.dbt.statement" - ] + "macros": [] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro run_hooks(hooks, inside_transaction=True) %}\n {% for hook in hooks | selectattr('transaction', 'equalto', inside_transaction) %}\n {% if not inside_transaction and loop.first %}\n {% call statement(auto_begin=inside_transaction) %}\n commit;\n {% endcall %}\n {% endif %}\n {% set rendered = render(hook.get('sql')) | trim %}\n {% if (rendered | length) > 0 %}\n {% call statement(auto_begin=inside_transaction) %}\n {{ rendered }}\n {% endcall %}\n {% endif %}\n {% endfor %}\n{% endmacro %}", + "macro_sql": "{% macro get_quoted_csv(column_names) %}\n\n {% set quoted = [] %}\n {% for col in column_names -%}\n {%- do quoted.append(adapter.quote(col)) -%}\n {%- endfor %}\n\n {%- set dest_cols_csv = quoted | join(', ') -%}\n {{ return(dest_cols_csv) }}\n\n{% endmacro %}", "meta": {}, - "name": "run_hooks", - "original_file_path": "macros/materializations/hooks.sql", + "name": "get_quoted_csv", + "original_file_path": "macros/materializations/models/incremental/column_helpers.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/hooks.sql", + "path": "macros/materializations/models/incremental/column_helpers.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.run_hooks" + "unique_id": "macro.dbt.get_quoted_csv" }, - "macro.dbt.run_query": { + "macro.dbt.get_relation_last_modified": { "arguments": [], - "created_at": 1696458269.765571, + "created_at": 1719485736.593024, "depends_on": { "macros": [ - "macro.dbt.statement" + "macro.dbt.default__get_relation_last_modified" ] }, "description": "", @@ -5842,25 +6110,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro run_query(sql) %}\n {% call statement(\"run_query_statement\", fetch_result=true, auto_begin=false) %}\n {{ sql }}\n {% endcall %}\n\n {% do return(load_result(\"run_query_statement\").table) %}\n{% endmacro %}", + "macro_sql": "{% macro get_relation_last_modified(information_schema, relations) %}\n {{ return(adapter.dispatch('get_relation_last_modified', 'dbt')(information_schema, relations)) }}\n{% endmacro %}", "meta": {}, - "name": "run_query", - "original_file_path": "macros/etc/statement.sql", + "name": "get_relation_last_modified", + "original_file_path": "macros/adapters/metadata.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/etc/statement.sql", + "path": "macros/adapters/metadata.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.run_query" + "unique_id": "macro.dbt.get_relation_last_modified" }, - "macro.dbt.safe_cast": { + "macro.dbt.get_relations": { "arguments": [], - "created_at": 1696458269.7820318, + "created_at": 1719485736.592459, "depends_on": { "macros": [ - "macro.dbt.default__safe_cast" + "macro.dbt_postgres.postgres__get_relations" ] }, "description": "", @@ -5868,73 +6134,71 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro safe_cast(field, type) %}\n {{ return(adapter.dispatch('safe_cast', 'dbt') (field, type)) }}\n{% endmacro %}", + "macro_sql": "{% macro get_relations() %}\n {{ return(adapter.dispatch('get_relations', 'dbt')()) }}\n{% endmacro %}", "meta": {}, - "name": "safe_cast", - "original_file_path": "macros/utils/safe_cast.sql", + "name": "get_relations", + "original_file_path": "macros/adapters/metadata.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/utils/safe_cast.sql", + "path": "macros/adapters/metadata.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.safe_cast" + "unique_id": "macro.dbt.get_relations" }, - "macro.dbt.set_sql_header": { + "macro.dbt.get_rename_intermediate_sql": { "arguments": [], - "created_at": 1696458269.592764, + "created_at": 1719485736.484606, "depends_on": { - "macros": [] + "macros": [ + "macro.dbt.default__get_rename_intermediate_sql" + ] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro set_sql_header(config) -%}\n {{ config.set('sql_header', caller()) }}\n{%- endmacro %}", + "macro_sql": "{%- macro get_rename_intermediate_sql(relation) -%}\n {{- log('Applying RENAME INTERMEDIATE to: ' ~ relation) -}}\n {{- adapter.dispatch('get_rename_intermediate_sql', 'dbt')(relation) -}}\n{%- endmacro -%}\n\n\n", "meta": {}, - "name": "set_sql_header", - "original_file_path": "macros/materializations/configs.sql", + "name": "get_rename_intermediate_sql", + "original_file_path": "macros/relations/rename_intermediate.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/configs.sql", + "path": "macros/relations/rename_intermediate.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.set_sql_header" + "unique_id": "macro.dbt.get_rename_intermediate_sql" }, - "macro.dbt.should_full_refresh": { + "macro.dbt.get_rename_materialized_view_sql": { "arguments": [], - "created_at": 1696458269.593334, + "created_at": 1719485736.4909282, "depends_on": { - "macros": [] + "macros": [ + "macro.dbt_postgres.postgres__get_rename_materialized_view_sql" + ] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro should_full_refresh() %}\n {% set config_full_refresh = config.get('full_refresh') %}\n {% if config_full_refresh is none %}\n {% set config_full_refresh = flags.FULL_REFRESH %}\n {% endif %}\n {% do return(config_full_refresh) %}\n{% endmacro %}", + "macro_sql": "{% macro get_rename_materialized_view_sql(relation, new_name) %}\n {{- adapter.dispatch('get_rename_materialized_view_sql', 'dbt')(relation, new_name) -}}\n{% endmacro %}", "meta": {}, - "name": "should_full_refresh", - "original_file_path": "macros/materializations/configs.sql", + "name": "get_rename_materialized_view_sql", + "original_file_path": "macros/relations/materialized_view/rename.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/configs.sql", + "path": "macros/relations/materialized_view/rename.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.should_full_refresh" + "unique_id": "macro.dbt.get_rename_materialized_view_sql" }, - "macro.dbt.should_revoke": { + "macro.dbt.get_rename_sql": { "arguments": [], - "created_at": 1696458269.824169, + "created_at": 1719485736.47766, "depends_on": { "macros": [ - "macro.dbt.copy_grants" + "macro.dbt.default__get_rename_sql" ] }, "description": "", @@ -5942,49 +6206,47 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro should_revoke(existing_relation, full_refresh_mode=True) %}\n\n {% if not existing_relation %}\n {#-- The table doesn't already exist, so no grants to copy over --#}\n {{ return(False) }}\n {% elif full_refresh_mode %}\n {#-- The object is being REPLACED -- whether grants are copied over depends on the value of user config --#}\n {{ return(copy_grants()) }}\n {% else %}\n {#-- The table is being merged/upserted/inserted -- grants will be carried over --#}\n {{ return(True) }}\n {% endif %}\n\n{% endmacro %}", + "macro_sql": "{%- macro get_rename_sql(relation, new_name) -%}\n {{- log('Applying RENAME to: ' ~ relation) -}}\n {{- adapter.dispatch('get_rename_sql', 'dbt')(relation, new_name) -}}\n{%- endmacro -%}\n\n\n", "meta": {}, - "name": "should_revoke", - "original_file_path": "macros/adapters/apply_grants.sql", + "name": "get_rename_sql", + "original_file_path": "macros/relations/rename.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/adapters/apply_grants.sql", + "path": "macros/relations/rename.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.should_revoke" + "unique_id": "macro.dbt.get_rename_sql" }, - "macro.dbt.should_store_failures": { + "macro.dbt.get_rename_table_sql": { "arguments": [], - "created_at": 1696458269.593961, + "created_at": 1719485736.5068932, "depends_on": { - "macros": [] + "macros": [ + "macro.dbt_postgres.postgres__get_rename_table_sql" + ] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro should_store_failures() %}\n {% set config_store_failures = config.get('store_failures') %}\n {% if config_store_failures is none %}\n {% set config_store_failures = flags.STORE_FAILURES %}\n {% endif %}\n {% do return(config_store_failures) %}\n{% endmacro %}", + "macro_sql": "{% macro get_rename_table_sql(relation, new_name) %}\n {{- adapter.dispatch('get_rename_table_sql', 'dbt')(relation, new_name) -}}\n{% endmacro %}", "meta": {}, - "name": "should_store_failures", - "original_file_path": "macros/materializations/configs.sql", + "name": "get_rename_table_sql", + "original_file_path": "macros/relations/table/rename.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/configs.sql", + "path": "macros/relations/table/rename.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.should_store_failures" + "unique_id": "macro.dbt.get_rename_table_sql" }, - "macro.dbt.snapshot_check_all_get_existing_columns": { + "macro.dbt.get_rename_view_sql": { "arguments": [], - "created_at": 1696458269.6062112, + "created_at": 1719485736.5145411, "depends_on": { "macros": [ - "macro.dbt.get_columns_in_query" + "macro.dbt_postgres.postgres__get_rename_view_sql" ] }, "description": "", @@ -5992,28 +6254,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro snapshot_check_all_get_existing_columns(node, target_exists, check_cols_config) -%}\n {%- if not target_exists -%}\n {#-- no table yet -> return whatever the query does --#}\n {{ return((false, query_columns)) }}\n {%- endif -%}\n\n {#-- handle any schema changes --#}\n {%- set target_relation = adapter.get_relation(database=node.database, schema=node.schema, identifier=node.alias) -%}\n\n {% if check_cols_config == 'all' %}\n {%- set query_columns = get_columns_in_query(node['compiled_code']) -%}\n\n {% elif check_cols_config is iterable and (check_cols_config | length) > 0 %}\n {#-- query for proper casing/quoting, to support comparison below --#}\n {%- set select_check_cols_from_target -%}\n select {{ check_cols_config | join(', ') }} from ({{ node['compiled_code'] }}) subq\n {%- endset -%}\n {% set query_columns = get_columns_in_query(select_check_cols_from_target) %}\n\n {% else %}\n {% do exceptions.raise_compiler_error(\"Invalid value for 'check_cols': \" ~ check_cols_config) %}\n {% endif %}\n\n {%- set existing_cols = adapter.get_columns_in_relation(target_relation) | map(attribute = 'name') | list -%}\n {%- set ns = namespace() -%} {#-- handle for-loop scoping with a namespace --#}\n {%- set ns.column_added = false -%}\n\n {%- set intersection = [] -%}\n {%- for col in query_columns -%}\n {%- if col in existing_cols -%}\n {%- do intersection.append(adapter.quote(col)) -%}\n {%- else -%}\n {% set ns.column_added = true %}\n {%- endif -%}\n {%- endfor -%}\n {{ return((ns.column_added, intersection)) }}\n{%- endmacro %}", + "macro_sql": "{% macro get_rename_view_sql(relation, new_name) %}\n {{- adapter.dispatch('get_rename_view_sql', 'dbt')(relation, new_name) -}}\n{% endmacro %}", "meta": {}, - "name": "snapshot_check_all_get_existing_columns", - "original_file_path": "macros/materializations/snapshots/strategies.sql", + "name": "get_rename_view_sql", + "original_file_path": "macros/relations/view/rename.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/snapshots/strategies.sql", + "path": "macros/relations/view/rename.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.snapshot_check_all_get_existing_columns" + "unique_id": "macro.dbt.get_rename_view_sql" }, - "macro.dbt.snapshot_check_strategy": { + "macro.dbt.get_replace_materialized_view_sql": { "arguments": [], - "created_at": 1696458269.6088378, + "created_at": 1719485736.487545, "depends_on": { "macros": [ - "macro.dbt.snapshot_get_time", - "macro.dbt.snapshot_check_all_get_existing_columns", - "macro.dbt.get_true_sql", - "macro.dbt.snapshot_hash_arguments" + "macro.dbt.default__get_replace_materialized_view_sql" ] }, "description": "", @@ -6021,25 +6278,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro snapshot_check_strategy(node, snapshotted_rel, current_rel, config, target_exists) %}\n {% set check_cols_config = config['check_cols'] %}\n {% set primary_key = config['unique_key'] %}\n {% set invalidate_hard_deletes = config.get('invalidate_hard_deletes', false) %}\n {% set updated_at = config.get('updated_at', snapshot_get_time()) %}\n\n {% set column_added = false %}\n\n {% set column_added, check_cols = snapshot_check_all_get_existing_columns(node, target_exists, check_cols_config) %}\n\n {%- set row_changed_expr -%}\n (\n {%- if column_added -%}\n {{ get_true_sql() }}\n {%- else -%}\n {%- for col in check_cols -%}\n {{ snapshotted_rel }}.{{ col }} != {{ current_rel }}.{{ col }}\n or\n (\n (({{ snapshotted_rel }}.{{ col }} is null) and not ({{ current_rel }}.{{ col }} is null))\n or\n ((not {{ snapshotted_rel }}.{{ col }} is null) and ({{ current_rel }}.{{ col }} is null))\n )\n {%- if not loop.last %} or {% endif -%}\n {%- endfor -%}\n {%- endif -%}\n )\n {%- endset %}\n\n {% set scd_id_expr = snapshot_hash_arguments([primary_key, updated_at]) %}\n\n {% do return({\n \"unique_key\": primary_key,\n \"updated_at\": updated_at,\n \"row_changed\": row_changed_expr,\n \"scd_id\": scd_id_expr,\n \"invalidate_hard_deletes\": invalidate_hard_deletes\n }) %}\n{% endmacro %}", + "macro_sql": "{% macro get_replace_materialized_view_sql(relation, sql) %}\n {{- adapter.dispatch('get_replace_materialized_view_sql', 'dbt')(relation, sql) -}}\n{% endmacro %}", "meta": {}, - "name": "snapshot_check_strategy", - "original_file_path": "macros/materializations/snapshots/strategies.sql", + "name": "get_replace_materialized_view_sql", + "original_file_path": "macros/relations/materialized_view/replace.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/snapshots/strategies.sql", + "path": "macros/relations/materialized_view/replace.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.snapshot_check_strategy" + "unique_id": "macro.dbt.get_replace_materialized_view_sql" }, - "macro.dbt.snapshot_get_time": { + "macro.dbt.get_replace_sql": { "arguments": [], - "created_at": 1696458269.80277, + "created_at": 1719485736.4724941, "depends_on": { "macros": [ - "macro.dbt_postgres.postgres__snapshot_get_time" + "macro.dbt.default__get_replace_sql" ] }, "description": "", @@ -6047,25 +6302,23 @@ "node_color": null, "show": true }, - "macro_sql": "\n\n{%- macro snapshot_get_time() -%}\n {{ adapter.dispatch('snapshot_get_time', 'dbt')() }}\n{%- endmacro -%}\n\n", + "macro_sql": "{% macro get_replace_sql(existing_relation, target_relation, sql) %}\n {{- log('Applying REPLACE to: ' ~ existing_relation) -}}\n {{- adapter.dispatch('get_replace_sql', 'dbt')(existing_relation, target_relation, sql) -}}\n{% endmacro %}", "meta": {}, - "name": "snapshot_get_time", - "original_file_path": "macros/adapters/timestamps.sql", + "name": "get_replace_sql", + "original_file_path": "macros/relations/replace.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/adapters/timestamps.sql", + "path": "macros/relations/replace.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.snapshot_get_time" + "unique_id": "macro.dbt.get_replace_sql" }, - "macro.dbt.snapshot_hash_arguments": { + "macro.dbt.get_replace_table_sql": { "arguments": [], - "created_at": 1696458269.6016371, + "created_at": 1719485736.506175, "depends_on": { "macros": [ - "macro.dbt.default__snapshot_hash_arguments" + "macro.dbt_postgres.postgres__get_replace_table_sql" ] }, "description": "", @@ -6073,25 +6326,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro snapshot_hash_arguments(args) -%}\n {{ adapter.dispatch('snapshot_hash_arguments', 'dbt')(args) }}\n{%- endmacro %}", + "macro_sql": "{% macro get_replace_table_sql(relation, sql) %}\n {{- adapter.dispatch('get_replace_table_sql', 'dbt')(relation, sql) -}}\n{% endmacro %}", "meta": {}, - "name": "snapshot_hash_arguments", - "original_file_path": "macros/materializations/snapshots/strategies.sql", + "name": "get_replace_table_sql", + "original_file_path": "macros/relations/table/replace.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/snapshots/strategies.sql", + "path": "macros/relations/table/replace.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.snapshot_hash_arguments" + "unique_id": "macro.dbt.get_replace_table_sql" }, - "macro.dbt.snapshot_merge_sql": { + "macro.dbt.get_replace_view_sql": { "arguments": [], - "created_at": 1696458269.594904, + "created_at": 1719485736.5121448, "depends_on": { "macros": [ - "macro.dbt_postgres.postgres__snapshot_merge_sql" + "macro.dbt_postgres.postgres__get_replace_view_sql" ] }, "description": "", @@ -6099,25 +6350,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro snapshot_merge_sql(target, source, insert_cols) -%}\n {{ adapter.dispatch('snapshot_merge_sql', 'dbt')(target, source, insert_cols) }}\n{%- endmacro %}", + "macro_sql": "{% macro get_replace_view_sql(relation, sql) %}\n {{- adapter.dispatch('get_replace_view_sql', 'dbt')(relation, sql) -}}\n{% endmacro %}", "meta": {}, - "name": "snapshot_merge_sql", - "original_file_path": "macros/materializations/snapshots/snapshot_merge.sql", + "name": "get_replace_view_sql", + "original_file_path": "macros/relations/view/replace.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/snapshots/snapshot_merge.sql", + "path": "macros/relations/view/replace.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.snapshot_merge_sql" + "unique_id": "macro.dbt.get_replace_view_sql" }, - "macro.dbt.snapshot_staging_table": { + "macro.dbt.get_revoke_sql": { "arguments": [], - "created_at": 1696458269.617667, + "created_at": 1719485736.574466, "depends_on": { "macros": [ - "macro.dbt.default__snapshot_staging_table" + "macro.dbt.default__get_revoke_sql" ] }, "description": "", @@ -6125,51 +6374,45 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro snapshot_staging_table(strategy, source_sql, target_relation) -%}\n {{ adapter.dispatch('snapshot_staging_table', 'dbt')(strategy, source_sql, target_relation) }}\n{% endmacro %}", + "macro_sql": "{% macro get_revoke_sql(relation, privilege, grantees) %}\n {{ return(adapter.dispatch('get_revoke_sql', 'dbt')(relation, privilege, grantees)) }}\n{% endmacro %}", "meta": {}, - "name": "snapshot_staging_table", - "original_file_path": "macros/materializations/snapshots/helpers.sql", + "name": "get_revoke_sql", + "original_file_path": "macros/adapters/apply_grants.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/snapshots/helpers.sql", + "path": "macros/adapters/apply_grants.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.snapshot_staging_table" + "unique_id": "macro.dbt.get_revoke_sql" }, - "macro.dbt.snapshot_string_as_time": { + "macro.dbt.get_seed_column_quoted_csv": { "arguments": [], - "created_at": 1696458269.603466, + "created_at": 1719485736.462852, "depends_on": { - "macros": [ - "macro.dbt_postgres.postgres__snapshot_string_as_time" - ] + "macros": [] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro snapshot_string_as_time(timestamp) -%}\n {{ adapter.dispatch('snapshot_string_as_time', 'dbt')(timestamp) }}\n{%- endmacro %}", + "macro_sql": "{% macro get_seed_column_quoted_csv(model, column_names) %}\n {%- set quote_seed_column = model['config'].get('quote_columns', None) -%}\n {% set quoted = [] %}\n {% for col in column_names -%}\n {%- do quoted.append(adapter.quote_seed_column(col, quote_seed_column)) -%}\n {%- endfor %}\n\n {%- set dest_cols_csv = quoted | join(', ') -%}\n {{ return(dest_cols_csv) }}\n{% endmacro %}", "meta": {}, - "name": "snapshot_string_as_time", - "original_file_path": "macros/materializations/snapshots/strategies.sql", + "name": "get_seed_column_quoted_csv", + "original_file_path": "macros/materializations/seeds/helpers.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/snapshots/strategies.sql", + "path": "macros/materializations/seeds/helpers.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.snapshot_string_as_time" + "unique_id": "macro.dbt.get_seed_column_quoted_csv" }, - "macro.dbt.snapshot_timestamp_strategy": { + "macro.dbt.get_select_subquery": { "arguments": [], - "created_at": 1696458269.603195, + "created_at": 1719485736.510276, "depends_on": { "macros": [ - "macro.dbt.snapshot_hash_arguments" + "macro.dbt.default__get_select_subquery" ] }, "description": "", @@ -6177,25 +6420,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro snapshot_timestamp_strategy(node, snapshotted_rel, current_rel, config, target_exists) %}\n {% set primary_key = config['unique_key'] %}\n {% set updated_at = config['updated_at'] %}\n {% set invalidate_hard_deletes = config.get('invalidate_hard_deletes', false) %}\n\n {#/*\n The snapshot relation might not have an {{ updated_at }} value if the\n snapshot strategy is changed from `check` to `timestamp`. We\n should use a dbt-created column for the comparison in the snapshot\n table instead of assuming that the user-supplied {{ updated_at }}\n will be present in the historical data.\n\n See https://github.com/dbt-labs/dbt-core/issues/2350\n */ #}\n {% set row_changed_expr -%}\n ({{ snapshotted_rel }}.dbt_valid_from < {{ current_rel }}.{{ updated_at }})\n {%- endset %}\n\n {% set scd_id_expr = snapshot_hash_arguments([primary_key, updated_at]) %}\n\n {% do return({\n \"unique_key\": primary_key,\n \"updated_at\": updated_at,\n \"row_changed\": row_changed_expr,\n \"scd_id\": scd_id_expr,\n \"invalidate_hard_deletes\": invalidate_hard_deletes\n }) %}\n{% endmacro %}", + "macro_sql": "{% macro get_select_subquery(sql) %}\n {{ return(adapter.dispatch('get_select_subquery', 'dbt')(sql)) }}\n{% endmacro %}", "meta": {}, - "name": "snapshot_timestamp_strategy", - "original_file_path": "macros/materializations/snapshots/strategies.sql", + "name": "get_select_subquery", + "original_file_path": "macros/relations/table/create.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/snapshots/strategies.sql", + "path": "macros/relations/table/create.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.snapshot_timestamp_strategy" + "unique_id": "macro.dbt.get_select_subquery" }, - "macro.dbt.split_part": { + "macro.dbt.get_show_grant_sql": { "arguments": [], - "created_at": 1696458269.795978, + "created_at": 1719485736.573664, "depends_on": { "macros": [ - "macro.dbt_postgres.postgres__split_part" + "macro.dbt_postgres.postgres__get_show_grant_sql" ] }, "description": "", @@ -6203,97 +6444,95 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro split_part(string_text, delimiter_text, part_number) %}\n {{ return(adapter.dispatch('split_part', 'dbt') (string_text, delimiter_text, part_number)) }}\n{% endmacro %}", + "macro_sql": "{% macro get_show_grant_sql(relation) %}\n {{ return(adapter.dispatch(\"get_show_grant_sql\", \"dbt\")(relation)) }}\n{% endmacro %}", "meta": {}, - "name": "split_part", - "original_file_path": "macros/utils/split_part.sql", + "name": "get_show_grant_sql", + "original_file_path": "macros/adapters/apply_grants.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/utils/split_part.sql", + "path": "macros/adapters/apply_grants.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.split_part" + "unique_id": "macro.dbt.get_show_grant_sql" }, - "macro.dbt.sql_convert_columns_in_relation": { + "macro.dbt.get_show_indexes_sql": { "arguments": [], - "created_at": 1696458269.844328, + "created_at": 1719485736.562955, "depends_on": { - "macros": [] + "macros": [ + "macro.dbt_postgres.postgres__get_show_indexes_sql" + ] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro sql_convert_columns_in_relation(table) -%}\n {% set columns = [] %}\n {% for row in table %}\n {% do columns.append(api.Column(*row)) %}\n {% endfor %}\n {{ return(columns) }}\n{% endmacro %}", + "macro_sql": "{% macro get_show_indexes_sql(relation) -%}\n {{ adapter.dispatch('get_show_indexes_sql', 'dbt')(relation) }}\n{%- endmacro %}", "meta": {}, - "name": "sql_convert_columns_in_relation", - "original_file_path": "macros/adapters/columns.sql", + "name": "get_show_indexes_sql", + "original_file_path": "macros/adapters/indexes.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/adapters/columns.sql", + "path": "macros/adapters/indexes.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.sql_convert_columns_in_relation" + "unique_id": "macro.dbt.get_show_indexes_sql" }, - "macro.dbt.statement": { + "macro.dbt.get_show_sql": { "arguments": [], - "created_at": 1696458269.764031, + "created_at": 1719485736.581328, "depends_on": { - "macros": [] + "macros": [ + "macro.dbt.get_limit_subquery_sql" + ] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "\n{%- macro statement(name=None, fetch_result=False, auto_begin=True, language='sql') -%}\n {%- if execute: -%}\n {%- set compiled_code = caller() -%}\n\n {%- if name == 'main' -%}\n {{ log('Writing runtime {} for node \"{}\"'.format(language, model['unique_id'])) }}\n {{ write(compiled_code) }}\n {%- endif -%}\n {%- if language == 'sql'-%}\n {%- set res, table = adapter.execute(compiled_code, auto_begin=auto_begin, fetch=fetch_result) -%}\n {%- elif language == 'python' -%}\n {%- set res = submit_python_job(model, compiled_code) -%}\n {#-- TODO: What should table be for python models? --#}\n {%- set table = None -%}\n {%- else -%}\n {% do exceptions.raise_compiler_error(\"statement macro didn't get supported language\") %}\n {%- endif -%}\n\n {%- if name is not none -%}\n {{ store_result(name, response=res, agate_table=table) }}\n {%- endif -%}\n\n {%- endif -%}\n{%- endmacro %}", + "macro_sql": "{% macro get_show_sql(compiled_code, sql_header, limit) -%}\n {%- if sql_header -%}\n {{ sql_header }}\n {%- endif -%}\n {%- if limit is not none -%}\n {{ get_limit_subquery_sql(compiled_code, limit) }}\n {%- else -%}\n {{ compiled_code }}\n {%- endif -%}\n{% endmacro %}", "meta": {}, - "name": "statement", - "original_file_path": "macros/etc/statement.sql", + "name": "get_show_sql", + "original_file_path": "macros/adapters/show.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/etc/statement.sql", + "path": "macros/adapters/show.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.statement" + "unique_id": "macro.dbt.get_show_sql" }, - "macro.dbt.strategy_dispatch": { + "macro.dbt.get_table_columns_and_constraints": { "arguments": [], - "created_at": 1696458269.601364, + "created_at": 1719485736.497471, "depends_on": { - "macros": [] + "macros": [ + "macro.dbt.default__get_table_columns_and_constraints" + ] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro strategy_dispatch(name) -%}\n{% set original_name = name %}\n {% if '.' in name %}\n {% set package_name, name = name.split(\".\", 1) %}\n {% else %}\n {% set package_name = none %}\n {% endif %}\n\n {% if package_name is none %}\n {% set package_context = context %}\n {% elif package_name in context %}\n {% set package_context = context[package_name] %}\n {% else %}\n {% set error_msg %}\n Could not find package '{{package_name}}', called with '{{original_name}}'\n {% endset %}\n {{ exceptions.raise_compiler_error(error_msg | trim) }}\n {% endif %}\n\n {%- set search_name = 'snapshot_' ~ name ~ '_strategy' -%}\n\n {% if search_name not in package_context %}\n {% set error_msg %}\n The specified strategy macro '{{name}}' was not found in package '{{ package_name }}'\n {% endset %}\n {{ exceptions.raise_compiler_error(error_msg | trim) }}\n {% endif %}\n {{ return(package_context[search_name]) }}\n{%- endmacro %}", + "macro_sql": "{%- macro get_table_columns_and_constraints() -%}\n {{ adapter.dispatch('get_table_columns_and_constraints', 'dbt')() }}\n{%- endmacro -%}\n\n", "meta": {}, - "name": "strategy_dispatch", - "original_file_path": "macros/materializations/snapshots/strategies.sql", + "name": "get_table_columns_and_constraints", + "original_file_path": "macros/relations/column/columns_spec_ddl.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/snapshots/strategies.sql", + "path": "macros/relations/column/columns_spec_ddl.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.strategy_dispatch" + "unique_id": "macro.dbt.get_table_columns_and_constraints" }, - "macro.dbt.string_literal": { + "macro.dbt.get_test_sql": { "arguments": [], - "created_at": 1696458269.786274, + "created_at": 1719485736.37231, "depends_on": { "macros": [ - "macro.dbt.default__string_literal" + "macro.dbt.default__get_test_sql" ] }, "description": "", @@ -6301,25 +6540,23 @@ "node_color": null, "show": true }, - "macro_sql": "{%- macro string_literal(value) -%}\n {{ return(adapter.dispatch('string_literal', 'dbt') (value)) }}\n{%- endmacro -%}\n\n", + "macro_sql": "{% macro get_test_sql(main_sql, fail_calc, warn_if, error_if, limit) -%}\n {{ adapter.dispatch('get_test_sql', 'dbt')(main_sql, fail_calc, warn_if, error_if, limit) }}\n{%- endmacro %}", "meta": {}, - "name": "string_literal", - "original_file_path": "macros/utils/literal.sql", + "name": "get_test_sql", + "original_file_path": "macros/materializations/tests/helpers.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/utils/literal.sql", + "path": "macros/materializations/tests/helpers.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.string_literal" + "unique_id": "macro.dbt.get_test_sql" }, - "macro.dbt.support_multiple_grantees_per_dcl_statement": { + "macro.dbt.get_true_sql": { "arguments": [], - "created_at": 1696458269.823423, + "created_at": 1719485736.355415, "depends_on": { "macros": [ - "macro.dbt.default__support_multiple_grantees_per_dcl_statement" + "macro.dbt.default__get_true_sql" ] }, "description": "", @@ -6327,26 +6564,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro support_multiple_grantees_per_dcl_statement() %}\n {{ return(adapter.dispatch('support_multiple_grantees_per_dcl_statement', 'dbt')()) }}\n{% endmacro %}", + "macro_sql": "{% macro get_true_sql() %}\n {{ adapter.dispatch('get_true_sql', 'dbt')() }}\n{% endmacro %}", "meta": {}, - "name": "support_multiple_grantees_per_dcl_statement", - "original_file_path": "macros/adapters/apply_grants.sql", + "name": "get_true_sql", + "original_file_path": "macros/materializations/snapshots/helpers.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/adapters/apply_grants.sql", + "path": "macros/materializations/snapshots/helpers.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.support_multiple_grantees_per_dcl_statement" + "unique_id": "macro.dbt.get_true_sql" }, - "macro.dbt.sync_column_schemas": { + "macro.dbt.get_unit_test_sql": { "arguments": [], - "created_at": 1696458269.713077, + "created_at": 1719485736.3730109, "depends_on": { "macros": [ - "macro.dbt.alter_relation_add_remove_columns", - "macro.dbt.alter_column_type" + "macro.dbt.default__get_unit_test_sql" ] }, "description": "", @@ -6354,25 +6588,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro sync_column_schemas(on_schema_change, target_relation, schema_changes_dict) %}\n\n {%- set add_to_target_arr = schema_changes_dict['source_not_in_target'] -%}\n\n {%- if on_schema_change == 'append_new_columns'-%}\n {%- if add_to_target_arr | length > 0 -%}\n {%- do alter_relation_add_remove_columns(target_relation, add_to_target_arr, none) -%}\n {%- endif -%}\n\n {% elif on_schema_change == 'sync_all_columns' %}\n {%- set remove_from_target_arr = schema_changes_dict['target_not_in_source'] -%}\n {%- set new_target_types = schema_changes_dict['new_target_types'] -%}\n\n {% if add_to_target_arr | length > 0 or remove_from_target_arr | length > 0 %}\n {%- do alter_relation_add_remove_columns(target_relation, add_to_target_arr, remove_from_target_arr) -%}\n {% endif %}\n\n {% if new_target_types != [] %}\n {% for ntt in new_target_types %}\n {% set column_name = ntt['column_name'] %}\n {% set new_type = ntt['new_type'] %}\n {% do alter_column_type(target_relation, column_name, new_type) %}\n {% endfor %}\n {% endif %}\n\n {% endif %}\n\n {% set schema_change_message %}\n In {{ target_relation }}:\n Schema change approach: {{ on_schema_change }}\n Columns added: {{ add_to_target_arr }}\n Columns removed: {{ remove_from_target_arr }}\n Data types changed: {{ new_target_types }}\n {% endset %}\n\n {% do log(schema_change_message) %}\n\n{% endmacro %}", + "macro_sql": "{% macro get_unit_test_sql(main_sql, expected_fixture_sql, expected_column_names) -%}\n {{ adapter.dispatch('get_unit_test_sql', 'dbt')(main_sql, expected_fixture_sql, expected_column_names) }}\n{%- endmacro %}", "meta": {}, - "name": "sync_column_schemas", - "original_file_path": "macros/materializations/models/incremental/on_schema_change.sql", + "name": "get_unit_test_sql", + "original_file_path": "macros/materializations/tests/helpers.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/materializations/models/incremental/on_schema_change.sql", + "path": "macros/materializations/tests/helpers.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.sync_column_schemas" + "unique_id": "macro.dbt.get_unit_test_sql" }, - "macro.dbt.test_accepted_values": { + "macro.dbt.get_where_subquery": { "arguments": [], - "created_at": 1696458269.854999, + "created_at": 1719485736.3740978, "depends_on": { "macros": [ - "macro.dbt.default__test_accepted_values" + "macro.dbt.default__get_where_subquery" ] }, "description": "", @@ -6380,25 +6612,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% test accepted_values(model, column_name, values, quote=True) %}\n {% set macro = adapter.dispatch('test_accepted_values', 'dbt') %}\n {{ macro(model, column_name, values, quote) }}\n{% endtest %}", + "macro_sql": "{% macro get_where_subquery(relation) -%}\n {% do return(adapter.dispatch('get_where_subquery', 'dbt')(relation)) %}\n{%- endmacro %}", "meta": {}, - "name": "test_accepted_values", - "original_file_path": "tests/generic/builtin.sql", + "name": "get_where_subquery", + "original_file_path": "macros/materializations/tests/where_subquery.sql", "package_name": "dbt", "patch_path": null, - "path": "tests/generic/builtin.sql", + "path": "macros/materializations/tests/where_subquery.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.test_accepted_values" + "unique_id": "macro.dbt.get_where_subquery" }, - "macro.dbt.test_not_null": { + "macro.dbt.handle_existing_table": { "arguments": [], - "created_at": 1696458269.854527, + "created_at": 1719485736.513942, "depends_on": { "macros": [ - "macro.dbt.default__test_not_null" + "macro.dbt.default__handle_existing_table" ] }, "description": "", @@ -6406,25 +6636,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% test not_null(model, column_name) %}\n {% set macro = adapter.dispatch('test_not_null', 'dbt') %}\n {{ macro(model, column_name) }}\n{% endtest %}", + "macro_sql": "{% macro handle_existing_table(full_refresh, old_relation) %}\n {{ adapter.dispatch('handle_existing_table', 'dbt')(full_refresh, old_relation) }}\n{% endmacro %}", "meta": {}, - "name": "test_not_null", - "original_file_path": "tests/generic/builtin.sql", + "name": "handle_existing_table", + "original_file_path": "macros/relations/view/replace.sql", "package_name": "dbt", "patch_path": null, - "path": "tests/generic/builtin.sql", + "path": "macros/relations/view/replace.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.test_not_null" + "unique_id": "macro.dbt.handle_existing_table" }, - "macro.dbt.test_relationships": { + "macro.dbt.hash": { "arguments": [], - "created_at": 1696458269.855469, + "created_at": 1719485736.5412428, "depends_on": { "macros": [ - "macro.dbt.default__test_relationships" + "macro.dbt.default__hash" ] }, "description": "", @@ -6432,25 +6660,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% test relationships(model, column_name, to, field) %}\n {% set macro = adapter.dispatch('test_relationships', 'dbt') %}\n {{ macro(model, column_name, to, field) }}\n{% endtest %}", + "macro_sql": "{% macro hash(field) -%}\n {{ return(adapter.dispatch('hash', 'dbt') (field)) }}\n{%- endmacro %}", "meta": {}, - "name": "test_relationships", - "original_file_path": "tests/generic/builtin.sql", + "name": "hash", + "original_file_path": "macros/utils/hash.sql", "package_name": "dbt", "patch_path": null, - "path": "tests/generic/builtin.sql", + "path": "macros/utils/hash.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.test_relationships" + "unique_id": "macro.dbt.hash" }, - "macro.dbt.test_unique": { + "macro.dbt.in_transaction": { "arguments": [], - "created_at": 1696458269.854142, + "created_at": 1719485736.336884, "depends_on": { "macros": [ - "macro.dbt.default__test_unique" + "macro.dbt.make_hook_config" ] }, "description": "", @@ -6458,25 +6684,45 @@ "node_color": null, "show": true }, - "macro_sql": "{% test unique(model, column_name) %}\n {% set macro = adapter.dispatch('test_unique', 'dbt') %}\n {{ macro(model, column_name) }}\n{% endtest %}", + "macro_sql": "{% macro in_transaction(sql) %}\n {{ make_hook_config(sql, inside_transaction=True) }}\n{% endmacro %}", "meta": {}, - "name": "test_unique", - "original_file_path": "tests/generic/builtin.sql", + "name": "in_transaction", + "original_file_path": "macros/materializations/hooks.sql", "package_name": "dbt", "patch_path": null, - "path": "tests/generic/builtin.sql", + "path": "macros/materializations/hooks.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.test_unique" + "unique_id": "macro.dbt.in_transaction" }, - "macro.dbt.truncate_relation": { + "macro.dbt.incremental_validate_on_schema_change": { + "arguments": [], + "created_at": 1719485736.437581, + "depends_on": { + "macros": [] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro incremental_validate_on_schema_change(on_schema_change, default='ignore') %}\n\n {% if on_schema_change not in ['sync_all_columns', 'append_new_columns', 'fail', 'ignore'] %}\n\n {% set log_message = 'Invalid value for on_schema_change (%s) specified. Setting default value of %s.' % (on_schema_change, default) %}\n {% do log(log_message) %}\n\n {{ return(default) }}\n\n {% else %}\n\n {{ return(on_schema_change) }}\n\n {% endif %}\n\n{% endmacro %}", + "meta": {}, + "name": "incremental_validate_on_schema_change", + "original_file_path": "macros/materializations/models/incremental/on_schema_change.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/materializations/models/incremental/on_schema_change.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.incremental_validate_on_schema_change" + }, + "macro.dbt.information_schema_name": { "arguments": [], - "created_at": 1696458269.8150861, + "created_at": 1719485736.590652, "depends_on": { "macros": [ - "macro.dbt.default__truncate_relation" + "macro.dbt_postgres.postgres__information_schema_name" ] }, "description": "", @@ -6484,25 +6730,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro truncate_relation(relation) -%}\n {{ return(adapter.dispatch('truncate_relation', 'dbt')(relation)) }}\n{% endmacro %}", + "macro_sql": "{% macro information_schema_name(database) %}\n {{ return(adapter.dispatch('information_schema_name', 'dbt')(database)) }}\n{% endmacro %}", "meta": {}, - "name": "truncate_relation", - "original_file_path": "macros/adapters/relation.sql", + "name": "information_schema_name", + "original_file_path": "macros/adapters/metadata.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/adapters/relation.sql", + "path": "macros/adapters/metadata.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.truncate_relation" + "unique_id": "macro.dbt.information_schema_name" }, - "macro.dbt.type_bigint": { + "macro.dbt.intersect": { "arguments": [], - "created_at": 1696458269.7903318, + "created_at": 1719485736.537264, "depends_on": { "macros": [ - "macro.dbt.default__type_bigint" + "macro.dbt.default__intersect" ] }, "description": "", @@ -6510,25 +6754,23 @@ "node_color": null, "show": true }, - "macro_sql": "\n\n{%- macro type_bigint() -%}\n {{ return(adapter.dispatch('type_bigint', 'dbt')()) }}\n{%- endmacro -%}\n\n", + "macro_sql": "{% macro intersect() %}\n {{ return(adapter.dispatch('intersect', 'dbt')()) }}\n{% endmacro %}", "meta": {}, - "name": "type_bigint", - "original_file_path": "macros/utils/data_types.sql", + "name": "intersect", + "original_file_path": "macros/utils/intersect.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/utils/data_types.sql", + "path": "macros/utils/intersect.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.type_bigint" + "unique_id": "macro.dbt.intersect" }, - "macro.dbt.type_boolean": { + "macro.dbt.is_incremental": { "arguments": [], - "created_at": 1696458269.791368, + "created_at": 1719485736.417088, "depends_on": { "macros": [ - "macro.dbt.default__type_boolean" + "macro.dbt.should_full_refresh" ] }, "description": "", @@ -6536,25 +6778,23 @@ "node_color": null, "show": true }, - "macro_sql": "\n\n{%- macro type_boolean() -%}\n {{ return(adapter.dispatch('type_boolean', 'dbt')()) }}\n{%- endmacro -%}\n\n", + "macro_sql": "{% macro is_incremental() %}\n {#-- do not run introspective queries in parsing #}\n {% if not execute %}\n {{ return(False) }}\n {% else %}\n {% set relation = adapter.get_relation(this.database, this.schema, this.table) %}\n {{ return(relation is not none\n and relation.type == 'table'\n and model.config.materialized == 'incremental'\n and not should_full_refresh()) }}\n {% endif %}\n{% endmacro %}", "meta": {}, - "name": "type_boolean", - "original_file_path": "macros/utils/data_types.sql", + "name": "is_incremental", + "original_file_path": "macros/materializations/models/incremental/is_incremental.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/utils/data_types.sql", + "path": "macros/materializations/models/incremental/is_incremental.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.type_boolean" + "unique_id": "macro.dbt.is_incremental" }, - "macro.dbt.type_float": { + "macro.dbt.last_day": { "arguments": [], - "created_at": 1696458269.789262, + "created_at": 1719485736.553028, "depends_on": { "macros": [ - "macro.dbt.default__type_float" + "macro.dbt_postgres.postgres__last_day" ] }, "description": "", @@ -6562,25 +6802,23 @@ "node_color": null, "show": true }, - "macro_sql": "\n\n{%- macro type_float() -%}\n {{ return(adapter.dispatch('type_float', 'dbt')()) }}\n{%- endmacro -%}\n\n", + "macro_sql": "{% macro last_day(date, datepart) %}\n {{ return(adapter.dispatch('last_day', 'dbt') (date, datepart)) }}\n{% endmacro %}", "meta": {}, - "name": "type_float", - "original_file_path": "macros/utils/data_types.sql", + "name": "last_day", + "original_file_path": "macros/utils/last_day.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/utils/data_types.sql", + "path": "macros/utils/last_day.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.type_float" + "unique_id": "macro.dbt.last_day" }, - "macro.dbt.type_int": { + "macro.dbt.length": { "arguments": [], - "created_at": 1696458269.790855, + "created_at": 1719485736.536302, "depends_on": { "macros": [ - "macro.dbt.default__type_int" + "macro.dbt.default__length" ] }, "description": "", @@ -6588,25 +6826,2248 @@ "node_color": null, "show": true }, - "macro_sql": "\n\n{%- macro type_int() -%}\n {{ return(adapter.dispatch('type_int', 'dbt')()) }}\n{%- endmacro -%}\n\n", + "macro_sql": "{% macro length(expression) -%}\n {{ return(adapter.dispatch('length', 'dbt') (expression)) }}\n{% endmacro %}", "meta": {}, - "name": "type_int", - "original_file_path": "macros/utils/data_types.sql", + "name": "length", + "original_file_path": "macros/utils/length.sql", "package_name": "dbt", "patch_path": null, - "path": "macros/utils/data_types.sql", + "path": "macros/utils/length.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.type_int" + "unique_id": "macro.dbt.length" + }, + "macro.dbt.list_relations_without_caching": { + "arguments": [], + "created_at": 1719485736.592111, + "depends_on": { + "macros": [ + "macro.dbt_postgres.postgres__list_relations_without_caching" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro list_relations_without_caching(schema_relation) %}\n {{ return(adapter.dispatch('list_relations_without_caching', 'dbt')(schema_relation)) }}\n{% endmacro %}", + "meta": {}, + "name": "list_relations_without_caching", + "original_file_path": "macros/adapters/metadata.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/adapters/metadata.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.list_relations_without_caching" + }, + "macro.dbt.list_schemas": { + "arguments": [], + "created_at": 1719485736.591011, + "depends_on": { + "macros": [ + "macro.dbt_postgres.postgres__list_schemas" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro list_schemas(database) -%}\n {{ return(adapter.dispatch('list_schemas', 'dbt')(database)) }}\n{% endmacro %}", + "meta": {}, + "name": "list_schemas", + "original_file_path": "macros/adapters/metadata.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/adapters/metadata.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.list_schemas" + }, + "macro.dbt.listagg": { + "arguments": [], + "created_at": 1719485736.5392041, + "depends_on": { + "macros": [ + "macro.dbt_postgres.postgres__listagg" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro listagg(measure, delimiter_text=\"','\", order_by_clause=none, limit_num=none) -%}\n {{ return(adapter.dispatch('listagg', 'dbt') (measure, delimiter_text, order_by_clause, limit_num)) }}\n{%- endmacro %}", + "meta": {}, + "name": "listagg", + "original_file_path": "macros/utils/listagg.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/utils/listagg.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.listagg" + }, + "macro.dbt.load_cached_relation": { + "arguments": [], + "created_at": 1719485736.568574, + "depends_on": { + "macros": [] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro load_cached_relation(relation) %}\n {% do return(adapter.get_relation(\n database=relation.database,\n schema=relation.schema,\n identifier=relation.identifier\n )) -%}\n{% endmacro %}", + "meta": {}, + "name": "load_cached_relation", + "original_file_path": "macros/adapters/relation.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/adapters/relation.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.load_cached_relation" + }, + "macro.dbt.load_csv_rows": { + "arguments": [], + "created_at": 1719485736.4631271, + "depends_on": { + "macros": [ + "macro.dbt.default__load_csv_rows" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro load_csv_rows(model, agate_table) -%}\n {{ adapter.dispatch('load_csv_rows', 'dbt')(model, agate_table) }}\n{%- endmacro %}", + "meta": {}, + "name": "load_csv_rows", + "original_file_path": "macros/materializations/seeds/helpers.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/materializations/seeds/helpers.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.load_csv_rows" + }, + "macro.dbt.load_relation": { + "arguments": [], + "created_at": 1719485736.568728, + "depends_on": { + "macros": [ + "macro.dbt.load_cached_relation" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro load_relation(relation) %}\n {{ return(load_cached_relation(relation)) }}\n{% endmacro %}", + "meta": {}, + "name": "load_relation", + "original_file_path": "macros/adapters/relation.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/adapters/relation.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.load_relation" + }, + "macro.dbt.make_backup_relation": { + "arguments": [], + "created_at": 1719485736.566729, + "depends_on": { + "macros": [ + "macro.dbt_postgres.postgres__make_backup_relation" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro make_backup_relation(base_relation, backup_relation_type, suffix='__dbt_backup') %}\n {{ return(adapter.dispatch('make_backup_relation', 'dbt')(base_relation, backup_relation_type, suffix)) }}\n{% endmacro %}", + "meta": {}, + "name": "make_backup_relation", + "original_file_path": "macros/adapters/relation.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/adapters/relation.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.make_backup_relation" + }, + "macro.dbt.make_hook_config": { + "arguments": [], + "created_at": 1719485736.336577, + "depends_on": { + "macros": [] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro make_hook_config(sql, inside_transaction) %}\n {{ tojson({\"sql\": sql, \"transaction\": inside_transaction}) }}\n{% endmacro %}", + "meta": {}, + "name": "make_hook_config", + "original_file_path": "macros/materializations/hooks.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/materializations/hooks.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.make_hook_config" + }, + "macro.dbt.make_intermediate_relation": { + "arguments": [], + "created_at": 1719485736.565727, + "depends_on": { + "macros": [ + "macro.dbt_postgres.postgres__make_intermediate_relation" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro make_intermediate_relation(base_relation, suffix='__dbt_tmp') %}\n {{ return(adapter.dispatch('make_intermediate_relation', 'dbt')(base_relation, suffix)) }}\n{% endmacro %}", + "meta": {}, + "name": "make_intermediate_relation", + "original_file_path": "macros/adapters/relation.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/adapters/relation.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.make_intermediate_relation" + }, + "macro.dbt.make_temp_relation": { + "arguments": [], + "created_at": 1719485736.5661461, + "depends_on": { + "macros": [ + "macro.dbt_postgres.postgres__make_temp_relation" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro make_temp_relation(base_relation, suffix='__dbt_tmp') %}\n {{ return(adapter.dispatch('make_temp_relation', 'dbt')(base_relation, suffix)) }}\n{% endmacro %}", + "meta": {}, + "name": "make_temp_relation", + "original_file_path": "macros/adapters/relation.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/adapters/relation.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.make_temp_relation" + }, + "macro.dbt.materialization_clone_default": { + "arguments": [], + "created_at": 1719485736.448061, + "depends_on": { + "macros": [ + "macro.dbt.load_cached_relation", + "macro.dbt.can_clone_table", + "macro.dbt.drop_relation_if_exists", + "macro.dbt.statement", + "macro.dbt.create_or_replace_clone", + "macro.dbt.should_revoke", + "macro.dbt.apply_grants", + "macro.dbt.persist_docs" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{%- materialization clone, default -%}\n\n {%- set relations = {'relations': []} -%}\n\n {%- if not defer_relation -%}\n -- nothing to do\n {{ log(\"No relation found in state manifest for \" ~ model.unique_id, info=True) }}\n {{ return(relations) }}\n {%- endif -%}\n\n {%- set existing_relation = load_cached_relation(this) -%}\n\n {%- if existing_relation and not flags.FULL_REFRESH -%}\n -- noop!\n {{ log(\"Relation \" ~ existing_relation ~ \" already exists\", info=True) }}\n {{ return(relations) }}\n {%- endif -%}\n\n {%- set other_existing_relation = load_cached_relation(defer_relation) -%}\n\n -- If this is a database that can do zero-copy cloning of tables, and the other relation is a table, then this will be a table\n -- Otherwise, this will be a view\n\n {% set can_clone_table = can_clone_table() %}\n\n {%- if other_existing_relation and other_existing_relation.type == 'table' and can_clone_table -%}\n\n {%- set target_relation = this.incorporate(type='table') -%}\n {% if existing_relation is not none and not existing_relation.is_table %}\n {{ log(\"Dropping relation \" ~ existing_relation ~ \" because it is of type \" ~ existing_relation.type) }}\n {{ drop_relation_if_exists(existing_relation) }}\n {% endif %}\n\n -- as a general rule, data platforms that can clone tables can also do atomic 'create or replace'\n {% call statement('main') %}\n {% if target_relation and defer_relation and target_relation == defer_relation %}\n {{ log(\"Target relation and defer relation are the same, skipping clone for relation: \" ~ target_relation) }}\n {% else %}\n {{ create_or_replace_clone(target_relation, defer_relation) }}\n {% endif %}\n\n {% endcall %}\n\n {% set should_revoke = should_revoke(existing_relation, full_refresh_mode=True) %}\n {% do apply_grants(target_relation, grant_config, should_revoke=should_revoke) %}\n {% do persist_docs(target_relation, model) %}\n\n {{ return({'relations': [target_relation]}) }}\n\n {%- else -%}\n\n {%- set target_relation = this.incorporate(type='view') -%}\n\n -- reuse the view materialization\n -- TODO: support actual dispatch for materialization macros\n -- Tracking ticket: https://github.com/dbt-labs/dbt-core/issues/7799\n {% set search_name = \"materialization_view_\" ~ adapter.type() %}\n {% if not search_name in context %}\n {% set search_name = \"materialization_view_default\" %}\n {% endif %}\n {% set materialization_macro = context[search_name] %}\n {% set relations = materialization_macro() %}\n {{ return(relations) }}\n\n {%- endif -%}\n\n{%- endmaterialization -%}", + "meta": {}, + "name": "materialization_clone_default", + "original_file_path": "macros/materializations/models/clone/clone.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/materializations/models/clone/clone.sql", + "resource_type": "macro", + "supported_languages": [ + "sql" + ], + "unique_id": "macro.dbt.materialization_clone_default" + }, + "macro.dbt.materialization_incremental_default": { + "arguments": [], + "created_at": 1719485736.4306219, + "depends_on": { + "macros": [ + "macro.dbt.load_cached_relation", + "macro.dbt.make_temp_relation", + "macro.dbt.make_intermediate_relation", + "macro.dbt.make_backup_relation", + "macro.dbt.should_full_refresh", + "macro.dbt.incremental_validate_on_schema_change", + "macro.dbt.drop_relation_if_exists", + "macro.dbt.run_hooks", + "macro.dbt.get_create_table_as_sql", + "macro.dbt.run_query", + "macro.dbt.process_schema_changes", + "macro.dbt.statement", + "macro.dbt.should_revoke", + "macro.dbt.apply_grants", + "macro.dbt.persist_docs", + "macro.dbt.create_indexes" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% materialization incremental, default -%}\n\n -- relations\n {%- set existing_relation = load_cached_relation(this) -%}\n {%- set target_relation = this.incorporate(type='table') -%}\n {%- set temp_relation = make_temp_relation(target_relation)-%}\n {%- set intermediate_relation = make_intermediate_relation(target_relation)-%}\n {%- set backup_relation_type = 'table' if existing_relation is none else existing_relation.type -%}\n {%- set backup_relation = make_backup_relation(target_relation, backup_relation_type) -%}\n\n -- configs\n {%- set unique_key = config.get('unique_key') -%}\n {%- set full_refresh_mode = (should_full_refresh() or existing_relation.is_view) -%}\n {%- set on_schema_change = incremental_validate_on_schema_change(config.get('on_schema_change'), default='ignore') -%}\n\n -- the temp_ and backup_ relations should not already exist in the database; get_relation\n -- will return None in that case. Otherwise, we get a relation that we can drop\n -- later, before we try to use this name for the current operation. This has to happen before\n -- BEGIN, in a separate transaction\n {%- set preexisting_intermediate_relation = load_cached_relation(intermediate_relation)-%}\n {%- set preexisting_backup_relation = load_cached_relation(backup_relation) -%}\n -- grab current tables grants config for comparision later on\n {% set grant_config = config.get('grants') %}\n {{ drop_relation_if_exists(preexisting_intermediate_relation) }}\n {{ drop_relation_if_exists(preexisting_backup_relation) }}\n\n {{ run_hooks(pre_hooks, inside_transaction=False) }}\n\n -- `BEGIN` happens here:\n {{ run_hooks(pre_hooks, inside_transaction=True) }}\n\n {% set to_drop = [] %}\n\n {% if existing_relation is none %}\n {% set build_sql = get_create_table_as_sql(False, target_relation, sql) %}\n {% elif full_refresh_mode %}\n {% set build_sql = get_create_table_as_sql(False, intermediate_relation, sql) %}\n {% set need_swap = true %}\n {% else %}\n {% do run_query(get_create_table_as_sql(True, temp_relation, sql)) %}\n {% do adapter.expand_target_column_types(\n from_relation=temp_relation,\n to_relation=target_relation) %}\n {#-- Process schema changes. Returns dict of changes if successful. Use source columns for upserting/merging --#}\n {% set dest_columns = process_schema_changes(on_schema_change, temp_relation, existing_relation) %}\n {% if not dest_columns %}\n {% set dest_columns = adapter.get_columns_in_relation(existing_relation) %}\n {% endif %}\n\n {#-- Get the incremental_strategy, the macro to use for the strategy, and build the sql --#}\n {% set incremental_strategy = config.get('incremental_strategy') or 'default' %}\n {% set incremental_predicates = config.get('predicates', none) or config.get('incremental_predicates', none) %}\n {% set strategy_sql_macro_func = adapter.get_incremental_strategy_macro(context, incremental_strategy) %}\n {% set strategy_arg_dict = ({'target_relation': target_relation, 'temp_relation': temp_relation, 'unique_key': unique_key, 'dest_columns': dest_columns, 'incremental_predicates': incremental_predicates }) %}\n {% set build_sql = strategy_sql_macro_func(strategy_arg_dict) %}\n\n {% endif %}\n\n {% call statement(\"main\") %}\n {{ build_sql }}\n {% endcall %}\n\n {% if need_swap %}\n {% do adapter.rename_relation(target_relation, backup_relation) %}\n {% do adapter.rename_relation(intermediate_relation, target_relation) %}\n {% do to_drop.append(backup_relation) %}\n {% endif %}\n\n {% set should_revoke = should_revoke(existing_relation, full_refresh_mode) %}\n {% do apply_grants(target_relation, grant_config, should_revoke=should_revoke) %}\n\n {% do persist_docs(target_relation, model) %}\n\n {% if existing_relation is none or existing_relation.is_view or should_full_refresh() %}\n {% do create_indexes(target_relation) %}\n {% endif %}\n\n {{ run_hooks(post_hooks, inside_transaction=True) }}\n\n -- `COMMIT` happens here\n {% do adapter.commit() %}\n\n {% for rel in to_drop %}\n {% do adapter.drop_relation(rel) %}\n {% endfor %}\n\n {{ run_hooks(post_hooks, inside_transaction=False) }}\n\n {{ return({'relations': [target_relation]}) }}\n\n{%- endmaterialization %}", + "meta": {}, + "name": "materialization_incremental_default", + "original_file_path": "macros/materializations/models/incremental/incremental.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/materializations/models/incremental/incremental.sql", + "resource_type": "macro", + "supported_languages": [ + "sql" + ], + "unique_id": "macro.dbt.materialization_incremental_default" + }, + "macro.dbt.materialization_materialized_view_default": { + "arguments": [], + "created_at": 1719485736.383056, + "depends_on": { + "macros": [ + "macro.dbt.load_cached_relation", + "macro.dbt.make_intermediate_relation", + "macro.dbt.make_backup_relation", + "macro.dbt.materialized_view_setup", + "macro.dbt.materialized_view_get_build_sql", + "macro.dbt.materialized_view_execute_no_op", + "macro.dbt.materialized_view_execute_build_sql", + "macro.dbt.materialized_view_teardown" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% materialization materialized_view, default %}\n {% set existing_relation = load_cached_relation(this) %}\n {% set target_relation = this.incorporate(type=this.MaterializedView) %}\n {% set intermediate_relation = make_intermediate_relation(target_relation) %}\n {% set backup_relation_type = target_relation.MaterializedView if existing_relation is none else existing_relation.type %}\n {% set backup_relation = make_backup_relation(target_relation, backup_relation_type) %}\n\n {{ materialized_view_setup(backup_relation, intermediate_relation, pre_hooks) }}\n\n {% set build_sql = materialized_view_get_build_sql(existing_relation, target_relation, backup_relation, intermediate_relation) %}\n\n {% if build_sql == '' %}\n {{ materialized_view_execute_no_op(target_relation) }}\n {% else %}\n {{ materialized_view_execute_build_sql(build_sql, existing_relation, target_relation, post_hooks) }}\n {% endif %}\n\n {{ materialized_view_teardown(backup_relation, intermediate_relation, post_hooks) }}\n\n {{ return({'relations': [target_relation]}) }}\n\n{% endmaterialization %}", + "meta": {}, + "name": "materialization_materialized_view_default", + "original_file_path": "macros/materializations/models/materialized_view.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/materializations/models/materialized_view.sql", + "resource_type": "macro", + "supported_languages": [ + "sql" + ], + "unique_id": "macro.dbt.materialization_materialized_view_default" + }, + "macro.dbt.materialization_seed_default": { + "arguments": [], + "created_at": 1719485736.4520829, + "depends_on": { + "macros": [ + "macro.dbt.should_full_refresh", + "macro.dbt.run_hooks", + "macro.dbt.reset_csv_table", + "macro.dbt.create_csv_table", + "macro.dbt.load_csv_rows", + "macro.dbt.noop_statement", + "macro.dbt.get_csv_sql", + "macro.dbt.should_revoke", + "macro.dbt.apply_grants", + "macro.dbt.persist_docs", + "macro.dbt.create_indexes" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% materialization seed, default %}\n\n {%- set identifier = model['alias'] -%}\n {%- set full_refresh_mode = (should_full_refresh()) -%}\n\n {%- set old_relation = adapter.get_relation(database=database, schema=schema, identifier=identifier) -%}\n\n {%- set exists_as_table = (old_relation is not none and old_relation.is_table) -%}\n {%- set exists_as_view = (old_relation is not none and old_relation.is_view) -%}\n\n {%- set grant_config = config.get('grants') -%}\n {%- set agate_table = load_agate_table() -%}\n -- grab current tables grants config for comparison later on\n\n {%- do store_result('agate_table', response='OK', agate_table=agate_table) -%}\n\n {{ run_hooks(pre_hooks, inside_transaction=False) }}\n\n -- `BEGIN` happens here:\n {{ run_hooks(pre_hooks, inside_transaction=True) }}\n\n -- build model\n {% set create_table_sql = \"\" %}\n {% if exists_as_view %}\n {{ exceptions.raise_compiler_error(\"Cannot seed to '{}', it is a view\".format(old_relation)) }}\n {% elif exists_as_table %}\n {% set create_table_sql = reset_csv_table(model, full_refresh_mode, old_relation, agate_table) %}\n {% else %}\n {% set create_table_sql = create_csv_table(model, agate_table) %}\n {% endif %}\n\n {% set code = 'CREATE' if full_refresh_mode else 'INSERT' %}\n {% set rows_affected = (agate_table.rows | length) %}\n {% set sql = load_csv_rows(model, agate_table) %}\n\n {% call noop_statement('main', code ~ ' ' ~ rows_affected, code, rows_affected) %}\n {{ get_csv_sql(create_table_sql, sql) }};\n {% endcall %}\n\n {% set target_relation = this.incorporate(type='table') %}\n\n {% set should_revoke = should_revoke(old_relation, full_refresh_mode) %}\n {% do apply_grants(target_relation, grant_config, should_revoke=should_revoke) %}\n\n {% do persist_docs(target_relation, model) %}\n\n {% if full_refresh_mode or not exists_as_table %}\n {% do create_indexes(target_relation) %}\n {% endif %}\n\n {{ run_hooks(post_hooks, inside_transaction=True) }}\n\n -- `COMMIT` happens here\n {{ adapter.commit() }}\n\n {{ run_hooks(post_hooks, inside_transaction=False) }}\n\n {{ return({'relations': [target_relation]}) }}\n\n{% endmaterialization %}", + "meta": {}, + "name": "materialization_seed_default", + "original_file_path": "macros/materializations/seeds/seed.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/materializations/seeds/seed.sql", + "resource_type": "macro", + "supported_languages": [ + "sql" + ], + "unique_id": "macro.dbt.materialization_seed_default" + }, + "macro.dbt.materialization_snapshot_default": { + "arguments": [], + "created_at": 1719485736.3679821, + "depends_on": { + "macros": [ + "macro.dbt.get_or_create_relation", + "macro.dbt.run_hooks", + "macro.dbt.strategy_dispatch", + "macro.dbt.build_snapshot_table", + "macro.dbt.create_table_as", + "macro.dbt.build_snapshot_staging_table", + "macro.dbt.create_columns", + "macro.dbt.snapshot_merge_sql", + "macro.dbt.statement", + "macro.dbt.should_revoke", + "macro.dbt.apply_grants", + "macro.dbt.persist_docs", + "macro.dbt.create_indexes", + "macro.dbt.post_snapshot" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% materialization snapshot, default %}\n {%- set config = model['config'] -%}\n\n {%- set target_table = model.get('alias', model.get('name')) -%}\n\n {%- set strategy_name = config.get('strategy') -%}\n {%- set unique_key = config.get('unique_key') %}\n -- grab current tables grants config for comparision later on\n {%- set grant_config = config.get('grants') -%}\n\n {% set target_relation_exists, target_relation = get_or_create_relation(\n database=model.database,\n schema=model.schema,\n identifier=target_table,\n type='table') -%}\n\n {%- if not target_relation.is_table -%}\n {% do exceptions.relation_wrong_type(target_relation, 'table') %}\n {%- endif -%}\n\n\n {{ run_hooks(pre_hooks, inside_transaction=False) }}\n\n {{ run_hooks(pre_hooks, inside_transaction=True) }}\n\n {% set strategy_macro = strategy_dispatch(strategy_name) %}\n {% set strategy = strategy_macro(model, \"snapshotted_data\", \"source_data\", config, target_relation_exists) %}\n\n {% if not target_relation_exists %}\n\n {% set build_sql = build_snapshot_table(strategy, model['compiled_code']) %}\n {% set final_sql = create_table_as(False, target_relation, build_sql) %}\n\n {% else %}\n\n {{ adapter.valid_snapshot_target(target_relation) }}\n\n {% set staging_table = build_snapshot_staging_table(strategy, sql, target_relation) %}\n\n -- this may no-op if the database does not require column expansion\n {% do adapter.expand_target_column_types(from_relation=staging_table,\n to_relation=target_relation) %}\n\n {% set missing_columns = adapter.get_missing_columns(staging_table, target_relation)\n | rejectattr('name', 'equalto', 'dbt_change_type')\n | rejectattr('name', 'equalto', 'DBT_CHANGE_TYPE')\n | rejectattr('name', 'equalto', 'dbt_unique_key')\n | rejectattr('name', 'equalto', 'DBT_UNIQUE_KEY')\n | list %}\n\n {% do create_columns(target_relation, missing_columns) %}\n\n {% set source_columns = adapter.get_columns_in_relation(staging_table)\n | rejectattr('name', 'equalto', 'dbt_change_type')\n | rejectattr('name', 'equalto', 'DBT_CHANGE_TYPE')\n | rejectattr('name', 'equalto', 'dbt_unique_key')\n | rejectattr('name', 'equalto', 'DBT_UNIQUE_KEY')\n | list %}\n\n {% set quoted_source_columns = [] %}\n {% for column in source_columns %}\n {% do quoted_source_columns.append(adapter.quote(column.name)) %}\n {% endfor %}\n\n {% set final_sql = snapshot_merge_sql(\n target = target_relation,\n source = staging_table,\n insert_cols = quoted_source_columns\n )\n %}\n\n {% endif %}\n\n {% call statement('main') %}\n {{ final_sql }}\n {% endcall %}\n\n {% set should_revoke = should_revoke(target_relation_exists, full_refresh_mode=False) %}\n {% do apply_grants(target_relation, grant_config, should_revoke=should_revoke) %}\n\n {% do persist_docs(target_relation, model) %}\n\n {% if not target_relation_exists %}\n {% do create_indexes(target_relation) %}\n {% endif %}\n\n {{ run_hooks(post_hooks, inside_transaction=True) }}\n\n {{ adapter.commit() }}\n\n {% if staging_table is defined %}\n {% do post_snapshot(staging_table) %}\n {% endif %}\n\n {{ run_hooks(post_hooks, inside_transaction=False) }}\n\n {{ return({'relations': [target_relation]}) }}\n\n{% endmaterialization %}", + "meta": {}, + "name": "materialization_snapshot_default", + "original_file_path": "macros/materializations/snapshots/snapshot.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/materializations/snapshots/snapshot.sql", + "resource_type": "macro", + "supported_languages": [ + "sql" + ], + "unique_id": "macro.dbt.materialization_snapshot_default" + }, + "macro.dbt.materialization_table_default": { + "arguments": [], + "created_at": 1719485736.3933172, + "depends_on": { + "macros": [ + "macro.dbt.load_cached_relation", + "macro.dbt.make_intermediate_relation", + "macro.dbt.make_backup_relation", + "macro.dbt.drop_relation_if_exists", + "macro.dbt.run_hooks", + "macro.dbt.statement", + "macro.dbt.get_create_table_as_sql", + "macro.dbt.create_indexes", + "macro.dbt.should_revoke", + "macro.dbt.apply_grants", + "macro.dbt.persist_docs" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% materialization table, default %}\n\n {%- set existing_relation = load_cached_relation(this) -%}\n {%- set target_relation = this.incorporate(type='table') %}\n {%- set intermediate_relation = make_intermediate_relation(target_relation) -%}\n -- the intermediate_relation should not already exist in the database; get_relation\n -- will return None in that case. Otherwise, we get a relation that we can drop\n -- later, before we try to use this name for the current operation\n {%- set preexisting_intermediate_relation = load_cached_relation(intermediate_relation) -%}\n /*\n See ../view/view.sql for more information about this relation.\n */\n {%- set backup_relation_type = 'table' if existing_relation is none else existing_relation.type -%}\n {%- set backup_relation = make_backup_relation(target_relation, backup_relation_type) -%}\n -- as above, the backup_relation should not already exist\n {%- set preexisting_backup_relation = load_cached_relation(backup_relation) -%}\n -- grab current tables grants config for comparision later on\n {% set grant_config = config.get('grants') %}\n\n -- drop the temp relations if they exist already in the database\n {{ drop_relation_if_exists(preexisting_intermediate_relation) }}\n {{ drop_relation_if_exists(preexisting_backup_relation) }}\n\n {{ run_hooks(pre_hooks, inside_transaction=False) }}\n\n -- `BEGIN` happens here:\n {{ run_hooks(pre_hooks, inside_transaction=True) }}\n\n -- build model\n {% call statement('main') -%}\n {{ get_create_table_as_sql(False, intermediate_relation, sql) }}\n {%- endcall %}\n\n -- cleanup\n {% if existing_relation is not none %}\n /* Do the equivalent of rename_if_exists. 'existing_relation' could have been dropped\n since the variable was first set. */\n {% set existing_relation = load_cached_relation(existing_relation) %}\n {% if existing_relation is not none %}\n {{ adapter.rename_relation(existing_relation, backup_relation) }}\n {% endif %}\n {% endif %}\n\n {{ adapter.rename_relation(intermediate_relation, target_relation) }}\n\n {% do create_indexes(target_relation) %}\n\n {{ run_hooks(post_hooks, inside_transaction=True) }}\n\n {% set should_revoke = should_revoke(existing_relation, full_refresh_mode=True) %}\n {% do apply_grants(target_relation, grant_config, should_revoke=should_revoke) %}\n\n {% do persist_docs(target_relation, model) %}\n\n -- `COMMIT` happens here\n {{ adapter.commit() }}\n\n -- finally, drop the existing/backup relation after the commit\n {{ drop_relation_if_exists(backup_relation) }}\n\n {{ run_hooks(post_hooks, inside_transaction=False) }}\n\n {{ return({'relations': [target_relation]}) }}\n{% endmaterialization %}", + "meta": {}, + "name": "materialization_table_default", + "original_file_path": "macros/materializations/models/table.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/materializations/models/table.sql", + "resource_type": "macro", + "supported_languages": [ + "sql" + ], + "unique_id": "macro.dbt.materialization_table_default" + }, + "macro.dbt.materialization_test_default": { + "arguments": [], + "created_at": 1719485736.370986, + "depends_on": { + "macros": [ + "macro.dbt.should_store_failures", + "macro.dbt.statement", + "macro.dbt.get_create_sql", + "macro.dbt.get_test_sql" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{%- materialization test, default -%}\n\n {% set relations = [] %}\n\n {% if should_store_failures() %}\n\n {% set identifier = model['alias'] %}\n {% set old_relation = adapter.get_relation(database=database, schema=schema, identifier=identifier) %}\n\n {% set store_failures_as = config.get('store_failures_as') %}\n -- if `--store-failures` is invoked via command line and `store_failures_as` is not set,\n -- config.get('store_failures_as', 'table') returns None, not 'table'\n {% if store_failures_as == none %}{% set store_failures_as = 'table' %}{% endif %}\n {% if store_failures_as not in ['table', 'view'] %}\n {{ exceptions.raise_compiler_error(\n \"'\" ~ store_failures_as ~ \"' is not a valid value for `store_failures_as`. \"\n \"Accepted values are: ['ephemeral', 'table', 'view']\"\n ) }}\n {% endif %}\n\n {% set target_relation = api.Relation.create(\n identifier=identifier, schema=schema, database=database, type=store_failures_as) -%} %}\n\n {% if old_relation %}\n {% do adapter.drop_relation(old_relation) %}\n {% endif %}\n\n {% call statement(auto_begin=True) %}\n {{ get_create_sql(target_relation, sql) }}\n {% endcall %}\n\n {% do relations.append(target_relation) %}\n\n {% set main_sql %}\n select *\n from {{ target_relation }}\n {% endset %}\n\n {{ adapter.commit() }}\n\n {% else %}\n\n {% set main_sql = sql %}\n\n {% endif %}\n\n {% set limit = config.get('limit') %}\n {% set fail_calc = config.get('fail_calc') %}\n {% set warn_if = config.get('warn_if') %}\n {% set error_if = config.get('error_if') %}\n\n {% call statement('main', fetch_result=True) -%}\n\n {{ get_test_sql(main_sql, fail_calc, warn_if, error_if, limit)}}\n\n {%- endcall %}\n\n {{ return({'relations': relations}) }}\n\n{%- endmaterialization -%}", + "meta": {}, + "name": "materialization_test_default", + "original_file_path": "macros/materializations/tests/test.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/materializations/tests/test.sql", + "resource_type": "macro", + "supported_languages": [ + "sql" + ], + "unique_id": "macro.dbt.materialization_test_default" + }, + "macro.dbt.materialization_unit_default": { + "arguments": [], + "created_at": 1719485736.3763871, + "depends_on": { + "macros": [ + "macro.dbt.get_columns_in_query", + "macro.dbt.make_temp_relation", + "macro.dbt.run_query", + "macro.dbt.get_create_table_as_sql", + "macro.dbt.get_empty_subquery_sql", + "macro.dbt.get_expected_sql", + "macro.dbt.get_unit_test_sql", + "macro.dbt.statement" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{%- materialization unit, default -%}\n\n {% set relations = [] %}\n\n {% set expected_rows = config.get('expected_rows') %}\n {% set expected_sql = config.get('expected_sql') %}\n {% set tested_expected_column_names = expected_rows[0].keys() if (expected_rows | length ) > 0 else get_columns_in_query(sql) %} %}\n\n {%- set target_relation = this.incorporate(type='table') -%}\n {%- set temp_relation = make_temp_relation(target_relation)-%}\n {% do run_query(get_create_table_as_sql(True, temp_relation, get_empty_subquery_sql(sql))) %}\n {%- set columns_in_relation = adapter.get_columns_in_relation(temp_relation) -%}\n {%- set column_name_to_data_types = {} -%}\n {%- for column in columns_in_relation -%}\n {%- do column_name_to_data_types.update({column.name|lower: column.data_type}) -%}\n {%- endfor -%}\n\n {% if not expected_sql %}\n {% set expected_sql = get_expected_sql(expected_rows, column_name_to_data_types) %}\n {% endif %}\n {% set unit_test_sql = get_unit_test_sql(sql, expected_sql, tested_expected_column_names) %}\n\n {% call statement('main', fetch_result=True) -%}\n\n {{ unit_test_sql }}\n\n {%- endcall %}\n\n {% do adapter.drop_relation(temp_relation) %}\n\n {{ return({'relations': relations}) }}\n\n{%- endmaterialization -%}", + "meta": {}, + "name": "materialization_unit_default", + "original_file_path": "macros/materializations/tests/unit.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/materializations/tests/unit.sql", + "resource_type": "macro", + "supported_languages": [ + "sql" + ], + "unique_id": "macro.dbt.materialization_unit_default" + }, + "macro.dbt.materialization_view_default": { + "arguments": [], + "created_at": 1719485736.390083, + "depends_on": { + "macros": [ + "macro.dbt.load_cached_relation", + "macro.dbt.make_intermediate_relation", + "macro.dbt.make_backup_relation", + "macro.dbt.run_hooks", + "macro.dbt.drop_relation_if_exists", + "macro.dbt.statement", + "macro.dbt.get_create_view_as_sql", + "macro.dbt.should_revoke", + "macro.dbt.apply_grants", + "macro.dbt.persist_docs" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{%- materialization view, default -%}\n\n {%- set existing_relation = load_cached_relation(this) -%}\n {%- set target_relation = this.incorporate(type='view') -%}\n {%- set intermediate_relation = make_intermediate_relation(target_relation) -%}\n\n -- the intermediate_relation should not already exist in the database; get_relation\n -- will return None in that case. Otherwise, we get a relation that we can drop\n -- later, before we try to use this name for the current operation\n {%- set preexisting_intermediate_relation = load_cached_relation(intermediate_relation) -%}\n /*\n This relation (probably) doesn't exist yet. If it does exist, it's a leftover from\n a previous run, and we're going to try to drop it immediately. At the end of this\n materialization, we're going to rename the \"existing_relation\" to this identifier,\n and then we're going to drop it. In order to make sure we run the correct one of:\n - drop view ...\n - drop table ...\n\n We need to set the type of this relation to be the type of the existing_relation, if it exists,\n or else \"view\" as a sane default if it does not. Note that if the existing_relation does not\n exist, then there is nothing to move out of the way and subsequentally drop. In that case,\n this relation will be effectively unused.\n */\n {%- set backup_relation_type = 'view' if existing_relation is none else existing_relation.type -%}\n {%- set backup_relation = make_backup_relation(target_relation, backup_relation_type) -%}\n -- as above, the backup_relation should not already exist\n {%- set preexisting_backup_relation = load_cached_relation(backup_relation) -%}\n -- grab current tables grants config for comparision later on\n {% set grant_config = config.get('grants') %}\n\n {{ run_hooks(pre_hooks, inside_transaction=False) }}\n\n -- drop the temp relations if they exist already in the database\n {{ drop_relation_if_exists(preexisting_intermediate_relation) }}\n {{ drop_relation_if_exists(preexisting_backup_relation) }}\n\n -- `BEGIN` happens here:\n {{ run_hooks(pre_hooks, inside_transaction=True) }}\n\n -- build model\n {% call statement('main') -%}\n {{ get_create_view_as_sql(intermediate_relation, sql) }}\n {%- endcall %}\n\n -- cleanup\n -- move the existing view out of the way\n {% if existing_relation is not none %}\n /* Do the equivalent of rename_if_exists. 'existing_relation' could have been dropped\n since the variable was first set. */\n {% set existing_relation = load_cached_relation(existing_relation) %}\n {% if existing_relation is not none %}\n {{ adapter.rename_relation(existing_relation, backup_relation) }}\n {% endif %}\n {% endif %}\n {{ adapter.rename_relation(intermediate_relation, target_relation) }}\n\n {% set should_revoke = should_revoke(existing_relation, full_refresh_mode=True) %}\n {% do apply_grants(target_relation, grant_config, should_revoke=should_revoke) %}\n\n {% do persist_docs(target_relation, model) %}\n\n {{ run_hooks(post_hooks, inside_transaction=True) }}\n\n {{ adapter.commit() }}\n\n {{ drop_relation_if_exists(backup_relation) }}\n\n {{ run_hooks(post_hooks, inside_transaction=False) }}\n\n {{ return({'relations': [target_relation]}) }}\n\n{%- endmaterialization -%}", + "meta": {}, + "name": "materialization_view_default", + "original_file_path": "macros/materializations/models/view.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/materializations/models/view.sql", + "resource_type": "macro", + "supported_languages": [ + "sql" + ], + "unique_id": "macro.dbt.materialization_view_default" + }, + "macro.dbt.materialized_view_execute_build_sql": { + "arguments": [], + "created_at": 1719485736.386875, + "depends_on": { + "macros": [ + "macro.dbt.run_hooks", + "macro.dbt.statement", + "macro.dbt.should_revoke", + "macro.dbt.apply_grants", + "macro.dbt.persist_docs" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro materialized_view_execute_build_sql(build_sql, existing_relation, target_relation, post_hooks) %}\n\n -- `BEGIN` happens here:\n {{ run_hooks(pre_hooks, inside_transaction=True) }}\n\n {% set grant_config = config.get('grants') %}\n\n {% call statement(name=\"main\") %}\n {{ build_sql }}\n {% endcall %}\n\n {% set should_revoke = should_revoke(existing_relation, full_refresh_mode=True) %}\n {% do apply_grants(target_relation, grant_config, should_revoke=should_revoke) %}\n\n {% do persist_docs(target_relation, model) %}\n\n {{ run_hooks(post_hooks, inside_transaction=True) }}\n\n {{ adapter.commit() }}\n\n{% endmacro %}", + "meta": {}, + "name": "materialized_view_execute_build_sql", + "original_file_path": "macros/materializations/models/materialized_view.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/materializations/models/materialized_view.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.materialized_view_execute_build_sql" + }, + "macro.dbt.materialized_view_execute_no_op": { + "arguments": [], + "created_at": 1719485736.385866, + "depends_on": { + "macros": [] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro materialized_view_execute_no_op(target_relation) %}\n {% do store_raw_result(\n name=\"main\",\n message=\"skip \" ~ target_relation,\n code=\"skip\",\n rows_affected=\"-1\"\n ) %}\n{% endmacro %}", + "meta": {}, + "name": "materialized_view_execute_no_op", + "original_file_path": "macros/materializations/models/materialized_view.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/materializations/models/materialized_view.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.materialized_view_execute_no_op" + }, + "macro.dbt.materialized_view_get_build_sql": { + "arguments": [], + "created_at": 1719485736.385609, + "depends_on": { + "macros": [ + "macro.dbt.should_full_refresh", + "macro.dbt.get_create_materialized_view_as_sql", + "macro.dbt.get_replace_sql", + "macro.dbt.get_materialized_view_configuration_changes", + "macro.dbt.refresh_materialized_view", + "macro.dbt.get_alter_materialized_view_as_sql" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro materialized_view_get_build_sql(existing_relation, target_relation, backup_relation, intermediate_relation) %}\n\n {% set full_refresh_mode = should_full_refresh() %}\n\n -- determine the scenario we're in: create, full_refresh, alter, refresh data\n {% if existing_relation is none %}\n {% set build_sql = get_create_materialized_view_as_sql(target_relation, sql) %}\n {% elif full_refresh_mode or not existing_relation.is_materialized_view %}\n {% set build_sql = get_replace_sql(existing_relation, target_relation, sql) %}\n {% else %}\n\n -- get config options\n {% set on_configuration_change = config.get('on_configuration_change') %}\n {% set configuration_changes = get_materialized_view_configuration_changes(existing_relation, config) %}\n\n {% if configuration_changes is none %}\n {% set build_sql = refresh_materialized_view(target_relation) %}\n\n {% elif on_configuration_change == 'apply' %}\n {% set build_sql = get_alter_materialized_view_as_sql(target_relation, configuration_changes, sql, existing_relation, backup_relation, intermediate_relation) %}\n {% elif on_configuration_change == 'continue' %}\n {% set build_sql = '' %}\n {{ exceptions.warn(\"Configuration changes were identified and `on_configuration_change` was set to `continue` for `\" ~ target_relation ~ \"`\") }}\n {% elif on_configuration_change == 'fail' %}\n {{ exceptions.raise_fail_fast_error(\"Configuration changes were identified and `on_configuration_change` was set to `fail` for `\" ~ target_relation ~ \"`\") }}\n\n {% else %}\n -- this only happens if the user provides a value other than `apply`, 'skip', 'fail'\n {{ exceptions.raise_compiler_error(\"Unexpected configuration scenario\") }}\n\n {% endif %}\n\n {% endif %}\n\n {% do return(build_sql) %}\n\n{% endmacro %}", + "meta": {}, + "name": "materialized_view_get_build_sql", + "original_file_path": "macros/materializations/models/materialized_view.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/materializations/models/materialized_view.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.materialized_view_get_build_sql" + }, + "macro.dbt.materialized_view_setup": { + "arguments": [], + "created_at": 1719485736.3838332, + "depends_on": { + "macros": [ + "macro.dbt.load_cached_relation", + "macro.dbt.drop_relation_if_exists", + "macro.dbt.run_hooks" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro materialized_view_setup(backup_relation, intermediate_relation, pre_hooks) %}\n\n -- backup_relation and intermediate_relation should not already exist in the database\n -- it's possible these exist because of a previous run that exited unexpectedly\n {% set preexisting_backup_relation = load_cached_relation(backup_relation) %}\n {% set preexisting_intermediate_relation = load_cached_relation(intermediate_relation) %}\n\n -- drop the temp relations if they exist already in the database\n {{ drop_relation_if_exists(preexisting_backup_relation) }}\n {{ drop_relation_if_exists(preexisting_intermediate_relation) }}\n\n {{ run_hooks(pre_hooks, inside_transaction=False) }}\n\n{% endmacro %}", + "meta": {}, + "name": "materialized_view_setup", + "original_file_path": "macros/materializations/models/materialized_view.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/materializations/models/materialized_view.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.materialized_view_setup" + }, + "macro.dbt.materialized_view_teardown": { + "arguments": [], + "created_at": 1719485736.384107, + "depends_on": { + "macros": [ + "macro.dbt.drop_relation_if_exists", + "macro.dbt.run_hooks" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro materialized_view_teardown(backup_relation, intermediate_relation, post_hooks) %}\n\n -- drop the temp relations if they exist to leave the database clean for the next run\n {{ drop_relation_if_exists(backup_relation) }}\n {{ drop_relation_if_exists(intermediate_relation) }}\n\n {{ run_hooks(post_hooks, inside_transaction=False) }}\n\n{% endmacro %}", + "meta": {}, + "name": "materialized_view_teardown", + "original_file_path": "macros/materializations/models/materialized_view.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/materializations/models/materialized_view.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.materialized_view_teardown" + }, + "macro.dbt.noop_statement": { + "arguments": [], + "created_at": 1719485736.5199249, + "depends_on": { + "macros": [] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro noop_statement(name=None, message=None, code=None, rows_affected=None, res=None) -%}\n {%- set sql = caller() -%}\n\n {%- if name == 'main' -%}\n {{ log('Writing runtime SQL for node \"{}\"'.format(model['unique_id'])) }}\n {{ write(sql) }}\n {%- endif -%}\n\n {%- if name is not none -%}\n {{ store_raw_result(name, message=message, code=code, rows_affected=rows_affected, agate_table=res) }}\n {%- endif -%}\n\n{%- endmacro %}", + "meta": {}, + "name": "noop_statement", + "original_file_path": "macros/etc/statement.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/etc/statement.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.noop_statement" + }, + "macro.dbt.partition_range": { + "arguments": [], + "created_at": 1719485736.5252612, + "depends_on": { + "macros": [ + "macro.dbt.dates_in_range" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro partition_range(raw_partition_date, date_fmt='%Y%m%d') %}\n {% set partition_range = (raw_partition_date | string).split(\",\") %}\n\n {% if (partition_range | length) == 1 %}\n {% set start_date = partition_range[0] %}\n {% set end_date = none %}\n {% elif (partition_range | length) == 2 %}\n {% set start_date = partition_range[0] %}\n {% set end_date = partition_range[1] %}\n {% else %}\n {{ exceptions.raise_compiler_error(\"Invalid partition time. Expected format: {Start Date}[,{End Date}]. Got: \" ~ raw_partition_date) }}\n {% endif %}\n\n {{ return(dates_in_range(start_date, end_date, in_fmt=date_fmt)) }}\n{% endmacro %}", + "meta": {}, + "name": "partition_range", + "original_file_path": "macros/etc/datetime.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/etc/datetime.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.partition_range" + }, + "macro.dbt.persist_docs": { + "arguments": [], + "created_at": 1719485736.585922, + "depends_on": { + "macros": [ + "macro.dbt.default__persist_docs" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro persist_docs(relation, model, for_relation=true, for_columns=true) -%}\n {{ return(adapter.dispatch('persist_docs', 'dbt')(relation, model, for_relation, for_columns)) }}\n{% endmacro %}", + "meta": {}, + "name": "persist_docs", + "original_file_path": "macros/adapters/persist_docs.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/adapters/persist_docs.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.persist_docs" + }, + "macro.dbt.position": { + "arguments": [], + "created_at": 1719485736.543356, + "depends_on": { + "macros": [ + "macro.dbt.default__position" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro position(substring_text, string_text) -%}\n {{ return(adapter.dispatch('position', 'dbt') (substring_text, string_text)) }}\n{% endmacro %}", + "meta": {}, + "name": "position", + "original_file_path": "macros/utils/position.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/utils/position.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.position" + }, + "macro.dbt.post_snapshot": { + "arguments": [], + "created_at": 1719485736.355165, + "depends_on": { + "macros": [ + "macro.dbt.default__post_snapshot" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro post_snapshot(staging_relation) %}\n {{ adapter.dispatch('post_snapshot', 'dbt')(staging_relation) }}\n{% endmacro %}", + "meta": {}, + "name": "post_snapshot", + "original_file_path": "macros/materializations/snapshots/helpers.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/materializations/snapshots/helpers.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.post_snapshot" + }, + "macro.dbt.process_schema_changes": { + "arguments": [], + "created_at": 1719485736.44261, + "depends_on": { + "macros": [ + "macro.dbt.check_for_schema_changes", + "macro.dbt.sync_column_schemas" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro process_schema_changes(on_schema_change, source_relation, target_relation) %}\n\n {% if on_schema_change == 'ignore' %}\n\n {{ return({}) }}\n\n {% else %}\n\n {% set schema_changes_dict = check_for_schema_changes(source_relation, target_relation) %}\n\n {% if schema_changes_dict['schema_changed'] %}\n\n {% if on_schema_change == 'fail' %}\n\n {% set fail_msg %}\n The source and target schemas on this incremental model are out of sync!\n They can be reconciled in several ways:\n - set the `on_schema_change` config to either append_new_columns or sync_all_columns, depending on your situation.\n - Re-run the incremental model with `full_refresh: True` to update the target schema.\n - update the schema manually and re-run the process.\n\n Additional troubleshooting context:\n Source columns not in target: {{ schema_changes_dict['source_not_in_target'] }}\n Target columns not in source: {{ schema_changes_dict['target_not_in_source'] }}\n New column types: {{ schema_changes_dict['new_target_types'] }}\n {% endset %}\n\n {% do exceptions.raise_compiler_error(fail_msg) %}\n\n {# -- unless we ignore, run the sync operation per the config #}\n {% else %}\n\n {% do sync_column_schemas(on_schema_change, target_relation, schema_changes_dict) %}\n\n {% endif %}\n\n {% endif %}\n\n {{ return(schema_changes_dict['source_columns']) }}\n\n {% endif %}\n\n{% endmacro %}", + "meta": {}, + "name": "process_schema_changes", + "original_file_path": "macros/materializations/models/incremental/on_schema_change.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/materializations/models/incremental/on_schema_change.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.process_schema_changes" + }, + "macro.dbt.py_current_timestring": { + "arguments": [], + "created_at": 1719485736.5255191, + "depends_on": { + "macros": [] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro py_current_timestring() %}\n {% set dt = modules.datetime.datetime.now() %}\n {% do return(dt.strftime(\"%Y%m%d%H%M%S%f\")) %}\n{% endmacro %}", + "meta": {}, + "name": "py_current_timestring", + "original_file_path": "macros/etc/datetime.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/etc/datetime.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.py_current_timestring" + }, + "macro.dbt.py_script_comment": { + "arguments": [], + "created_at": 1719485736.6181471, + "depends_on": { + "macros": [] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{%macro py_script_comment()%}\n{%endmacro%}", + "meta": {}, + "name": "py_script_comment", + "original_file_path": "macros/python_model/python.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/python_model/python.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.py_script_comment" + }, + "macro.dbt.py_script_postfix": { + "arguments": [], + "created_at": 1719485736.618067, + "depends_on": { + "macros": [ + "macro.dbt.build_ref_function", + "macro.dbt.build_source_function", + "macro.dbt.build_config_dict", + "macro.dbt.resolve_model_name", + "macro.dbt.is_incremental", + "macro.dbt.py_script_comment" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro py_script_postfix(model) %}\n# This part is user provided model code\n# you will need to copy the next section to run the code\n# COMMAND ----------\n# this part is dbt logic for get ref work, do not modify\n\n{{ build_ref_function(model ) }}\n{{ build_source_function(model ) }}\n{{ build_config_dict(model) }}\n\nclass config:\n def __init__(self, *args, **kwargs):\n pass\n\n @staticmethod\n def get(key, default=None):\n return config_dict.get(key, default)\n\nclass this:\n \"\"\"dbt.this() or dbt.this.identifier\"\"\"\n database = \"{{ this.database }}\"\n schema = \"{{ this.schema }}\"\n identifier = \"{{ this.identifier }}\"\n {% set this_relation_name = resolve_model_name(this) %}\n def __repr__(self):\n return '{{ this_relation_name }}'\n\n\nclass dbtObj:\n def __init__(self, load_df_function) -> None:\n self.source = lambda *args: source(*args, dbt_load_df_function=load_df_function)\n self.ref = lambda *args, **kwargs: ref(*args, **kwargs, dbt_load_df_function=load_df_function)\n self.config = config\n self.this = this()\n self.is_incremental = {{ is_incremental() }}\n\n# COMMAND ----------\n{{py_script_comment()}}\n{% endmacro %}", + "meta": {}, + "name": "py_script_postfix", + "original_file_path": "macros/python_model/python.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/python_model/python.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.py_script_postfix" + }, + "macro.dbt.refresh_materialized_view": { + "arguments": [], + "created_at": 1719485736.489407, + "depends_on": { + "macros": [ + "macro.dbt_postgres.postgres__refresh_materialized_view" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro refresh_materialized_view(relation) %}\n {{- log('Applying REFRESH to: ' ~ relation) -}}\n {{- adapter.dispatch('refresh_materialized_view', 'dbt')(relation) -}}\n{% endmacro %}", + "meta": {}, + "name": "refresh_materialized_view", + "original_file_path": "macros/relations/materialized_view/refresh.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/relations/materialized_view/refresh.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.refresh_materialized_view" + }, + "macro.dbt.rename_relation": { + "arguments": [], + "created_at": 1719485736.478327, + "depends_on": { + "macros": [ + "macro.dbt.default__rename_relation" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro rename_relation(from_relation, to_relation) -%}\n {{ return(adapter.dispatch('rename_relation', 'dbt')(from_relation, to_relation)) }}\n{% endmacro %}", + "meta": {}, + "name": "rename_relation", + "original_file_path": "macros/relations/rename.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/relations/rename.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.rename_relation" + }, + "macro.dbt.replace": { + "arguments": [], + "created_at": 1719485736.532748, + "depends_on": { + "macros": [ + "macro.dbt.default__replace" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro replace(field, old_chars, new_chars) -%}\n {{ return(adapter.dispatch('replace', 'dbt') (field, old_chars, new_chars)) }}\n{% endmacro %}", + "meta": {}, + "name": "replace", + "original_file_path": "macros/utils/replace.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/utils/replace.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.replace" + }, + "macro.dbt.reset_csv_table": { + "arguments": [], + "created_at": 1719485736.460277, + "depends_on": { + "macros": [ + "macro.dbt.default__reset_csv_table" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro reset_csv_table(model, full_refresh, old_relation, agate_table) -%}\n {{ adapter.dispatch('reset_csv_table', 'dbt')(model, full_refresh, old_relation, agate_table) }}\n{%- endmacro %}", + "meta": {}, + "name": "reset_csv_table", + "original_file_path": "macros/materializations/seeds/helpers.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/materializations/seeds/helpers.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.reset_csv_table" + }, + "macro.dbt.resolve_model_name": { + "arguments": [], + "created_at": 1719485736.614536, + "depends_on": { + "macros": [ + "macro.dbt.default__resolve_model_name" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro resolve_model_name(input_model_name) %}\n {{ return(adapter.dispatch('resolve_model_name', 'dbt')(input_model_name)) }}\n{% endmacro %}", + "meta": {}, + "name": "resolve_model_name", + "original_file_path": "macros/python_model/python.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/python_model/python.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.resolve_model_name" + }, + "macro.dbt.right": { + "arguments": [], + "created_at": 1719485736.538353, + "depends_on": { + "macros": [ + "macro.dbt.default__right" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro right(string_text, length_expression) -%}\n {{ return(adapter.dispatch('right', 'dbt') (string_text, length_expression)) }}\n{% endmacro %}", + "meta": {}, + "name": "right", + "original_file_path": "macros/utils/right.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/utils/right.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.right" + }, + "macro.dbt.run_hooks": { + "arguments": [], + "created_at": 1719485736.336357, + "depends_on": { + "macros": [ + "macro.dbt.statement" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro run_hooks(hooks, inside_transaction=True) %}\n {% for hook in hooks | selectattr('transaction', 'equalto', inside_transaction) %}\n {% if not inside_transaction and loop.first %}\n {% call statement(auto_begin=inside_transaction) %}\n commit;\n {% endcall %}\n {% endif %}\n {% set rendered = render(hook.get('sql')) | trim %}\n {% if (rendered | length) > 0 %}\n {% call statement(auto_begin=inside_transaction) %}\n {{ rendered }}\n {% endcall %}\n {% endif %}\n {% endfor %}\n{% endmacro %}", + "meta": {}, + "name": "run_hooks", + "original_file_path": "macros/materializations/hooks.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/materializations/hooks.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.run_hooks" + }, + "macro.dbt.run_query": { + "arguments": [], + "created_at": 1719485736.5202482, + "depends_on": { + "macros": [ + "macro.dbt.statement" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro run_query(sql) %}\n {% call statement(\"run_query_statement\", fetch_result=true, auto_begin=false) %}\n {{ sql }}\n {% endcall %}\n\n {% do return(load_result(\"run_query_statement\").table) %}\n{% endmacro %}", + "meta": {}, + "name": "run_query", + "original_file_path": "macros/etc/statement.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/etc/statement.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.run_query" + }, + "macro.dbt.safe_cast": { + "arguments": [], + "created_at": 1719485736.540742, + "depends_on": { + "macros": [ + "macro.dbt.default__safe_cast" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro safe_cast(field, type) %}\n {{ return(adapter.dispatch('safe_cast', 'dbt') (field, type)) }}\n{% endmacro %}", + "meta": {}, + "name": "safe_cast", + "original_file_path": "macros/utils/safe_cast.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/utils/safe_cast.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.safe_cast" + }, + "macro.dbt.set_sql_header": { + "arguments": [], + "created_at": 1719485736.338798, + "depends_on": { + "macros": [] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro set_sql_header(config) -%}\n {{ config.set('sql_header', caller()) }}\n{%- endmacro %}", + "meta": {}, + "name": "set_sql_header", + "original_file_path": "macros/materializations/configs.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/materializations/configs.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.set_sql_header" + }, + "macro.dbt.should_full_refresh": { + "arguments": [], + "created_at": 1719485736.339242, + "depends_on": { + "macros": [] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro should_full_refresh() %}\n {% set config_full_refresh = config.get('full_refresh') %}\n {% if config_full_refresh is none %}\n {% set config_full_refresh = flags.FULL_REFRESH %}\n {% endif %}\n {% do return(config_full_refresh) %}\n{% endmacro %}", + "meta": {}, + "name": "should_full_refresh", + "original_file_path": "macros/materializations/configs.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/materializations/configs.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.should_full_refresh" + }, + "macro.dbt.should_revoke": { + "arguments": [], + "created_at": 1719485736.573465, + "depends_on": { + "macros": [ + "macro.dbt.copy_grants" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro should_revoke(existing_relation, full_refresh_mode=True) %}\n\n {% if not existing_relation %}\n {#-- The table doesn't already exist, so no grants to copy over --#}\n {{ return(False) }}\n {% elif full_refresh_mode %}\n {#-- The object is being REPLACED -- whether grants are copied over depends on the value of user config --#}\n {{ return(copy_grants()) }}\n {% else %}\n {#-- The table is being merged/upserted/inserted -- grants will be carried over --#}\n {{ return(True) }}\n {% endif %}\n\n{% endmacro %}", + "meta": {}, + "name": "should_revoke", + "original_file_path": "macros/adapters/apply_grants.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/adapters/apply_grants.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.should_revoke" + }, + "macro.dbt.should_store_failures": { + "arguments": [], + "created_at": 1719485736.339565, + "depends_on": { + "macros": [] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro should_store_failures() %}\n {% set config_store_failures = config.get('store_failures') %}\n {% if config_store_failures is none %}\n {% set config_store_failures = flags.STORE_FAILURES %}\n {% endif %}\n {% do return(config_store_failures) %}\n{% endmacro %}", + "meta": {}, + "name": "should_store_failures", + "original_file_path": "macros/materializations/configs.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/materializations/configs.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.should_store_failures" + }, + "macro.dbt.snapshot_check_all_get_existing_columns": { + "arguments": [], + "created_at": 1719485736.3482602, + "depends_on": { + "macros": [ + "macro.dbt.get_columns_in_query" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro snapshot_check_all_get_existing_columns(node, target_exists, check_cols_config) -%}\n {%- if not target_exists -%}\n {#-- no table yet -> return whatever the query does --#}\n {{ return((false, query_columns)) }}\n {%- endif -%}\n\n {#-- handle any schema changes --#}\n {%- set target_relation = adapter.get_relation(database=node.database, schema=node.schema, identifier=node.alias) -%}\n\n {% if check_cols_config == 'all' %}\n {%- set query_columns = get_columns_in_query(node['compiled_code']) -%}\n\n {% elif check_cols_config is iterable and (check_cols_config | length) > 0 %}\n {#-- query for proper casing/quoting, to support comparison below --#}\n {%- set select_check_cols_from_target -%}\n {#-- N.B. The whitespace below is necessary to avoid edge case issue with comments --#}\n {#-- See: https://github.com/dbt-labs/dbt-core/issues/6781 --#}\n select {{ check_cols_config | join(', ') }} from (\n {{ node['compiled_code'] }}\n ) subq\n {%- endset -%}\n {% set query_columns = get_columns_in_query(select_check_cols_from_target) %}\n\n {% else %}\n {% do exceptions.raise_compiler_error(\"Invalid value for 'check_cols': \" ~ check_cols_config) %}\n {% endif %}\n\n {%- set existing_cols = adapter.get_columns_in_relation(target_relation) | map(attribute = 'name') | list -%}\n {%- set ns = namespace() -%} {#-- handle for-loop scoping with a namespace --#}\n {%- set ns.column_added = false -%}\n\n {%- set intersection = [] -%}\n {%- for col in query_columns -%}\n {%- if col in existing_cols -%}\n {%- do intersection.append(adapter.quote(col)) -%}\n {%- else -%}\n {% set ns.column_added = true %}\n {%- endif -%}\n {%- endfor -%}\n {{ return((ns.column_added, intersection)) }}\n{%- endmacro %}", + "meta": {}, + "name": "snapshot_check_all_get_existing_columns", + "original_file_path": "macros/materializations/snapshots/strategies.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/materializations/snapshots/strategies.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.snapshot_check_all_get_existing_columns" + }, + "macro.dbt.snapshot_check_strategy": { + "arguments": [], + "created_at": 1719485736.3500328, + "depends_on": { + "macros": [ + "macro.dbt.snapshot_get_time", + "macro.dbt.snapshot_check_all_get_existing_columns", + "macro.dbt.get_true_sql", + "macro.dbt.snapshot_hash_arguments" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro snapshot_check_strategy(node, snapshotted_rel, current_rel, config, target_exists) %}\n {% set check_cols_config = config['check_cols'] %}\n {% set primary_key = config['unique_key'] %}\n {% set invalidate_hard_deletes = config.get('invalidate_hard_deletes', false) %}\n {% set updated_at = config.get('updated_at', snapshot_get_time()) %}\n\n {% set column_added = false %}\n\n {% set column_added, check_cols = snapshot_check_all_get_existing_columns(node, target_exists, check_cols_config) %}\n\n {%- set row_changed_expr -%}\n (\n {%- if column_added -%}\n {{ get_true_sql() }}\n {%- else -%}\n {%- for col in check_cols -%}\n {{ snapshotted_rel }}.{{ col }} != {{ current_rel }}.{{ col }}\n or\n (\n (({{ snapshotted_rel }}.{{ col }} is null) and not ({{ current_rel }}.{{ col }} is null))\n or\n ((not {{ snapshotted_rel }}.{{ col }} is null) and ({{ current_rel }}.{{ col }} is null))\n )\n {%- if not loop.last %} or {% endif -%}\n {%- endfor -%}\n {%- endif -%}\n )\n {%- endset %}\n\n {% set scd_id_expr = snapshot_hash_arguments([primary_key, updated_at]) %}\n\n {% do return({\n \"unique_key\": primary_key,\n \"updated_at\": updated_at,\n \"row_changed\": row_changed_expr,\n \"scd_id\": scd_id_expr,\n \"invalidate_hard_deletes\": invalidate_hard_deletes\n }) %}\n{% endmacro %}", + "meta": {}, + "name": "snapshot_check_strategy", + "original_file_path": "macros/materializations/snapshots/strategies.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/materializations/snapshots/strategies.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.snapshot_check_strategy" + }, + "macro.dbt.snapshot_get_time": { + "arguments": [], + "created_at": 1719485736.55953, + "depends_on": { + "macros": [ + "macro.dbt_postgres.postgres__snapshot_get_time" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "\n\n{%- macro snapshot_get_time() -%}\n {{ adapter.dispatch('snapshot_get_time', 'dbt')() }}\n{%- endmacro -%}\n\n", + "meta": {}, + "name": "snapshot_get_time", + "original_file_path": "macros/adapters/timestamps.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/adapters/timestamps.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.snapshot_get_time" + }, + "macro.dbt.snapshot_hash_arguments": { + "arguments": [], + "created_at": 1719485736.3454158, + "depends_on": { + "macros": [ + "macro.dbt.default__snapshot_hash_arguments" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro snapshot_hash_arguments(args) -%}\n {{ adapter.dispatch('snapshot_hash_arguments', 'dbt')(args) }}\n{%- endmacro %}", + "meta": {}, + "name": "snapshot_hash_arguments", + "original_file_path": "macros/materializations/snapshots/strategies.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/materializations/snapshots/strategies.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.snapshot_hash_arguments" + }, + "macro.dbt.snapshot_merge_sql": { + "arguments": [], + "created_at": 1719485736.3400362, + "depends_on": { + "macros": [ + "macro.dbt_postgres.postgres__snapshot_merge_sql" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro snapshot_merge_sql(target, source, insert_cols) -%}\n {{ adapter.dispatch('snapshot_merge_sql', 'dbt')(target, source, insert_cols) }}\n{%- endmacro %}", + "meta": {}, + "name": "snapshot_merge_sql", + "original_file_path": "macros/materializations/snapshots/snapshot_merge.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/materializations/snapshots/snapshot_merge.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.snapshot_merge_sql" + }, + "macro.dbt.snapshot_staging_table": { + "arguments": [], + "created_at": 1719485736.355765, + "depends_on": { + "macros": [ + "macro.dbt.default__snapshot_staging_table" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro snapshot_staging_table(strategy, source_sql, target_relation) -%}\n {{ adapter.dispatch('snapshot_staging_table', 'dbt')(strategy, source_sql, target_relation) }}\n{% endmacro %}", + "meta": {}, + "name": "snapshot_staging_table", + "original_file_path": "macros/materializations/snapshots/helpers.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/materializations/snapshots/helpers.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.snapshot_staging_table" + }, + "macro.dbt.snapshot_string_as_time": { + "arguments": [], + "created_at": 1719485736.3465831, + "depends_on": { + "macros": [ + "macro.dbt_postgres.postgres__snapshot_string_as_time" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro snapshot_string_as_time(timestamp) -%}\n {{ adapter.dispatch('snapshot_string_as_time', 'dbt')(timestamp) }}\n{%- endmacro %}", + "meta": {}, + "name": "snapshot_string_as_time", + "original_file_path": "macros/materializations/snapshots/strategies.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/materializations/snapshots/strategies.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.snapshot_string_as_time" + }, + "macro.dbt.snapshot_timestamp_strategy": { + "arguments": [], + "created_at": 1719485736.346405, + "depends_on": { + "macros": [ + "macro.dbt.snapshot_hash_arguments" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro snapshot_timestamp_strategy(node, snapshotted_rel, current_rel, config, target_exists) %}\n {% set primary_key = config['unique_key'] %}\n {% set updated_at = config['updated_at'] %}\n {% set invalidate_hard_deletes = config.get('invalidate_hard_deletes', false) %}\n\n {#/*\n The snapshot relation might not have an {{ updated_at }} value if the\n snapshot strategy is changed from `check` to `timestamp`. We\n should use a dbt-created column for the comparison in the snapshot\n table instead of assuming that the user-supplied {{ updated_at }}\n will be present in the historical data.\n\n See https://github.com/dbt-labs/dbt-core/issues/2350\n */ #}\n {% set row_changed_expr -%}\n ({{ snapshotted_rel }}.dbt_valid_from < {{ current_rel }}.{{ updated_at }})\n {%- endset %}\n\n {% set scd_id_expr = snapshot_hash_arguments([primary_key, updated_at]) %}\n\n {% do return({\n \"unique_key\": primary_key,\n \"updated_at\": updated_at,\n \"row_changed\": row_changed_expr,\n \"scd_id\": scd_id_expr,\n \"invalidate_hard_deletes\": invalidate_hard_deletes\n }) %}\n{% endmacro %}", + "meta": {}, + "name": "snapshot_timestamp_strategy", + "original_file_path": "macros/materializations/snapshots/strategies.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/materializations/snapshots/strategies.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.snapshot_timestamp_strategy" + }, + "macro.dbt.split_part": { + "arguments": [], + "created_at": 1719485736.55408, + "depends_on": { + "macros": [ + "macro.dbt_postgres.postgres__split_part" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro split_part(string_text, delimiter_text, part_number) %}\n {{ return(adapter.dispatch('split_part', 'dbt') (string_text, delimiter_text, part_number)) }}\n{% endmacro %}", + "meta": {}, + "name": "split_part", + "original_file_path": "macros/utils/split_part.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/utils/split_part.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.split_part" + }, + "macro.dbt.sql_convert_columns_in_relation": { + "arguments": [], + "created_at": 1719485736.59661, + "depends_on": { + "macros": [] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro sql_convert_columns_in_relation(table) -%}\n {% set columns = [] %}\n {% for row in table %}\n {% do columns.append(api.Column(*row)) %}\n {% endfor %}\n {{ return(columns) }}\n{% endmacro %}", + "meta": {}, + "name": "sql_convert_columns_in_relation", + "original_file_path": "macros/adapters/columns.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/adapters/columns.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.sql_convert_columns_in_relation" + }, + "macro.dbt.statement": { + "arguments": [], + "created_at": 1719485736.51912, + "depends_on": { + "macros": [] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "\n{%- macro statement(name=None, fetch_result=False, auto_begin=True, language='sql') -%}\n {%- if execute: -%}\n {%- set compiled_code = caller() -%}\n\n {%- if name == 'main' -%}\n {{ log('Writing runtime {} for node \"{}\"'.format(language, model['unique_id'])) }}\n {{ write(compiled_code) }}\n {%- endif -%}\n {%- if language == 'sql'-%}\n {%- set res, table = adapter.execute(compiled_code, auto_begin=auto_begin, fetch=fetch_result) -%}\n {%- elif language == 'python' -%}\n {%- set res = submit_python_job(model, compiled_code) -%}\n {#-- TODO: What should table be for python models? --#}\n {%- set table = None -%}\n {%- else -%}\n {% do exceptions.raise_compiler_error(\"statement macro didn't get supported language\") %}\n {%- endif -%}\n\n {%- if name is not none -%}\n {{ store_result(name, response=res, agate_table=table) }}\n {%- endif -%}\n\n {%- endif -%}\n{%- endmacro %}", + "meta": {}, + "name": "statement", + "original_file_path": "macros/etc/statement.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/etc/statement.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.statement" + }, + "macro.dbt.strategy_dispatch": { + "arguments": [], + "created_at": 1719485736.345131, + "depends_on": { + "macros": [] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro strategy_dispatch(name) -%}\n{% set original_name = name %}\n {% if '.' in name %}\n {% set package_name, name = name.split(\".\", 1) %}\n {% else %}\n {% set package_name = none %}\n {% endif %}\n\n {% if package_name is none %}\n {% set package_context = context %}\n {% elif package_name in context %}\n {% set package_context = context[package_name] %}\n {% else %}\n {% set error_msg %}\n Could not find package '{{package_name}}', called with '{{original_name}}'\n {% endset %}\n {{ exceptions.raise_compiler_error(error_msg | trim) }}\n {% endif %}\n\n {%- set search_name = 'snapshot_' ~ name ~ '_strategy' -%}\n\n {% if search_name not in package_context %}\n {% set error_msg %}\n The specified strategy macro '{{name}}' was not found in package '{{ package_name }}'\n {% endset %}\n {{ exceptions.raise_compiler_error(error_msg | trim) }}\n {% endif %}\n {{ return(package_context[search_name]) }}\n{%- endmacro %}", + "meta": {}, + "name": "strategy_dispatch", + "original_file_path": "macros/materializations/snapshots/strategies.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/materializations/snapshots/strategies.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.strategy_dispatch" + }, + "macro.dbt.string_literal": { + "arguments": [], + "created_at": 1719485736.543961, + "depends_on": { + "macros": [ + "macro.dbt.default__string_literal" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{%- macro string_literal(value) -%}\n {{ return(adapter.dispatch('string_literal', 'dbt') (value)) }}\n{%- endmacro -%}\n\n", + "meta": {}, + "name": "string_literal", + "original_file_path": "macros/utils/literal.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/utils/literal.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.string_literal" + }, + "macro.dbt.support_multiple_grantees_per_dcl_statement": { + "arguments": [], + "created_at": 1719485736.572978, + "depends_on": { + "macros": [ + "macro.dbt.default__support_multiple_grantees_per_dcl_statement" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro support_multiple_grantees_per_dcl_statement() %}\n {{ return(adapter.dispatch('support_multiple_grantees_per_dcl_statement', 'dbt')()) }}\n{% endmacro %}", + "meta": {}, + "name": "support_multiple_grantees_per_dcl_statement", + "original_file_path": "macros/adapters/apply_grants.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/adapters/apply_grants.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.support_multiple_grantees_per_dcl_statement" + }, + "macro.dbt.sync_column_schemas": { + "arguments": [], + "created_at": 1719485736.4416761, + "depends_on": { + "macros": [ + "macro.dbt.alter_relation_add_remove_columns", + "macro.dbt.alter_column_type" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro sync_column_schemas(on_schema_change, target_relation, schema_changes_dict) %}\n\n {%- set add_to_target_arr = schema_changes_dict['source_not_in_target'] -%}\n\n {%- if on_schema_change == 'append_new_columns'-%}\n {%- if add_to_target_arr | length > 0 -%}\n {%- do alter_relation_add_remove_columns(target_relation, add_to_target_arr, none) -%}\n {%- endif -%}\n\n {% elif on_schema_change == 'sync_all_columns' %}\n {%- set remove_from_target_arr = schema_changes_dict['target_not_in_source'] -%}\n {%- set new_target_types = schema_changes_dict['new_target_types'] -%}\n\n {% if add_to_target_arr | length > 0 or remove_from_target_arr | length > 0 %}\n {%- do alter_relation_add_remove_columns(target_relation, add_to_target_arr, remove_from_target_arr) -%}\n {% endif %}\n\n {% if new_target_types != [] %}\n {% for ntt in new_target_types %}\n {% set column_name = ntt['column_name'] %}\n {% set new_type = ntt['new_type'] %}\n {% do alter_column_type(target_relation, column_name, new_type) %}\n {% endfor %}\n {% endif %}\n\n {% endif %}\n\n {% set schema_change_message %}\n In {{ target_relation }}:\n Schema change approach: {{ on_schema_change }}\n Columns added: {{ add_to_target_arr }}\n Columns removed: {{ remove_from_target_arr }}\n Data types changed: {{ new_target_types }}\n {% endset %}\n\n {% do log(schema_change_message) %}\n\n{% endmacro %}", + "meta": {}, + "name": "sync_column_schemas", + "original_file_path": "macros/materializations/models/incremental/on_schema_change.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/materializations/models/incremental/on_schema_change.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.sync_column_schemas" + }, + "macro.dbt.table_columns_and_constraints": { + "arguments": [], + "created_at": 1719485736.500673, + "depends_on": { + "macros": [] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro table_columns_and_constraints() %}\n {# loop through user_provided_columns to create DDL with data types and constraints #}\n {%- set raw_column_constraints = adapter.render_raw_columns_constraints(raw_columns=model['columns']) -%}\n {%- set raw_model_constraints = adapter.render_raw_model_constraints(raw_constraints=model['constraints']) -%}\n (\n {% for c in raw_column_constraints -%}\n {{ c }}{{ \",\" if not loop.last or raw_model_constraints }}\n {% endfor %}\n {% for c in raw_model_constraints -%}\n {{ c }}{{ \",\" if not loop.last }}\n {% endfor -%}\n )\n{% endmacro %}", + "meta": {}, + "name": "table_columns_and_constraints", + "original_file_path": "macros/relations/column/columns_spec_ddl.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/relations/column/columns_spec_ddl.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.table_columns_and_constraints" + }, + "macro.dbt.test_accepted_values": { + "arguments": [], + "created_at": 1719485736.6202009, + "depends_on": { + "macros": [ + "macro.dbt.default__test_accepted_values" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% test accepted_values(model, column_name, values, quote=True) %}\n {% set macro = adapter.dispatch('test_accepted_values', 'dbt') %}\n {{ macro(model, column_name, values, quote) }}\n{% endtest %}", + "meta": {}, + "name": "test_accepted_values", + "original_file_path": "tests/generic/builtin.sql", + "package_name": "dbt", + "patch_path": null, + "path": "tests/generic/builtin.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.test_accepted_values" + }, + "macro.dbt.test_not_null": { + "arguments": [], + "created_at": 1719485736.619859, + "depends_on": { + "macros": [ + "macro.dbt.default__test_not_null" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% test not_null(model, column_name) %}\n {% set macro = adapter.dispatch('test_not_null', 'dbt') %}\n {{ macro(model, column_name) }}\n{% endtest %}", + "meta": {}, + "name": "test_not_null", + "original_file_path": "tests/generic/builtin.sql", + "package_name": "dbt", + "patch_path": null, + "path": "tests/generic/builtin.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.test_not_null" + }, + "macro.dbt.test_relationships": { + "arguments": [], + "created_at": 1719485736.620512, + "depends_on": { + "macros": [ + "macro.dbt.default__test_relationships" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% test relationships(model, column_name, to, field) %}\n {% set macro = adapter.dispatch('test_relationships', 'dbt') %}\n {{ macro(model, column_name, to, field) }}\n{% endtest %}", + "meta": {}, + "name": "test_relationships", + "original_file_path": "tests/generic/builtin.sql", + "package_name": "dbt", + "patch_path": null, + "path": "tests/generic/builtin.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.test_relationships" + }, + "macro.dbt.test_unique": { + "arguments": [], + "created_at": 1719485736.619491, + "depends_on": { + "macros": [ + "macro.dbt.default__test_unique" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% test unique(model, column_name) %}\n {% set macro = adapter.dispatch('test_unique', 'dbt') %}\n {{ macro(model, column_name) }}\n{% endtest %}", + "meta": {}, + "name": "test_unique", + "original_file_path": "tests/generic/builtin.sql", + "package_name": "dbt", + "patch_path": null, + "path": "tests/generic/builtin.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.test_unique" + }, + "macro.dbt.truncate_relation": { + "arguments": [], + "created_at": 1719485736.567298, + "depends_on": { + "macros": [ + "macro.dbt.default__truncate_relation" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro truncate_relation(relation) -%}\n {{ return(adapter.dispatch('truncate_relation', 'dbt')(relation)) }}\n{% endmacro %}", + "meta": {}, + "name": "truncate_relation", + "original_file_path": "macros/adapters/relation.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/adapters/relation.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.truncate_relation" + }, + "macro.dbt.type_bigint": { + "arguments": [], + "created_at": 1719485736.548424, + "depends_on": { + "macros": [ + "macro.dbt.default__type_bigint" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "\n\n{%- macro type_bigint() -%}\n {{ return(adapter.dispatch('type_bigint', 'dbt')()) }}\n{%- endmacro -%}\n\n", + "meta": {}, + "name": "type_bigint", + "original_file_path": "macros/utils/data_types.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/utils/data_types.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.type_bigint" + }, + "macro.dbt.type_boolean": { + "arguments": [], + "created_at": 1719485736.55002, + "depends_on": { + "macros": [ + "macro.dbt.default__type_boolean" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "\n\n{%- macro type_boolean() -%}\n {{ return(adapter.dispatch('type_boolean', 'dbt')()) }}\n{%- endmacro -%}\n\n", + "meta": {}, + "name": "type_boolean", + "original_file_path": "macros/utils/data_types.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/utils/data_types.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.type_boolean" + }, + "macro.dbt.type_float": { + "arguments": [], + "created_at": 1719485736.547037, + "depends_on": { + "macros": [ + "macro.dbt.default__type_float" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "\n\n{%- macro type_float() -%}\n {{ return(adapter.dispatch('type_float', 'dbt')()) }}\n{%- endmacro -%}\n\n", + "meta": {}, + "name": "type_float", + "original_file_path": "macros/utils/data_types.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/utils/data_types.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.type_float" + }, + "macro.dbt.type_int": { + "arguments": [], + "created_at": 1719485736.54878, + "depends_on": { + "macros": [ + "macro.dbt.default__type_int" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "\n\n{%- macro type_int() -%}\n {{ return(adapter.dispatch('type_int', 'dbt')()) }}\n{%- endmacro -%}\n\n", + "meta": {}, + "name": "type_int", + "original_file_path": "macros/utils/data_types.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/utils/data_types.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.type_int" + }, + "macro.dbt.type_numeric": { + "arguments": [], + "created_at": 1719485736.547442, + "depends_on": { + "macros": [ + "macro.dbt.default__type_numeric" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "\n\n{%- macro type_numeric() -%}\n {{ return(adapter.dispatch('type_numeric', 'dbt')()) }}\n{%- endmacro -%}\n\n", + "meta": {}, + "name": "type_numeric", + "original_file_path": "macros/utils/data_types.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/utils/data_types.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.type_numeric" + }, + "macro.dbt.type_string": { + "arguments": [], + "created_at": 1719485736.545998, + "depends_on": { + "macros": [ + "macro.dbt.default__type_string" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "\n\n{%- macro type_string() -%}\n {{ return(adapter.dispatch('type_string', 'dbt')()) }}\n{%- endmacro -%}\n\n", + "meta": {}, + "name": "type_string", + "original_file_path": "macros/utils/data_types.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/utils/data_types.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.type_string" + }, + "macro.dbt.type_timestamp": { + "arguments": [], + "created_at": 1719485736.546634, + "depends_on": { + "macros": [ + "macro.dbt.default__type_timestamp" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "\n\n{%- macro type_timestamp() -%}\n {{ return(adapter.dispatch('type_timestamp', 'dbt')()) }}\n{%- endmacro -%}\n\n", + "meta": {}, + "name": "type_timestamp", + "original_file_path": "macros/utils/data_types.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/utils/data_types.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.type_timestamp" + }, + "macro.dbt.validate_sql": { + "arguments": [], + "created_at": 1719485736.5702772, + "depends_on": { + "macros": [ + "macro.dbt.default__validate_sql" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro validate_sql(sql) -%}\n {{ return(adapter.dispatch('validate_sql', 'dbt')(sql)) }}\n{% endmacro %}", + "meta": {}, + "name": "validate_sql", + "original_file_path": "macros/adapters/validate_sql.sql", + "package_name": "dbt", + "patch_path": null, + "path": "macros/adapters/validate_sql.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt.validate_sql" + }, + "macro.dbt_postgres.postgres__alter_column_comment": { + "arguments": [], + "created_at": 1719485736.314291, + "depends_on": { + "macros": [ + "macro.dbt_postgres.postgres_escape_comment" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro postgres__alter_column_comment(relation, column_dict) %}\n {% set existing_columns = adapter.get_columns_in_relation(relation) | map(attribute=\"name\") | list %}\n {% for column_name in column_dict if (column_name in existing_columns) %}\n {% set comment = column_dict[column_name]['description'] %}\n {% set escaped_comment = postgres_escape_comment(comment) %}\n comment on column {{ relation }}.{{ adapter.quote(column_name) if column_dict[column_name]['quote'] else column_name }} is {{ escaped_comment }};\n {% endfor %}\n{% endmacro %}", + "meta": {}, + "name": "postgres__alter_column_comment", + "original_file_path": "macros/adapters.sql", + "package_name": "dbt_postgres", + "patch_path": null, + "path": "macros/adapters.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt_postgres.postgres__alter_column_comment" + }, + "macro.dbt_postgres.postgres__alter_relation_comment": { + "arguments": [], + "created_at": 1719485736.312385, + "depends_on": { + "macros": [ + "macro.dbt_postgres.postgres_escape_comment" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro postgres__alter_relation_comment(relation, comment) %}\n {% set escaped_comment = postgres_escape_comment(comment) %}\n comment on {{ relation.type }} {{ relation }} is {{ escaped_comment }};\n{% endmacro %}", + "meta": {}, + "name": "postgres__alter_relation_comment", + "original_file_path": "macros/adapters.sql", + "package_name": "dbt_postgres", + "patch_path": null, + "path": "macros/adapters.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt_postgres.postgres__alter_relation_comment" + }, + "macro.dbt_postgres.postgres__any_value": { + "arguments": [], + "created_at": 1719485736.332639, + "depends_on": { + "macros": [] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro postgres__any_value(expression) -%}\n\n min({{ expression }})\n\n{%- endmacro %}", + "meta": {}, + "name": "postgres__any_value", + "original_file_path": "macros/utils/any_value.sql", + "package_name": "dbt_postgres", + "patch_path": null, + "path": "macros/utils/any_value.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt_postgres.postgres__any_value" + }, + "macro.dbt_postgres.postgres__check_schema_exists": { + "arguments": [], + "created_at": 1719485736.309761, + "depends_on": { + "macros": [ + "macro.dbt.statement" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro postgres__check_schema_exists(information_schema, schema) -%}\n {% if information_schema.database -%}\n {{ adapter.verify_database(information_schema.database) }}\n {%- endif -%}\n {% call statement('check_schema_exists', fetch_result=True, auto_begin=False) %}\n select count(*) from pg_namespace where nspname = '{{ schema }}'\n {% endcall %}\n {{ return(load_result('check_schema_exists').table) }}\n{% endmacro %}", + "meta": {}, + "name": "postgres__check_schema_exists", + "original_file_path": "macros/adapters.sql", + "package_name": "dbt_postgres", + "patch_path": null, + "path": "macros/adapters.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt_postgres.postgres__check_schema_exists" + }, + "macro.dbt_postgres.postgres__copy_grants": { + "arguments": [], + "created_at": 1719485736.3146439, + "depends_on": { + "macros": [] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro postgres__copy_grants() %}\n {{ return(False) }}\n{% endmacro %}", + "meta": {}, + "name": "postgres__copy_grants", + "original_file_path": "macros/adapters.sql", + "package_name": "dbt_postgres", + "patch_path": null, + "path": "macros/adapters.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt_postgres.postgres__copy_grants" + }, + "macro.dbt_postgres.postgres__create_schema": { + "arguments": [], + "created_at": 1719485736.306344, + "depends_on": { + "macros": [ + "macro.dbt.statement" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro postgres__create_schema(relation) -%}\n {% if relation.database -%}\n {{ adapter.verify_database(relation.database) }}\n {%- endif -%}\n {%- call statement('create_schema') -%}\n create schema if not exists {{ relation.without_identifier().include(database=False) }}\n {%- endcall -%}\n{% endmacro %}", + "meta": {}, + "name": "postgres__create_schema", + "original_file_path": "macros/adapters.sql", + "package_name": "dbt_postgres", + "patch_path": null, + "path": "macros/adapters.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt_postgres.postgres__create_schema" + }, + "macro.dbt_postgres.postgres__create_table_as": { + "arguments": [], + "created_at": 1719485736.3053398, + "depends_on": { + "macros": [ + "macro.dbt.get_assert_columns_equivalent", + "macro.dbt.get_table_columns_and_constraints", + "macro.dbt.default__get_column_names", + "macro.dbt.get_select_subquery" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro postgres__create_table_as(temporary, relation, sql) -%}\n {%- set unlogged = config.get('unlogged', default=false) -%}\n {%- set sql_header = config.get('sql_header', none) -%}\n\n {{ sql_header if sql_header is not none }}\n\n create {% if temporary -%}\n temporary\n {%- elif unlogged -%}\n unlogged\n {%- endif %} table {{ relation }}\n {% set contract_config = config.get('contract') %}\n {% if contract_config.enforced %}\n {{ get_assert_columns_equivalent(sql) }}\n {% endif -%}\n {% if contract_config.enforced and (not temporary) -%}\n {{ get_table_columns_and_constraints() }} ;\n insert into {{ relation }} (\n {{ adapter.dispatch('get_column_names', 'dbt')() }}\n )\n {%- set sql = get_select_subquery(sql) %}\n {% else %}\n as\n {% endif %}\n (\n {{ sql }}\n );\n{%- endmacro %}", + "meta": {}, + "name": "postgres__create_table_as", + "original_file_path": "macros/adapters.sql", + "package_name": "dbt_postgres", + "patch_path": null, + "path": "macros/adapters.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt_postgres.postgres__create_table_as" + }, + "macro.dbt_postgres.postgres__current_timestamp": { + "arguments": [], + "created_at": 1719485736.2888992, + "depends_on": { + "macros": [] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro postgres__current_timestamp() -%}\n now()\n{%- endmacro %}", + "meta": {}, + "name": "postgres__current_timestamp", + "original_file_path": "macros/timestamps.sql", + "package_name": "dbt_postgres", + "patch_path": null, + "path": "macros/timestamps.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt_postgres.postgres__current_timestamp" + }, + "macro.dbt_postgres.postgres__current_timestamp_backcompat": { + "arguments": [], + "created_at": 1719485736.289357, + "depends_on": { + "macros": [ + "macro.dbt.type_timestamp" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro postgres__current_timestamp_backcompat() %}\n current_timestamp::{{ type_timestamp() }}\n{% endmacro %}", + "meta": {}, + "name": "postgres__current_timestamp_backcompat", + "original_file_path": "macros/timestamps.sql", + "package_name": "dbt_postgres", + "patch_path": null, + "path": "macros/timestamps.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt_postgres.postgres__current_timestamp_backcompat" + }, + "macro.dbt_postgres.postgres__current_timestamp_in_utc_backcompat": { + "arguments": [], + "created_at": 1719485736.289477, + "depends_on": { + "macros": [ + "macro.dbt.type_timestamp" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro postgres__current_timestamp_in_utc_backcompat() %}\n (current_timestamp at time zone 'utc')::{{ type_timestamp() }}\n{% endmacro %}", + "meta": {}, + "name": "postgres__current_timestamp_in_utc_backcompat", + "original_file_path": "macros/timestamps.sql", + "package_name": "dbt_postgres", + "patch_path": null, + "path": "macros/timestamps.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt_postgres.postgres__current_timestamp_in_utc_backcompat" + }, + "macro.dbt_postgres.postgres__dateadd": { + "arguments": [], + "created_at": 1719485736.328303, + "depends_on": { + "macros": [] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro postgres__dateadd(datepart, interval, from_date_or_timestamp) %}\n\n {{ from_date_or_timestamp }} + ((interval '1 {{ datepart }}') * ({{ interval }}))\n\n{% endmacro %}", + "meta": {}, + "name": "postgres__dateadd", + "original_file_path": "macros/utils/dateadd.sql", + "package_name": "dbt_postgres", + "patch_path": null, + "path": "macros/utils/dateadd.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt_postgres.postgres__dateadd" + }, + "macro.dbt_postgres.postgres__datediff": { + "arguments": [], + "created_at": 1719485736.332466, + "depends_on": { + "macros": [ + "macro.dbt.datediff" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro postgres__datediff(first_date, second_date, datepart) -%}\n\n {% if datepart == 'year' %}\n (date_part('year', ({{second_date}})::date) - date_part('year', ({{first_date}})::date))\n {% elif datepart == 'quarter' %}\n ({{ datediff(first_date, second_date, 'year') }} * 4 + date_part('quarter', ({{second_date}})::date) - date_part('quarter', ({{first_date}})::date))\n {% elif datepart == 'month' %}\n ({{ datediff(first_date, second_date, 'year') }} * 12 + date_part('month', ({{second_date}})::date) - date_part('month', ({{first_date}})::date))\n {% elif datepart == 'day' %}\n (({{second_date}})::date - ({{first_date}})::date)\n {% elif datepart == 'week' %}\n ({{ datediff(first_date, second_date, 'day') }} / 7 + case\n when date_part('dow', ({{first_date}})::timestamp) <= date_part('dow', ({{second_date}})::timestamp) then\n case when {{first_date}} <= {{second_date}} then 0 else -1 end\n else\n case when {{first_date}} <= {{second_date}} then 1 else 0 end\n end)\n {% elif datepart == 'hour' %}\n ({{ datediff(first_date, second_date, 'day') }} * 24 + date_part('hour', ({{second_date}})::timestamp) - date_part('hour', ({{first_date}})::timestamp))\n {% elif datepart == 'minute' %}\n ({{ datediff(first_date, second_date, 'hour') }} * 60 + date_part('minute', ({{second_date}})::timestamp) - date_part('minute', ({{first_date}})::timestamp))\n {% elif datepart == 'second' %}\n ({{ datediff(first_date, second_date, 'minute') }} * 60 + floor(date_part('second', ({{second_date}})::timestamp)) - floor(date_part('second', ({{first_date}})::timestamp)))\n {% elif datepart == 'millisecond' %}\n ({{ datediff(first_date, second_date, 'minute') }} * 60000 + floor(date_part('millisecond', ({{second_date}})::timestamp)) - floor(date_part('millisecond', ({{first_date}})::timestamp)))\n {% elif datepart == 'microsecond' %}\n ({{ datediff(first_date, second_date, 'minute') }} * 60000000 + floor(date_part('microsecond', ({{second_date}})::timestamp)) - floor(date_part('microsecond', ({{first_date}})::timestamp)))\n {% else %}\n {{ exceptions.raise_compiler_error(\"Unsupported datepart for macro datediff in postgres: {!r}\".format(datepart)) }}\n {% endif %}\n\n{%- endmacro %}", + "meta": {}, + "name": "postgres__datediff", + "original_file_path": "macros/utils/datediff.sql", + "package_name": "dbt_postgres", + "patch_path": null, + "path": "macros/utils/datediff.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt_postgres.postgres__datediff" + }, + "macro.dbt_postgres.postgres__describe_materialized_view": { + "arguments": [], + "created_at": 1719485736.320213, + "depends_on": { + "macros": [ + "macro.dbt.run_query", + "macro.dbt.get_show_indexes_sql" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro postgres__describe_materialized_view(relation) %}\n -- for now just get the indexes, we don't need the name or the query yet\n {% set _indexes = run_query(get_show_indexes_sql(relation)) %}\n {% do return({'indexes': _indexes}) %}\n{% endmacro %}", + "meta": {}, + "name": "postgres__describe_materialized_view", + "original_file_path": "macros/relations/materialized_view/describe.sql", + "package_name": "dbt_postgres", + "patch_path": null, + "path": "macros/relations/materialized_view/describe.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt_postgres.postgres__describe_materialized_view" + }, + "macro.dbt_postgres.postgres__drop_materialized_view": { + "arguments": [], + "created_at": 1719485736.319324, + "depends_on": { + "macros": [] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro postgres__drop_materialized_view(relation) -%}\n drop materialized view if exists {{ relation }} cascade\n{%- endmacro %}", + "meta": {}, + "name": "postgres__drop_materialized_view", + "original_file_path": "macros/relations/materialized_view/drop.sql", + "package_name": "dbt_postgres", + "patch_path": null, + "path": "macros/relations/materialized_view/drop.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt_postgres.postgres__drop_materialized_view" }, - "macro.dbt.type_numeric": { + "macro.dbt_postgres.postgres__drop_schema": { "arguments": [], - "created_at": 1696458269.789772, + "created_at": 1719485736.307206, "depends_on": { "macros": [ - "macro.dbt.default__type_numeric" + "macro.dbt.statement" ] }, "description": "", @@ -6614,77 +9075,68 @@ "node_color": null, "show": true }, - "macro_sql": "\n\n{%- macro type_numeric() -%}\n {{ return(adapter.dispatch('type_numeric', 'dbt')()) }}\n{%- endmacro -%}\n\n", + "macro_sql": "{% macro postgres__drop_schema(relation) -%}\n {% if relation.database -%}\n {{ adapter.verify_database(relation.database) }}\n {%- endif -%}\n {%- call statement('drop_schema') -%}\n drop schema if exists {{ relation.without_identifier().include(database=False) }} cascade\n {%- endcall -%}\n{% endmacro %}", "meta": {}, - "name": "type_numeric", - "original_file_path": "macros/utils/data_types.sql", - "package_name": "dbt", + "name": "postgres__drop_schema", + "original_file_path": "macros/adapters.sql", + "package_name": "dbt_postgres", "patch_path": null, - "path": "macros/utils/data_types.sql", + "path": "macros/adapters.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.type_numeric" + "unique_id": "macro.dbt_postgres.postgres__drop_schema" }, - "macro.dbt.type_string": { + "macro.dbt_postgres.postgres__drop_table": { "arguments": [], - "created_at": 1696458269.788082, + "created_at": 1719485736.3256729, "depends_on": { - "macros": [ - "macro.dbt.default__type_string" - ] + "macros": [] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "\n\n{%- macro type_string() -%}\n {{ return(adapter.dispatch('type_string', 'dbt')()) }}\n{%- endmacro -%}\n\n", + "macro_sql": "{% macro postgres__drop_table(relation) -%}\n drop table if exists {{ relation }} cascade\n{%- endmacro %}", "meta": {}, - "name": "type_string", - "original_file_path": "macros/utils/data_types.sql", - "package_name": "dbt", + "name": "postgres__drop_table", + "original_file_path": "macros/relations/table/drop.sql", + "package_name": "dbt_postgres", "patch_path": null, - "path": "macros/utils/data_types.sql", + "path": "macros/relations/table/drop.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.type_string" + "unique_id": "macro.dbt_postgres.postgres__drop_table" }, - "macro.dbt.type_timestamp": { + "macro.dbt_postgres.postgres__drop_view": { "arguments": [], - "created_at": 1696458269.788734, + "created_at": 1719485736.327039, "depends_on": { - "macros": [ - "macro.dbt.default__type_timestamp" - ] + "macros": [] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "\n\n{%- macro type_timestamp() -%}\n {{ return(adapter.dispatch('type_timestamp', 'dbt')()) }}\n{%- endmacro -%}\n\n", + "macro_sql": "{% macro postgres__drop_view(relation) -%}\n drop view if exists {{ relation }} cascade\n{%- endmacro %}", "meta": {}, - "name": "type_timestamp", - "original_file_path": "macros/utils/data_types.sql", - "package_name": "dbt", + "name": "postgres__drop_view", + "original_file_path": "macros/relations/view/drop.sql", + "package_name": "dbt_postgres", "patch_path": null, - "path": "macros/utils/data_types.sql", + "path": "macros/relations/view/drop.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/global_project", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt.type_timestamp" + "unique_id": "macro.dbt_postgres.postgres__drop_view" }, - "macro.dbt_postgres.postgres__alter_column_comment": { + "macro.dbt_postgres.postgres__get_alter_materialized_view_as_sql": { "arguments": [], - "created_at": 1696458269.575566, + "created_at": 1719485736.3214989, "depends_on": { "macros": [ - "macro.dbt_postgres.postgres_escape_comment" + "macro.dbt.get_replace_sql", + "macro.dbt_postgres.postgres__update_indexes_on_materialized_view" ] }, "description": "", @@ -6692,25 +9144,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro postgres__alter_column_comment(relation, column_dict) %}\n {% set existing_columns = adapter.get_columns_in_relation(relation) | map(attribute=\"name\") | list %}\n {% for column_name in column_dict if (column_name in existing_columns) %}\n {% set comment = column_dict[column_name]['description'] %}\n {% set escaped_comment = postgres_escape_comment(comment) %}\n comment on column {{ relation }}.{{ adapter.quote(column_name) if column_dict[column_name]['quote'] else column_name }} is {{ escaped_comment }};\n {% endfor %}\n{% endmacro %}", + "macro_sql": "{% macro postgres__get_alter_materialized_view_as_sql(\n relation,\n configuration_changes,\n sql,\n existing_relation,\n backup_relation,\n intermediate_relation\n) %}\n\n -- apply a full refresh immediately if needed\n {% if configuration_changes.requires_full_refresh %}\n\n {{ get_replace_sql(existing_relation, relation, sql) }}\n\n -- otherwise apply individual changes as needed\n {% else %}\n\n {{ postgres__update_indexes_on_materialized_view(relation, configuration_changes.indexes) }}\n\n {%- endif -%}\n\n{% endmacro %}", "meta": {}, - "name": "postgres__alter_column_comment", - "original_file_path": "macros/adapters.sql", + "name": "postgres__get_alter_materialized_view_as_sql", + "original_file_path": "macros/relations/materialized_view/alter.sql", "package_name": "dbt_postgres", "patch_path": null, - "path": "macros/adapters.sql", + "path": "macros/relations/materialized_view/alter.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/postgres", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt_postgres.postgres__alter_column_comment" + "unique_id": "macro.dbt_postgres.postgres__get_alter_materialized_view_as_sql" }, - "macro.dbt_postgres.postgres__alter_relation_comment": { + "macro.dbt_postgres.postgres__get_catalog": { "arguments": [], - "created_at": 1696458269.574534, + "created_at": 1719485736.292309, "depends_on": { "macros": [ - "macro.dbt_postgres.postgres_escape_comment" + "macro.dbt_postgres.postgres__get_catalog_relations" ] }, "description": "", @@ -6718,49 +9168,48 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro postgres__alter_relation_comment(relation, comment) %}\n {% set escaped_comment = postgres_escape_comment(comment) %}\n comment on {{ relation.type }} {{ relation }} is {{ escaped_comment }};\n{% endmacro %}", + "macro_sql": "{% macro postgres__get_catalog(information_schema, schemas) -%}\n {%- set relations = [] -%}\n {%- for schema in schemas -%}\n {%- set dummy = relations.append({'schema': schema}) -%}\n {%- endfor -%}\n {{ return(postgres__get_catalog_relations(information_schema, relations)) }}\n{%- endmacro %}", "meta": {}, - "name": "postgres__alter_relation_comment", - "original_file_path": "macros/adapters.sql", + "name": "postgres__get_catalog", + "original_file_path": "macros/catalog.sql", "package_name": "dbt_postgres", "patch_path": null, - "path": "macros/adapters.sql", + "path": "macros/catalog.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/postgres", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt_postgres.postgres__alter_relation_comment" + "unique_id": "macro.dbt_postgres.postgres__get_catalog" }, - "macro.dbt_postgres.postgres__any_value": { + "macro.dbt_postgres.postgres__get_catalog_relations": { "arguments": [], - "created_at": 1696458269.586868, + "created_at": 1719485736.2918808, "depends_on": { - "macros": [] + "macros": [ + "macro.dbt.statement" + ] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro postgres__any_value(expression) -%}\n\n min({{ expression }})\n\n{%- endmacro %}", + "macro_sql": "{% macro postgres__get_catalog_relations(information_schema, relations) -%}\n {%- call statement('catalog', fetch_result=True) -%}\n\n {#\n If the user has multiple databases set and the first one is wrong, this will fail.\n But we won't fail in the case where there are multiple quoting-difference-only dbs, which is better.\n #}\n {% set database = information_schema.database %}\n {{ adapter.verify_database(database) }}\n\n select\n '{{ database }}' as table_database,\n sch.nspname as table_schema,\n tbl.relname as table_name,\n case tbl.relkind\n when 'v' then 'VIEW'\n when 'm' then 'MATERIALIZED VIEW'\n else 'BASE TABLE'\n end as table_type,\n tbl_desc.description as table_comment,\n col.attname as column_name,\n col.attnum as column_index,\n pg_catalog.format_type(col.atttypid, col.atttypmod) as column_type,\n col_desc.description as column_comment,\n pg_get_userbyid(tbl.relowner) as table_owner\n\n from pg_catalog.pg_namespace sch\n join pg_catalog.pg_class tbl on tbl.relnamespace = sch.oid\n join pg_catalog.pg_attribute col on col.attrelid = tbl.oid\n left outer join pg_catalog.pg_description tbl_desc on (tbl_desc.objoid = tbl.oid and tbl_desc.objsubid = 0)\n left outer join pg_catalog.pg_description col_desc on (col_desc.objoid = tbl.oid and col_desc.objsubid = col.attnum)\n where (\n {%- for relation in relations -%}\n {%- if relation.identifier -%}\n (upper(sch.nspname) = upper('{{ relation.schema }}') and\n upper(tbl.relname) = upper('{{ relation.identifier }}'))\n {%- else-%}\n upper(sch.nspname) = upper('{{ relation.schema }}')\n {%- endif -%}\n {%- if not loop.last %} or {% endif -%}\n {%- endfor -%}\n )\n and not pg_is_other_temp_schema(sch.oid) -- not a temporary schema belonging to another session\n and tbl.relpersistence in ('p', 'u') -- [p]ermanent table or [u]nlogged table. Exclude [t]emporary tables\n and tbl.relkind in ('r', 'v', 'f', 'p', 'm') -- o[r]dinary table, [v]iew, [f]oreign table, [p]artitioned table, [m]aterialized view. Other values are [i]ndex, [S]equence, [c]omposite type, [t]OAST table\n and col.attnum > 0 -- negative numbers are used for system columns such as oid\n and not col.attisdropped -- column as not been dropped\n\n order by\n sch.nspname,\n tbl.relname,\n col.attnum\n\n {%- endcall -%}\n\n {{ return(load_result('catalog').table) }}\n{%- endmacro %}", "meta": {}, - "name": "postgres__any_value", - "original_file_path": "macros/utils/any_value.sql", + "name": "postgres__get_catalog_relations", + "original_file_path": "macros/catalog.sql", "package_name": "dbt_postgres", "patch_path": null, - "path": "macros/utils/any_value.sql", + "path": "macros/catalog.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/postgres", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt_postgres.postgres__any_value" + "unique_id": "macro.dbt_postgres.postgres__get_catalog_relations" }, - "macro.dbt_postgres.postgres__check_schema_exists": { + "macro.dbt_postgres.postgres__get_columns_in_relation": { "arguments": [], - "created_at": 1696458269.570358, + "created_at": 1719485736.308002, "depends_on": { "macros": [ - "macro.dbt.statement" + "macro.dbt.statement", + "macro.dbt.sql_convert_columns_in_relation" ] }, "description": "", @@ -6768,22 +9217,20 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro postgres__check_schema_exists(information_schema, schema) -%}\n {% if information_schema.database -%}\n {{ adapter.verify_database(information_schema.database) }}\n {%- endif -%}\n {% call statement('check_schema_exists', fetch_result=True, auto_begin=False) %}\n select count(*) from pg_namespace where nspname = '{{ schema }}'\n {% endcall %}\n {{ return(load_result('check_schema_exists').table) }}\n{% endmacro %}", + "macro_sql": "{% macro postgres__get_columns_in_relation(relation) -%}\n {% call statement('get_columns_in_relation', fetch_result=True) %}\n select\n column_name,\n data_type,\n character_maximum_length,\n numeric_precision,\n numeric_scale\n\n from {{ relation.information_schema('columns') }}\n where table_name = '{{ relation.identifier }}'\n {% if relation.schema %}\n and table_schema = '{{ relation.schema }}'\n {% endif %}\n order by ordinal_position\n\n {% endcall %}\n {% set table = load_result('get_columns_in_relation').table %}\n {{ return(sql_convert_columns_in_relation(table)) }}\n{% endmacro %}", "meta": {}, - "name": "postgres__check_schema_exists", + "name": "postgres__get_columns_in_relation", "original_file_path": "macros/adapters.sql", "package_name": "dbt_postgres", "patch_path": null, "path": "macros/adapters.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/postgres", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt_postgres.postgres__check_schema_exists" + "unique_id": "macro.dbt_postgres.postgres__get_columns_in_relation" }, - "macro.dbt_postgres.postgres__copy_grants": { + "macro.dbt_postgres.postgres__get_create_index_sql": { "arguments": [], - "created_at": 1696458269.57614, + "created_at": 1719485736.30595, "depends_on": { "macros": [] }, @@ -6792,25 +9239,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro postgres__copy_grants() %}\n {{ return(False) }}\n{% endmacro %}", + "macro_sql": "{% macro postgres__get_create_index_sql(relation, index_dict) -%}\n {%- set index_config = adapter.parse_index(index_dict) -%}\n {%- set comma_separated_columns = \", \".join(index_config.columns) -%}\n {%- set index_name = index_config.render(relation) -%}\n\n create {% if index_config.unique -%}\n unique\n {%- endif %} index if not exists\n \"{{ index_name }}\"\n on {{ relation }} {% if index_config.type -%}\n using {{ index_config.type }}\n {%- endif %}\n ({{ comma_separated_columns }})\n{%- endmacro %}", "meta": {}, - "name": "postgres__copy_grants", + "name": "postgres__get_create_index_sql", "original_file_path": "macros/adapters.sql", "package_name": "dbt_postgres", "patch_path": null, "path": "macros/adapters.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/postgres", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt_postgres.postgres__copy_grants" + "unique_id": "macro.dbt_postgres.postgres__get_create_index_sql" }, - "macro.dbt_postgres.postgres__create_schema": { + "macro.dbt_postgres.postgres__get_create_materialized_view_as_sql": { "arguments": [], - "created_at": 1696458269.566682, + "created_at": 1719485736.3255181, "depends_on": { "macros": [ - "macro.dbt.statement" + "macro.dbt.get_create_index_sql" ] }, "description": "", @@ -6818,22 +9263,20 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro postgres__create_schema(relation) -%}\n {% if relation.database -%}\n {{ adapter.verify_database(relation.database) }}\n {%- endif -%}\n {%- call statement('create_schema') -%}\n create schema if not exists {{ relation.without_identifier().include(database=False) }}\n {%- endcall -%}\n{% endmacro %}", + "macro_sql": "{% macro postgres__get_create_materialized_view_as_sql(relation, sql) %}\n create materialized view if not exists {{ relation }} as {{ sql }};\n\n {% for _index_dict in config.get('indexes', []) -%}\n {{- get_create_index_sql(relation, _index_dict) -}}\n {%- endfor -%}\n\n{% endmacro %}", "meta": {}, - "name": "postgres__create_schema", - "original_file_path": "macros/adapters.sql", + "name": "postgres__get_create_materialized_view_as_sql", + "original_file_path": "macros/relations/materialized_view/create.sql", "package_name": "dbt_postgres", "patch_path": null, - "path": "macros/adapters.sql", + "path": "macros/relations/materialized_view/create.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/postgres", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt_postgres.postgres__create_schema" + "unique_id": "macro.dbt_postgres.postgres__get_create_materialized_view_as_sql" }, - "macro.dbt_postgres.postgres__create_table_as": { + "macro.dbt_postgres.postgres__get_drop_index_sql": { "arguments": [], - "created_at": 1696458269.5653162, + "created_at": 1719485736.3150148, "depends_on": { "macros": [] }, @@ -6842,49 +9285,48 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro postgres__create_table_as(temporary, relation, sql) -%}\n {%- set unlogged = config.get('unlogged', default=false) -%}\n {%- set sql_header = config.get('sql_header', none) -%}\n\n {{ sql_header if sql_header is not none }}\n\n create {% if temporary -%}\n temporary\n {%- elif unlogged -%}\n unlogged\n {%- endif %} table {{ relation }}\n as (\n {{ sql }}\n );\n{%- endmacro %}", + "macro_sql": "\n\n\n{%- macro postgres__get_drop_index_sql(relation, index_name) -%}\n drop index if exists \"{{ relation.schema }}\".\"{{ index_name }}\"\n{%- endmacro -%}", "meta": {}, - "name": "postgres__create_table_as", + "name": "postgres__get_drop_index_sql", "original_file_path": "macros/adapters.sql", "package_name": "dbt_postgres", "patch_path": null, "path": "macros/adapters.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/postgres", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt_postgres.postgres__create_table_as" + "unique_id": "macro.dbt_postgres.postgres__get_drop_index_sql" }, - "macro.dbt_postgres.postgres__current_timestamp": { + "macro.dbt_postgres.postgres__get_incremental_default_sql": { "arguments": [], - "created_at": 1696458269.5499048, + "created_at": 1719485736.316301, "depends_on": { - "macros": [] + "macros": [ + "macro.dbt.get_incremental_delete_insert_sql", + "macro.dbt.get_incremental_append_sql" + ] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro postgres__current_timestamp() -%}\n now()\n{%- endmacro %}", + "macro_sql": "{% macro postgres__get_incremental_default_sql(arg_dict) %}\n\n {% if arg_dict[\"unique_key\"] %}\n {% do return(get_incremental_delete_insert_sql(arg_dict)) %}\n {% else %}\n {% do return(get_incremental_append_sql(arg_dict)) %}\n {% endif %}\n\n{% endmacro %}", "meta": {}, - "name": "postgres__current_timestamp", - "original_file_path": "macros/timestamps.sql", + "name": "postgres__get_incremental_default_sql", + "original_file_path": "macros/materializations/incremental_strategies.sql", "package_name": "dbt_postgres", "patch_path": null, - "path": "macros/timestamps.sql", + "path": "macros/materializations/incremental_strategies.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/postgres", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt_postgres.postgres__current_timestamp" + "unique_id": "macro.dbt_postgres.postgres__get_incremental_default_sql" }, - "macro.dbt_postgres.postgres__current_timestamp_backcompat": { + "macro.dbt_postgres.postgres__get_materialized_view_configuration_changes": { "arguments": [], - "created_at": 1696458269.551879, + "created_at": 1719485736.324945, "depends_on": { "macros": [ - "macro.dbt.type_timestamp" + "macro.dbt_postgres.postgres__describe_materialized_view" ] }, "description": "", @@ -6892,25 +9334,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro postgres__current_timestamp_backcompat() %}\n current_timestamp::{{ type_timestamp() }}\n{% endmacro %}", + "macro_sql": "{% macro postgres__get_materialized_view_configuration_changes(existing_relation, new_config) %}\n {% set _existing_materialized_view = postgres__describe_materialized_view(existing_relation) %}\n {% set _configuration_changes = existing_relation.get_materialized_view_config_change_collection(_existing_materialized_view, new_config.model) %}\n {% do return(_configuration_changes) %}\n{% endmacro %}", "meta": {}, - "name": "postgres__current_timestamp_backcompat", - "original_file_path": "macros/timestamps.sql", + "name": "postgres__get_materialized_view_configuration_changes", + "original_file_path": "macros/relations/materialized_view/alter.sql", "package_name": "dbt_postgres", "patch_path": null, - "path": "macros/timestamps.sql", + "path": "macros/relations/materialized_view/alter.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/postgres", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt_postgres.postgres__current_timestamp_backcompat" + "unique_id": "macro.dbt_postgres.postgres__get_materialized_view_configuration_changes" }, - "macro.dbt_postgres.postgres__current_timestamp_in_utc_backcompat": { + "macro.dbt_postgres.postgres__get_relations": { "arguments": [], - "created_at": 1696458269.5520551, + "created_at": 1719485736.293338, "depends_on": { "macros": [ - "macro.dbt.type_timestamp" + "macro.dbt.statement" ] }, "description": "", @@ -6918,22 +9358,20 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro postgres__current_timestamp_in_utc_backcompat() %}\n (current_timestamp at time zone 'utc')::{{ type_timestamp() }}\n{% endmacro %}", + "macro_sql": "{% macro postgres__get_relations() -%}\n\n {#\n -- in pg_depend, objid is the dependent, refobjid is the referenced object\n -- > a pg_depend entry indicates that the referenced object cannot be\n -- > dropped without also dropping the dependent object.\n #}\n\n {%- call statement('relations', fetch_result=True) -%}\n with relation as (\n select\n pg_rewrite.ev_class as class,\n pg_rewrite.oid as id\n from pg_rewrite\n ),\n class as (\n select\n oid as id,\n relname as name,\n relnamespace as schema,\n relkind as kind\n from pg_class\n ),\n dependency as (\n select distinct\n pg_depend.objid as id,\n pg_depend.refobjid as ref\n from pg_depend\n ),\n schema as (\n select\n pg_namespace.oid as id,\n pg_namespace.nspname as name\n from pg_namespace\n where nspname != 'information_schema' and nspname not like 'pg\\_%'\n ),\n referenced as (\n select\n relation.id AS id,\n referenced_class.name ,\n referenced_class.schema ,\n referenced_class.kind\n from relation\n join class as referenced_class on relation.class=referenced_class.id\n where referenced_class.kind in ('r', 'v', 'm')\n ),\n relationships as (\n select\n referenced.name as referenced_name,\n referenced.schema as referenced_schema_id,\n dependent_class.name as dependent_name,\n dependent_class.schema as dependent_schema_id,\n referenced.kind as kind\n from referenced\n join dependency on referenced.id=dependency.id\n join class as dependent_class on dependency.ref=dependent_class.id\n where\n (referenced.name != dependent_class.name or\n referenced.schema != dependent_class.schema)\n )\n\n select\n referenced_schema.name as referenced_schema,\n relationships.referenced_name as referenced_name,\n dependent_schema.name as dependent_schema,\n relationships.dependent_name as dependent_name\n from relationships\n join schema as dependent_schema on relationships.dependent_schema_id=dependent_schema.id\n join schema as referenced_schema on relationships.referenced_schema_id=referenced_schema.id\n group by referenced_schema, referenced_name, dependent_schema, dependent_name\n order by referenced_schema, referenced_name, dependent_schema, dependent_name;\n\n {%- endcall -%}\n\n {{ return(load_result('relations').table) }}\n{% endmacro %}", "meta": {}, - "name": "postgres__current_timestamp_in_utc_backcompat", - "original_file_path": "macros/timestamps.sql", + "name": "postgres__get_relations", + "original_file_path": "macros/relations.sql", "package_name": "dbt_postgres", "patch_path": null, - "path": "macros/timestamps.sql", + "path": "macros/relations.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/postgres", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt_postgres.postgres__current_timestamp_in_utc_backcompat" + "unique_id": "macro.dbt_postgres.postgres__get_relations" }, - "macro.dbt_postgres.postgres__dateadd": { + "macro.dbt_postgres.postgres__get_rename_materialized_view_sql": { "arguments": [], - "created_at": 1696458269.579446, + "created_at": 1719485736.3205602, "depends_on": { "macros": [] }, @@ -6942,77 +9380,69 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro postgres__dateadd(datepart, interval, from_date_or_timestamp) %}\n\n {{ from_date_or_timestamp }} + ((interval '1 {{ datepart }}') * ({{ interval }}))\n\n{% endmacro %}", + "macro_sql": "{% macro postgres__get_rename_materialized_view_sql(relation, new_name) %}\n alter materialized view {{ relation }} rename to {{ new_name }}\n{% endmacro %}", "meta": {}, - "name": "postgres__dateadd", - "original_file_path": "macros/utils/dateadd.sql", + "name": "postgres__get_rename_materialized_view_sql", + "original_file_path": "macros/relations/materialized_view/rename.sql", "package_name": "dbt_postgres", "patch_path": null, - "path": "macros/utils/dateadd.sql", + "path": "macros/relations/materialized_view/rename.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/postgres", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt_postgres.postgres__dateadd" + "unique_id": "macro.dbt_postgres.postgres__get_rename_materialized_view_sql" }, - "macro.dbt_postgres.postgres__datediff": { + "macro.dbt_postgres.postgres__get_rename_table_sql": { "arguments": [], - "created_at": 1696458269.586445, + "created_at": 1719485736.326884, "depends_on": { - "macros": [ - "macro.dbt.datediff" - ] + "macros": [] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro postgres__datediff(first_date, second_date, datepart) -%}\n\n {% if datepart == 'year' %}\n (date_part('year', ({{second_date}})::date) - date_part('year', ({{first_date}})::date))\n {% elif datepart == 'quarter' %}\n ({{ datediff(first_date, second_date, 'year') }} * 4 + date_part('quarter', ({{second_date}})::date) - date_part('quarter', ({{first_date}})::date))\n {% elif datepart == 'month' %}\n ({{ datediff(first_date, second_date, 'year') }} * 12 + date_part('month', ({{second_date}})::date) - date_part('month', ({{first_date}})::date))\n {% elif datepart == 'day' %}\n (({{second_date}})::date - ({{first_date}})::date)\n {% elif datepart == 'week' %}\n ({{ datediff(first_date, second_date, 'day') }} / 7 + case\n when date_part('dow', ({{first_date}})::timestamp) <= date_part('dow', ({{second_date}})::timestamp) then\n case when {{first_date}} <= {{second_date}} then 0 else -1 end\n else\n case when {{first_date}} <= {{second_date}} then 1 else 0 end\n end)\n {% elif datepart == 'hour' %}\n ({{ datediff(first_date, second_date, 'day') }} * 24 + date_part('hour', ({{second_date}})::timestamp) - date_part('hour', ({{first_date}})::timestamp))\n {% elif datepart == 'minute' %}\n ({{ datediff(first_date, second_date, 'hour') }} * 60 + date_part('minute', ({{second_date}})::timestamp) - date_part('minute', ({{first_date}})::timestamp))\n {% elif datepart == 'second' %}\n ({{ datediff(first_date, second_date, 'minute') }} * 60 + floor(date_part('second', ({{second_date}})::timestamp)) - floor(date_part('second', ({{first_date}})::timestamp)))\n {% elif datepart == 'millisecond' %}\n ({{ datediff(first_date, second_date, 'minute') }} * 60000 + floor(date_part('millisecond', ({{second_date}})::timestamp)) - floor(date_part('millisecond', ({{first_date}})::timestamp)))\n {% elif datepart == 'microsecond' %}\n ({{ datediff(first_date, second_date, 'minute') }} * 60000000 + floor(date_part('microsecond', ({{second_date}})::timestamp)) - floor(date_part('microsecond', ({{first_date}})::timestamp)))\n {% else %}\n {{ exceptions.raise_compiler_error(\"Unsupported datepart for macro datediff in postgres: {!r}\".format(datepart)) }}\n {% endif %}\n\n{%- endmacro %}", + "macro_sql": "{% macro postgres__get_rename_table_sql(relation, new_name) %}\n alter table {{ relation }} rename to {{ new_name }}\n{% endmacro %}", "meta": {}, - "name": "postgres__datediff", - "original_file_path": "macros/utils/datediff.sql", + "name": "postgres__get_rename_table_sql", + "original_file_path": "macros/relations/table/rename.sql", "package_name": "dbt_postgres", "patch_path": null, - "path": "macros/utils/datediff.sql", + "path": "macros/relations/table/rename.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/postgres", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt_postgres.postgres__datediff" + "unique_id": "macro.dbt_postgres.postgres__get_rename_table_sql" }, - "macro.dbt_postgres.postgres__drop_schema": { + "macro.dbt_postgres.postgres__get_rename_view_sql": { "arguments": [], - "created_at": 1696458269.567198, + "created_at": 1719485736.32807, "depends_on": { - "macros": [ - "macro.dbt.statement" - ] + "macros": [] }, "description": "", "docs": { "node_color": null, "show": true }, - "macro_sql": "{% macro postgres__drop_schema(relation) -%}\n {% if relation.database -%}\n {{ adapter.verify_database(relation.database) }}\n {%- endif -%}\n {%- call statement('drop_schema') -%}\n drop schema if exists {{ relation.without_identifier().include(database=False) }} cascade\n {%- endcall -%}\n{% endmacro %}", + "macro_sql": "{% macro postgres__get_rename_view_sql(relation, new_name) %}\n alter view {{ relation }} rename to {{ new_name }}\n{% endmacro %}", "meta": {}, - "name": "postgres__drop_schema", - "original_file_path": "macros/adapters.sql", + "name": "postgres__get_rename_view_sql", + "original_file_path": "macros/relations/view/rename.sql", "package_name": "dbt_postgres", "patch_path": null, - "path": "macros/adapters.sql", + "path": "macros/relations/view/rename.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/postgres", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt_postgres.postgres__drop_schema" + "unique_id": "macro.dbt_postgres.postgres__get_rename_view_sql" }, - "macro.dbt_postgres.postgres__get_catalog": { + "macro.dbt_postgres.postgres__get_replace_table_sql": { "arguments": [], - "created_at": 1696458269.5540092, + "created_at": 1719485736.326431, "depends_on": { "macros": [ - "macro.dbt.statement" + "macro.dbt.get_assert_columns_equivalent", + "macro.dbt.get_table_columns_and_constraints", + "macro.dbt.get_select_subquery" ] }, "description": "", @@ -7020,26 +9450,23 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro postgres__get_catalog(information_schema, schemas) -%}\n\n {%- call statement('catalog', fetch_result=True) -%}\n {#\n If the user has multiple databases set and the first one is wrong, this will fail.\n But we won't fail in the case where there are multiple quoting-difference-only dbs, which is better.\n #}\n {% set database = information_schema.database %}\n {{ adapter.verify_database(database) }}\n\n select\n '{{ database }}' as table_database,\n sch.nspname as table_schema,\n tbl.relname as table_name,\n case tbl.relkind\n when 'v' then 'VIEW'\n else 'BASE TABLE'\n end as table_type,\n tbl_desc.description as table_comment,\n col.attname as column_name,\n col.attnum as column_index,\n pg_catalog.format_type(col.atttypid, col.atttypmod) as column_type,\n col_desc.description as column_comment,\n pg_get_userbyid(tbl.relowner) as table_owner\n\n from pg_catalog.pg_namespace sch\n join pg_catalog.pg_class tbl on tbl.relnamespace = sch.oid\n join pg_catalog.pg_attribute col on col.attrelid = tbl.oid\n left outer join pg_catalog.pg_description tbl_desc on (tbl_desc.objoid = tbl.oid and tbl_desc.objsubid = 0)\n left outer join pg_catalog.pg_description col_desc on (col_desc.objoid = tbl.oid and col_desc.objsubid = col.attnum)\n\n where (\n {%- for schema in schemas -%}\n upper(sch.nspname) = upper('{{ schema }}'){%- if not loop.last %} or {% endif -%}\n {%- endfor -%}\n )\n and not pg_is_other_temp_schema(sch.oid) -- not a temporary schema belonging to another session\n and tbl.relpersistence in ('p', 'u') -- [p]ermanent table or [u]nlogged table. Exclude [t]emporary tables\n and tbl.relkind in ('r', 'v', 'f', 'p') -- o[r]dinary table, [v]iew, [f]oreign table, [p]artitioned table. Other values are [i]ndex, [S]equence, [c]omposite type, [t]OAST table, [m]aterialized view\n and col.attnum > 0 -- negative numbers are used for system columns such as oid\n and not col.attisdropped -- column as not been dropped\n\n order by\n sch.nspname,\n tbl.relname,\n col.attnum\n\n {%- endcall -%}\n\n {{ return(load_result('catalog').table) }}\n\n{%- endmacro %}", + "macro_sql": "{% macro postgres__get_replace_table_sql(relation, sql) -%}\n\n {%- set sql_header = config.get('sql_header', none) -%}\n {{ sql_header if sql_header is not none }}\n\n create or replace table {{ relation }}\n {% set contract_config = config.get('contract') %}\n {% if contract_config.enforced %}\n {{ get_assert_columns_equivalent(sql) }}\n {{ get_table_columns_and_constraints() }}\n {%- set sql = get_select_subquery(sql) %}\n {% endif %}\n as (\n {{ sql }}\n );\n\n{%- endmacro %}", "meta": {}, - "name": "postgres__get_catalog", - "original_file_path": "macros/catalog.sql", + "name": "postgres__get_replace_table_sql", + "original_file_path": "macros/relations/table/replace.sql", "package_name": "dbt_postgres", "patch_path": null, - "path": "macros/catalog.sql", + "path": "macros/relations/table/replace.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/postgres", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt_postgres.postgres__get_catalog" + "unique_id": "macro.dbt_postgres.postgres__get_replace_table_sql" }, - "macro.dbt_postgres.postgres__get_columns_in_relation": { + "macro.dbt_postgres.postgres__get_replace_view_sql": { "arguments": [], - "created_at": 1696458269.568068, + "created_at": 1719485736.327884, "depends_on": { "macros": [ - "macro.dbt.statement", - "macro.dbt.sql_convert_columns_in_relation" + "macro.dbt.get_assert_columns_equivalent" ] }, "description": "", @@ -7047,22 +9474,20 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro postgres__get_columns_in_relation(relation) -%}\n {% call statement('get_columns_in_relation', fetch_result=True) %}\n select\n column_name,\n data_type,\n character_maximum_length,\n numeric_precision,\n numeric_scale\n\n from {{ relation.information_schema('columns') }}\n where table_name = '{{ relation.identifier }}'\n {% if relation.schema %}\n and table_schema = '{{ relation.schema }}'\n {% endif %}\n order by ordinal_position\n\n {% endcall %}\n {% set table = load_result('get_columns_in_relation').table %}\n {{ return(sql_convert_columns_in_relation(table)) }}\n{% endmacro %}", + "macro_sql": "{% macro postgres__get_replace_view_sql(relation, sql) -%}\n\n {%- set sql_header = config.get('sql_header', none) -%}\n {{ sql_header if sql_header is not none }}\n\n create or replace view {{ relation }}\n {% set contract_config = config.get('contract') %}\n {% if contract_config.enforced %}\n {{ get_assert_columns_equivalent(sql) }}\n {%- endif %}\n as (\n {{ sql }}\n );\n\n{%- endmacro %}", "meta": {}, - "name": "postgres__get_columns_in_relation", - "original_file_path": "macros/adapters.sql", + "name": "postgres__get_replace_view_sql", + "original_file_path": "macros/relations/view/replace.sql", "package_name": "dbt_postgres", "patch_path": null, - "path": "macros/adapters.sql", + "path": "macros/relations/view/replace.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/postgres", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt_postgres.postgres__get_columns_in_relation" + "unique_id": "macro.dbt_postgres.postgres__get_replace_view_sql" }, - "macro.dbt_postgres.postgres__get_create_index_sql": { + "macro.dbt_postgres.postgres__get_show_grant_sql": { "arguments": [], - "created_at": 1696458269.566161, + "created_at": 1719485736.314513, "depends_on": { "macros": [] }, @@ -7071,49 +9496,20 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro postgres__get_create_index_sql(relation, index_dict) -%}\n {%- set index_config = adapter.parse_index(index_dict) -%}\n {%- set comma_separated_columns = \", \".join(index_config.columns) -%}\n {%- set index_name = index_config.render(relation) -%}\n\n create {% if index_config.unique -%}\n unique\n {%- endif %} index if not exists\n \"{{ index_name }}\"\n on {{ relation }} {% if index_config.type -%}\n using {{ index_config.type }}\n {%- endif %}\n ({{ comma_separated_columns }});\n{%- endmacro %}", + "macro_sql": "\n\n{%- macro postgres__get_show_grant_sql(relation) -%}\n select grantee, privilege_type\n from {{ relation.information_schema('role_table_grants') }}\n where grantor = current_role\n and grantee != current_role\n and table_schema = '{{ relation.schema }}'\n and table_name = '{{ relation.identifier }}'\n{%- endmacro -%}\n\n", "meta": {}, - "name": "postgres__get_create_index_sql", + "name": "postgres__get_show_grant_sql", "original_file_path": "macros/adapters.sql", "package_name": "dbt_postgres", "patch_path": null, "path": "macros/adapters.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/postgres", - "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt_postgres.postgres__get_create_index_sql" - }, - "macro.dbt_postgres.postgres__get_incremental_default_sql": { - "arguments": [], - "created_at": 1696458269.577235, - "depends_on": { - "macros": [ - "macro.dbt.get_incremental_delete_insert_sql", - "macro.dbt.get_incremental_append_sql" - ] - }, - "description": "", - "docs": { - "node_color": null, - "show": true - }, - "macro_sql": "{% macro postgres__get_incremental_default_sql(arg_dict) %}\n\n {% if arg_dict[\"unique_key\"] %}\n {% do return(get_incremental_delete_insert_sql(arg_dict)) %}\n {% else %}\n {% do return(get_incremental_append_sql(arg_dict)) %}\n {% endif %}\n\n{% endmacro %}", - "meta": {}, - "name": "postgres__get_incremental_default_sql", - "original_file_path": "macros/materializations/incremental_strategies.sql", - "package_name": "dbt_postgres", - "patch_path": null, - "path": "macros/materializations/incremental_strategies.sql", - "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/postgres", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt_postgres.postgres__get_incremental_default_sql" + "unique_id": "macro.dbt_postgres.postgres__get_show_grant_sql" }, - "macro.dbt_postgres.postgres__get_show_grant_sql": { + "macro.dbt_postgres.postgres__get_show_indexes_sql": { "arguments": [], - "created_at": 1696458269.575934, + "created_at": 1719485736.31486, "depends_on": { "macros": [] }, @@ -7122,22 +9518,20 @@ "node_color": null, "show": true }, - "macro_sql": "\n\n{%- macro postgres__get_show_grant_sql(relation) -%}\n select grantee, privilege_type\n from {{ relation.information_schema('role_table_grants') }}\n where grantor = current_role\n and grantee != current_role\n and table_schema = '{{ relation.schema }}'\n and table_name = '{{ relation.identifier }}'\n{%- endmacro -%}\n\n", + "macro_sql": "{% macro postgres__get_show_indexes_sql(relation) %}\n select\n i.relname as name,\n m.amname as method,\n ix.indisunique as \"unique\",\n array_to_string(array_agg(a.attname), ',') as column_names\n from pg_index ix\n join pg_class i\n on i.oid = ix.indexrelid\n join pg_am m\n on m.oid=i.relam\n join pg_class t\n on t.oid = ix.indrelid\n join pg_namespace n\n on n.oid = t.relnamespace\n join pg_attribute a\n on a.attrelid = t.oid\n and a.attnum = ANY(ix.indkey)\n where t.relname = '{{ relation.identifier }}'\n and n.nspname = '{{ relation.schema }}'\n and t.relkind in ('r', 'm')\n group by 1, 2, 3\n order by 1, 2, 3\n{% endmacro %}", "meta": {}, - "name": "postgres__get_show_grant_sql", + "name": "postgres__get_show_indexes_sql", "original_file_path": "macros/adapters.sql", "package_name": "dbt_postgres", "patch_path": null, "path": "macros/adapters.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/postgres", "supported_languages": null, - "tags": [], - "unique_id": "macro.dbt_postgres.postgres__get_show_grant_sql" + "unique_id": "macro.dbt_postgres.postgres__get_show_indexes_sql" }, "macro.dbt_postgres.postgres__information_schema_name": { "arguments": [], - "created_at": 1696458269.568956, + "created_at": 1719485736.308933, "depends_on": { "macros": [] }, @@ -7154,14 +9548,12 @@ "patch_path": null, "path": "macros/adapters.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/postgres", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_postgres.postgres__information_schema_name" }, "macro.dbt_postgres.postgres__last_day": { "arguments": [], - "created_at": 1696458269.587851, + "created_at": 1719485736.333154, "depends_on": { "macros": [ "macro.dbt.dateadd", @@ -7182,14 +9574,12 @@ "patch_path": null, "path": "macros/utils/last_day.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/postgres", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_postgres.postgres__last_day" }, "macro.dbt_postgres.postgres__list_relations_without_caching": { "arguments": [], - "created_at": 1696458269.568678, + "created_at": 1719485736.308728, "depends_on": { "macros": [ "macro.dbt.statement" @@ -7200,7 +9590,7 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro postgres__list_relations_without_caching(schema_relation) %}\n {% call statement('list_relations_without_caching', fetch_result=True) -%}\n select\n '{{ schema_relation.database }}' as database,\n tablename as name,\n schemaname as schema,\n 'table' as type\n from pg_tables\n where schemaname ilike '{{ schema_relation.schema }}'\n union all\n select\n '{{ schema_relation.database }}' as database,\n viewname as name,\n schemaname as schema,\n 'view' as type\n from pg_views\n where schemaname ilike '{{ schema_relation.schema }}'\n {% endcall %}\n {{ return(load_result('list_relations_without_caching').table) }}\n{% endmacro %}", + "macro_sql": "{% macro postgres__list_relations_without_caching(schema_relation) %}\n {% call statement('list_relations_without_caching', fetch_result=True) -%}\n select\n '{{ schema_relation.database }}' as database,\n tablename as name,\n schemaname as schema,\n 'table' as type\n from pg_tables\n where schemaname ilike '{{ schema_relation.schema }}'\n union all\n select\n '{{ schema_relation.database }}' as database,\n viewname as name,\n schemaname as schema,\n 'view' as type\n from pg_views\n where schemaname ilike '{{ schema_relation.schema }}'\n union all\n select\n '{{ schema_relation.database }}' as database,\n matviewname as name,\n schemaname as schema,\n 'materialized_view' as type\n from pg_matviews\n where schemaname ilike '{{ schema_relation.schema }}'\n {% endcall %}\n {{ return(load_result('list_relations_without_caching').table) }}\n{% endmacro %}", "meta": {}, "name": "postgres__list_relations_without_caching", "original_file_path": "macros/adapters.sql", @@ -7208,14 +9598,12 @@ "patch_path": null, "path": "macros/adapters.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/postgres", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_postgres.postgres__list_relations_without_caching" }, "macro.dbt_postgres.postgres__list_schemas": { "arguments": [], - "created_at": 1696458269.569512, + "created_at": 1719485736.309331, "depends_on": { "macros": [ "macro.dbt.statement" @@ -7234,14 +9622,12 @@ "patch_path": null, "path": "macros/adapters.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/postgres", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_postgres.postgres__list_schemas" }, "macro.dbt_postgres.postgres__listagg": { "arguments": [], - "created_at": 1696458269.5807512, + "created_at": 1719485736.329083, "depends_on": { "macros": [] }, @@ -7258,14 +9644,12 @@ "patch_path": null, "path": "macros/utils/listagg.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/postgres", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_postgres.postgres__listagg" }, "macro.dbt_postgres.postgres__make_backup_relation": { "arguments": [], - "created_at": 1696458269.5734138, + "created_at": 1719485736.311583, "depends_on": { "macros": [ "macro.dbt_postgres.postgres__make_relation_with_suffix" @@ -7284,14 +9668,12 @@ "patch_path": null, "path": "macros/adapters.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/postgres", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_postgres.postgres__make_backup_relation" }, "macro.dbt_postgres.postgres__make_intermediate_relation": { "arguments": [], - "created_at": 1696458269.5723772, + "created_at": 1719485736.3109372, "depends_on": { "macros": [ "macro.dbt_postgres.postgres__make_relation_with_suffix" @@ -7310,14 +9692,12 @@ "patch_path": null, "path": "macros/adapters.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/postgres", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_postgres.postgres__make_intermediate_relation" }, "macro.dbt_postgres.postgres__make_relation_with_suffix": { "arguments": [], - "created_at": 1696458269.5720162, + "created_at": 1719485736.31072, "depends_on": { "macros": [] }, @@ -7334,14 +9714,12 @@ "patch_path": null, "path": "macros/adapters.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/postgres", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_postgres.postgres__make_relation_with_suffix" }, "macro.dbt_postgres.postgres__make_temp_relation": { "arguments": [], - "created_at": 1696458269.572947, + "created_at": 1719485736.31129, "depends_on": { "macros": [ "macro.dbt_postgres.postgres__make_relation_with_suffix" @@ -7360,14 +9738,34 @@ "patch_path": null, "path": "macros/adapters.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/postgres", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_postgres.postgres__make_temp_relation" }, + "macro.dbt_postgres.postgres__refresh_materialized_view": { + "arguments": [], + "created_at": 1719485736.32038, + "depends_on": { + "macros": [] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "{% macro postgres__refresh_materialized_view(relation) %}\n refresh materialized view {{ relation }}\n{% endmacro %}", + "meta": {}, + "name": "postgres__refresh_materialized_view", + "original_file_path": "macros/relations/materialized_view/refresh.sql", + "package_name": "dbt_postgres", + "patch_path": null, + "path": "macros/relations/materialized_view/refresh.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt_postgres.postgres__refresh_materialized_view" + }, "macro.dbt_postgres.postgres__snapshot_get_time": { "arguments": [], - "created_at": 1696458269.5516999, + "created_at": 1719485736.289238, "depends_on": { "macros": [ "macro.dbt.current_timestamp" @@ -7386,14 +9784,12 @@ "patch_path": null, "path": "macros/timestamps.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/postgres", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_postgres.postgres__snapshot_get_time" }, "macro.dbt_postgres.postgres__snapshot_merge_sql": { "arguments": [], - "created_at": 1696458269.5788622, + "created_at": 1719485736.318985, "depends_on": { "macros": [] }, @@ -7410,14 +9806,12 @@ "patch_path": null, "path": "macros/materializations/snapshot_merge.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/postgres", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_postgres.postgres__snapshot_merge_sql" }, "macro.dbt_postgres.postgres__snapshot_string_as_time": { "arguments": [], - "created_at": 1696458269.551509, + "created_at": 1719485736.289119, "depends_on": { "macros": [] }, @@ -7434,14 +9828,12 @@ "patch_path": null, "path": "macros/timestamps.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/postgres", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_postgres.postgres__snapshot_string_as_time" }, "macro.dbt_postgres.postgres__split_part": { "arguments": [], - "created_at": 1696458269.588754, + "created_at": 1719485736.334007, "depends_on": { "macros": [ "macro.dbt.default__split_part", @@ -7461,14 +9853,37 @@ "patch_path": null, "path": "macros/utils/split_part.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/postgres", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_postgres.postgres__split_part" }, + "macro.dbt_postgres.postgres__update_indexes_on_materialized_view": { + "arguments": [], + "created_at": 1719485736.3239639, + "depends_on": { + "macros": [ + "macro.dbt_postgres.postgres__get_drop_index_sql", + "macro.dbt_postgres.postgres__get_create_index_sql" + ] + }, + "description": "", + "docs": { + "node_color": null, + "show": true + }, + "macro_sql": "\n\n\n{%- macro postgres__update_indexes_on_materialized_view(relation, index_changes) -%}\n {{- log(\"Applying UPDATE INDEXES to: \" ~ relation) -}}\n\n {%- for _index_change in index_changes -%}\n {%- set _index = _index_change.context -%}\n\n {%- if _index_change.action == \"drop\" -%}\n\n {{ postgres__get_drop_index_sql(relation, _index.name) }};\n\n {%- elif _index_change.action == \"create\" -%}\n\n {{ postgres__get_create_index_sql(relation, _index.as_node_config) }}\n\n {%- endif -%}\n\n {%- endfor -%}\n\n{%- endmacro -%}\n\n\n", + "meta": {}, + "name": "postgres__update_indexes_on_materialized_view", + "original_file_path": "macros/relations/materialized_view/alter.sql", + "package_name": "dbt_postgres", + "patch_path": null, + "path": "macros/relations/materialized_view/alter.sql", + "resource_type": "macro", + "supported_languages": null, + "unique_id": "macro.dbt_postgres.postgres__update_indexes_on_materialized_view" + }, "macro.dbt_postgres.postgres_escape_comment": { "arguments": [], - "created_at": 1696458269.5741508, + "created_at": 1719485736.3121421, "depends_on": { "macros": [] }, @@ -7485,17 +9900,15 @@ "patch_path": null, "path": "macros/adapters.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/postgres", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_postgres.postgres_escape_comment" }, "macro.dbt_postgres.postgres_get_relations": { "arguments": [], - "created_at": 1696458269.555246, + "created_at": 1719485736.2934961, "depends_on": { "macros": [ - "macro.dbt.statement" + "macro.dbt_postgres.postgres__get_relations" ] }, "description": "", @@ -7503,7 +9916,7 @@ "node_color": null, "show": true }, - "macro_sql": "{% macro postgres_get_relations () -%}\n\n {#\n -- in pg_depend, objid is the dependent, refobjid is the referenced object\n -- > a pg_depend entry indicates that the referenced object cannot be\n -- > dropped without also dropping the dependent object.\n #}\n\n {%- call statement('relations', fetch_result=True) -%}\n with relation as (\n select\n pg_rewrite.ev_class as class,\n pg_rewrite.oid as id\n from pg_rewrite\n ),\n class as (\n select\n oid as id,\n relname as name,\n relnamespace as schema,\n relkind as kind\n from pg_class\n ),\n dependency as (\n select distinct\n pg_depend.objid as id,\n pg_depend.refobjid as ref\n from pg_depend\n ),\n schema as (\n select\n pg_namespace.oid as id,\n pg_namespace.nspname as name\n from pg_namespace\n where nspname != 'information_schema' and nspname not like 'pg\\_%'\n ),\n referenced as (\n select\n relation.id AS id,\n referenced_class.name ,\n referenced_class.schema ,\n referenced_class.kind\n from relation\n join class as referenced_class on relation.class=referenced_class.id\n where referenced_class.kind in ('r', 'v')\n ),\n relationships as (\n select\n referenced.name as referenced_name,\n referenced.schema as referenced_schema_id,\n dependent_class.name as dependent_name,\n dependent_class.schema as dependent_schema_id,\n referenced.kind as kind\n from referenced\n join dependency on referenced.id=dependency.id\n join class as dependent_class on dependency.ref=dependent_class.id\n where\n (referenced.name != dependent_class.name or\n referenced.schema != dependent_class.schema)\n )\n\n select\n referenced_schema.name as referenced_schema,\n relationships.referenced_name as referenced_name,\n dependent_schema.name as dependent_schema,\n relationships.dependent_name as dependent_name\n from relationships\n join schema as dependent_schema on relationships.dependent_schema_id=dependent_schema.id\n join schema as referenced_schema on relationships.referenced_schema_id=referenced_schema.id\n group by referenced_schema, referenced_name, dependent_schema, dependent_name\n order by referenced_schema, referenced_name, dependent_schema, dependent_name;\n\n {%- endcall -%}\n\n {{ return(load_result('relations').table) }}\n{% endmacro %}", + "macro_sql": "{% macro postgres_get_relations() %}\n {{ return(postgres__get_relations()) }}\n{% endmacro %}", "meta": {}, "name": "postgres_get_relations", "original_file_path": "macros/relations.sql", @@ -7511,14 +9924,12 @@ "patch_path": null, "path": "macros/relations.sql", "resource_type": "macro", - "root_path": "/Users/julian/.pyenv/versions/3.10.0/lib/python3.10/site-packages/dbt/include/postgres", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_postgres.postgres_get_relations" }, "macro.dbt_utils._bigquery__get_matching_schemata": { "arguments": [], - "created_at": 1696458269.967883, + "created_at": 1719485736.7140338, "depends_on": { "macros": [ "macro.dbt.run_query" @@ -7537,14 +9948,12 @@ "patch_path": null, "path": "macros/sql/get_tables_by_pattern_sql.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils._bigquery__get_matching_schemata" }, "macro.dbt_utils._is_ephemeral": { "arguments": [], - "created_at": 1696458269.911734, + "created_at": 1719485736.667329, "depends_on": { "macros": [] }, @@ -7561,14 +9970,12 @@ "patch_path": null, "path": "macros/jinja_helpers/_is_ephemeral.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils._is_ephemeral" }, "macro.dbt_utils._is_relation": { "arguments": [], - "created_at": 1696458269.907017, + "created_at": 1719485736.6636631, "depends_on": { "macros": [] }, @@ -7585,14 +9992,12 @@ "patch_path": null, "path": "macros/jinja_helpers/_is_relation.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils._is_relation" }, "macro.dbt_utils.bigquery__deduplicate": { "arguments": [], - "created_at": 1696458269.95776, + "created_at": 1719485736.703784, "depends_on": { "macros": [] }, @@ -7609,14 +10014,12 @@ "patch_path": null, "path": "macros/sql/deduplicate.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.bigquery__deduplicate" }, "macro.dbt_utils.bigquery__get_tables_by_pattern_sql": { "arguments": [], - "created_at": 1696458269.9670799, + "created_at": 1719485736.713356, "depends_on": { "macros": [ "macro.dbt_utils._bigquery__get_matching_schemata", @@ -7636,14 +10039,12 @@ "patch_path": null, "path": "macros/sql/get_tables_by_pattern_sql.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.bigquery__get_tables_by_pattern_sql" }, "macro.dbt_utils.bigquery__haversine_distance": { "arguments": [], - "created_at": 1696458269.9946911, + "created_at": 1719485736.73806, "depends_on": { "macros": [ "macro.dbt_utils.degrees_to_radians" @@ -7662,14 +10063,12 @@ "patch_path": null, "path": "macros/sql/haversine_distance.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.bigquery__haversine_distance" }, "macro.dbt_utils.databricks__get_table_types_sql": { "arguments": [], - "created_at": 1696458269.98752, + "created_at": 1719485736.73151, "depends_on": { "macros": [] }, @@ -7686,14 +10085,12 @@ "patch_path": null, "path": "macros/sql/get_table_types_sql.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.databricks__get_table_types_sql" }, "macro.dbt_utils.date_spine": { "arguments": [], - "created_at": 1696458269.914428, + "created_at": 1719485736.669255, "depends_on": { "macros": [ "macro.dbt_utils.default__date_spine" @@ -7712,14 +10109,12 @@ "patch_path": null, "path": "macros/sql/date_spine.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.date_spine" }, "macro.dbt_utils.deduplicate": { "arguments": [], - "created_at": 1696458269.9561112, + "created_at": 1719485736.7012029, "depends_on": { "macros": [ "macro.dbt_utils.postgres__deduplicate" @@ -7738,14 +10133,12 @@ "patch_path": null, "path": "macros/sql/deduplicate.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.deduplicate" }, "macro.dbt_utils.default__date_spine": { "arguments": [], - "created_at": 1696458269.915016, + "created_at": 1719485736.669651, "depends_on": { "macros": [ "macro.dbt_utils.generate_series", @@ -7766,14 +10159,12 @@ "patch_path": null, "path": "macros/sql/date_spine.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__date_spine" }, "macro.dbt_utils.default__deduplicate": { "arguments": [], - "created_at": 1696458269.956494, + "created_at": 1719485736.701886, "depends_on": { "macros": [] }, @@ -7790,14 +10181,12 @@ "patch_path": null, "path": "macros/sql/deduplicate.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__deduplicate" }, "macro.dbt_utils.default__generate_series": { "arguments": [], - "created_at": 1696458269.925334, + "created_at": 1719485736.678598, "depends_on": { "macros": [ "macro.dbt_utils.get_powers_of_two" @@ -7816,14 +10205,12 @@ "patch_path": null, "path": "macros/sql/generate_series.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__generate_series" }, "macro.dbt_utils.default__generate_surrogate_key": { "arguments": [], - "created_at": 1696458269.985795, + "created_at": 1719485736.730099, "depends_on": { "macros": [ "macro.dbt.type_string", @@ -7844,14 +10231,12 @@ "patch_path": null, "path": "macros/sql/generate_surrogate_key.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__generate_surrogate_key" }, "macro.dbt_utils.default__get_column_values": { "arguments": [], - "created_at": 1696458269.9728918, + "created_at": 1719485736.717495, "depends_on": { "macros": [ "macro.dbt_utils._is_ephemeral", @@ -7872,14 +10257,12 @@ "patch_path": null, "path": "macros/sql/get_column_values.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__get_column_values" }, "macro.dbt_utils.default__get_filtered_columns_in_relation": { "arguments": [], - "created_at": 1696458269.979146, + "created_at": 1719485736.722564, "depends_on": { "macros": [ "macro.dbt_utils._is_relation", @@ -7899,14 +10282,12 @@ "patch_path": null, "path": "macros/sql/get_filtered_columns_in_relation.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__get_filtered_columns_in_relation" }, "macro.dbt_utils.default__get_intervals_between": { "arguments": [], - "created_at": 1696458269.914051, + "created_at": 1719485736.668746, "depends_on": { "macros": [ "macro.dbt.statement", @@ -7926,14 +10307,12 @@ "patch_path": null, "path": "macros/sql/date_spine.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__get_intervals_between" }, "macro.dbt_utils.default__get_powers_of_two": { "arguments": [], - "created_at": 1696458269.923839, + "created_at": 1719485736.677862, "depends_on": { "macros": [] }, @@ -7950,14 +10329,12 @@ "patch_path": null, "path": "macros/sql/generate_series.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__get_powers_of_two" }, "macro.dbt_utils.default__get_query_results_as_dict": { "arguments": [], - "created_at": 1696458269.9838238, + "created_at": 1719485736.727696, "depends_on": { "macros": [ "macro.dbt.statement" @@ -7976,14 +10353,12 @@ "patch_path": null, "path": "macros/sql/get_query_results_as_dict.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__get_query_results_as_dict" }, "macro.dbt_utils.default__get_relations_by_pattern": { "arguments": [], - "created_at": 1696458269.921258, + "created_at": 1719485736.674046, "depends_on": { "macros": [ "macro.dbt.statement", @@ -8003,14 +10378,12 @@ "patch_path": null, "path": "macros/sql/get_relations_by_pattern.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__get_relations_by_pattern" }, "macro.dbt_utils.default__get_relations_by_prefix": { "arguments": [], - "created_at": 1696458269.928546, + "created_at": 1719485736.680293, "depends_on": { "macros": [ "macro.dbt.statement", @@ -8030,14 +10403,12 @@ "patch_path": null, "path": "macros/sql/get_relations_by_prefix.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__get_relations_by_prefix" }, "macro.dbt_utils.default__get_single_value": { "arguments": [], - "created_at": 1696458269.989894, + "created_at": 1719485736.7332969, "depends_on": { "macros": [ "macro.dbt.statement" @@ -8056,14 +10427,12 @@ "patch_path": null, "path": "macros/sql/get_single_value.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__get_single_value" }, "macro.dbt_utils.default__get_table_types_sql": { "arguments": [], - "created_at": 1696458269.987062, + "created_at": 1719485736.73105, "depends_on": { "macros": [] }, @@ -8080,14 +10449,12 @@ "patch_path": null, "path": "macros/sql/get_table_types_sql.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__get_table_types_sql" }, "macro.dbt_utils.default__get_tables_by_pattern_sql": { "arguments": [], - "created_at": 1696458269.965835, + "created_at": 1719485736.7122319, "depends_on": { "macros": [ "macro.dbt_utils.get_table_types_sql" @@ -8106,14 +10473,12 @@ "patch_path": null, "path": "macros/sql/get_tables_by_pattern_sql.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__get_tables_by_pattern_sql" }, "macro.dbt_utils.default__get_tables_by_prefix_sql": { "arguments": [], - "created_at": 1696458269.930016, + "created_at": 1719485736.681414, "depends_on": { "macros": [ "macro.dbt_utils.get_tables_by_pattern_sql" @@ -8132,14 +10497,12 @@ "patch_path": null, "path": "macros/sql/get_tables_by_prefix_sql.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__get_tables_by_prefix_sql" }, "macro.dbt_utils.default__get_url_host": { "arguments": [], - "created_at": 1696458269.857218, + "created_at": 1719485736.621399, "depends_on": { "macros": [ "macro.dbt.split_part", @@ -8161,14 +10524,12 @@ "patch_path": null, "path": "macros/web/get_url_host.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__get_url_host" }, "macro.dbt_utils.default__get_url_parameter": { "arguments": [], - "created_at": 1696458269.860918, + "created_at": 1719485736.624197, "depends_on": { "macros": [ "macro.dbt.split_part" @@ -8187,14 +10548,12 @@ "patch_path": null, "path": "macros/web/get_url_parameter.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__get_url_parameter" }, "macro.dbt_utils.default__get_url_path": { "arguments": [], - "created_at": 1696458269.859402, + "created_at": 1719485736.6228778, "depends_on": { "macros": [ "macro.dbt.replace", @@ -8219,14 +10578,12 @@ "patch_path": null, "path": "macros/web/get_url_path.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__get_url_path" }, "macro.dbt_utils.default__group_by": { "arguments": [], - "created_at": 1696458269.954616, + "created_at": 1719485736.700402, "depends_on": { "macros": [] }, @@ -8243,14 +10600,12 @@ "patch_path": null, "path": "macros/sql/groupby.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__group_by" }, "macro.dbt_utils.default__haversine_distance": { "arguments": [], - "created_at": 1696458269.993296, + "created_at": 1719485736.736367, "depends_on": { "macros": [] }, @@ -8267,14 +10622,12 @@ "patch_path": null, "path": "macros/sql/haversine_distance.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__haversine_distance" }, "macro.dbt_utils.default__log_info": { "arguments": [], - "created_at": 1696458269.908833, + "created_at": 1719485736.665316, "depends_on": { "macros": [ "macro.dbt_utils.pretty_log_format" @@ -8293,14 +10646,12 @@ "patch_path": null, "path": "macros/jinja_helpers/log_info.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__log_info" }, "macro.dbt_utils.default__nullcheck": { "arguments": [], - "created_at": 1696458269.9623308, + "created_at": 1719485736.7089212, "depends_on": { "macros": [] }, @@ -8317,14 +10668,12 @@ "patch_path": null, "path": "macros/sql/nullcheck.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__nullcheck" }, "macro.dbt_utils.default__nullcheck_table": { "arguments": [], - "created_at": 1696458269.9181361, + "created_at": 1719485736.6715178, "depends_on": { "macros": [ "macro.dbt_utils._is_relation", @@ -8345,14 +10694,12 @@ "patch_path": null, "path": "macros/sql/nullcheck_table.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__nullcheck_table" }, "macro.dbt_utils.default__pivot": { "arguments": [], - "created_at": 1696458269.976574, + "created_at": 1719485736.720366, "depends_on": { "macros": [ "macro.dbt.escape_single_quotes", @@ -8372,14 +10719,12 @@ "patch_path": null, "path": "macros/sql/pivot.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__pivot" }, "macro.dbt_utils.default__pretty_log_format": { "arguments": [], - "created_at": 1696458269.906094, + "created_at": 1719485736.66297, "depends_on": { "macros": [ "macro.dbt_utils.pretty_time" @@ -8398,14 +10743,12 @@ "patch_path": null, "path": "macros/jinja_helpers/pretty_log_format.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__pretty_log_format" }, "macro.dbt_utils.default__pretty_time": { "arguments": [], - "created_at": 1696458269.90797, + "created_at": 1719485736.66434, "depends_on": { "macros": [] }, @@ -8422,14 +10765,12 @@ "patch_path": null, "path": "macros/jinja_helpers/pretty_time.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__pretty_time" }, "macro.dbt_utils.default__safe_add": { "arguments": [], - "created_at": 1696458269.961039, + "created_at": 1719485736.707396, "depends_on": { "macros": [] }, @@ -8446,14 +10787,12 @@ "patch_path": null, "path": "macros/sql/safe_add.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__safe_add" }, "macro.dbt_utils.default__safe_divide": { "arguments": [], - "created_at": 1696458269.941708, + "created_at": 1719485736.689852, "depends_on": { "macros": [] }, @@ -8470,14 +10809,12 @@ "patch_path": null, "path": "macros/sql/safe_divide.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__safe_divide" }, "macro.dbt_utils.default__safe_subtract": { "arguments": [], - "created_at": 1696458269.916886, + "created_at": 1719485736.670645, "depends_on": { "macros": [] }, @@ -8494,14 +10831,12 @@ "patch_path": null, "path": "macros/sql/safe_subtract.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__safe_subtract" }, "macro.dbt_utils.default__star": { "arguments": [], - "created_at": 1696458269.935112, + "created_at": 1719485736.685639, "depends_on": { "macros": [ "macro.dbt_utils._is_relation", @@ -8522,14 +10857,12 @@ "patch_path": null, "path": "macros/sql/star.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__star" }, "macro.dbt_utils.default__surrogate_key": { "arguments": [], - "created_at": 1696458269.959094, + "created_at": 1719485736.7051, "depends_on": { "macros": [] }, @@ -8546,14 +10879,12 @@ "patch_path": null, "path": "macros/sql/surrogate_key.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__surrogate_key" }, "macro.dbt_utils.default__test_accepted_range": { "arguments": [], - "created_at": 1696458269.8764522, + "created_at": 1719485736.6358259, "depends_on": { "macros": [] }, @@ -8570,14 +10901,12 @@ "patch_path": null, "path": "macros/generic_tests/accepted_range.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__test_accepted_range" }, "macro.dbt_utils.default__test_at_least_one": { "arguments": [], - "created_at": 1696458269.880932, + "created_at": 1719485736.638953, "depends_on": { "macros": [] }, @@ -8594,14 +10923,12 @@ "patch_path": null, "path": "macros/generic_tests/at_least_one.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__test_at_least_one" }, "macro.dbt_utils.default__test_cardinality_equality": { "arguments": [], - "created_at": 1696458269.884924, + "created_at": 1719485736.646685, "depends_on": { "macros": [ "macro.dbt.except" @@ -8620,14 +10947,12 @@ "patch_path": null, "path": "macros/generic_tests/cardinality_equality.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__test_cardinality_equality" }, "macro.dbt_utils.default__test_equal_rowcount": { "arguments": [], - "created_at": 1696458269.867796, + "created_at": 1719485736.629295, "depends_on": { "macros": [] }, @@ -8644,14 +10969,12 @@ "patch_path": null, "path": "macros/generic_tests/equal_rowcount.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__test_equal_rowcount" }, "macro.dbt_utils.default__test_equality": { "arguments": [], - "created_at": 1696458269.8951738, + "created_at": 1719485736.654316, "depends_on": { "macros": [ "macro.dbt_utils._is_relation", @@ -8672,14 +10995,12 @@ "patch_path": null, "path": "macros/generic_tests/equality.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__test_equality" }, "macro.dbt_utils.default__test_expression_is_true": { "arguments": [], - "created_at": 1696458269.8862998, + "created_at": 1719485736.6476219, "depends_on": { "macros": [ "macro.dbt.should_store_failures" @@ -8698,14 +11019,12 @@ "patch_path": null, "path": "macros/generic_tests/expression_is_true.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__test_expression_is_true" }, "macro.dbt_utils.default__test_fewer_rows_than": { "arguments": [], - "created_at": 1696458269.864535, + "created_at": 1719485736.626648, "depends_on": { "macros": [] }, @@ -8722,14 +11041,12 @@ "patch_path": null, "path": "macros/generic_tests/fewer_rows_than.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__test_fewer_rows_than" }, "macro.dbt_utils.default__test_mutually_exclusive_ranges": { "arguments": [], - "created_at": 1696458269.90514, + "created_at": 1719485736.662267, "depends_on": { "macros": [] }, @@ -8746,14 +11063,12 @@ "patch_path": null, "path": "macros/generic_tests/mutually_exclusive_ranges.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__test_mutually_exclusive_ranges" }, "macro.dbt_utils.default__test_not_accepted_values": { "arguments": [], - "created_at": 1696458269.8782852, + "created_at": 1719485736.636965, "depends_on": { "macros": [] }, @@ -8770,14 +11085,12 @@ "patch_path": null, "path": "macros/generic_tests/not_accepted_values.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__test_not_accepted_values" }, "macro.dbt_utils.default__test_not_constant": { "arguments": [], - "created_at": 1696458269.874454, + "created_at": 1719485736.634388, "depends_on": { "macros": [] }, @@ -8794,14 +11107,12 @@ "patch_path": null, "path": "macros/generic_tests/not_constant.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__test_not_constant" }, "macro.dbt_utils.default__test_not_empty_string": { "arguments": [], - "created_at": 1696458269.8967302, + "created_at": 1719485736.655891, "depends_on": { "macros": [] }, @@ -8818,14 +11129,12 @@ "patch_path": null, "path": "macros/generic_tests/not_empty_string.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__test_not_empty_string" }, "macro.dbt_utils.default__test_not_null_proportion": { "arguments": [], - "created_at": 1696458269.8888562, + "created_at": 1719485736.6493912, "depends_on": { "macros": [] }, @@ -8842,14 +11151,12 @@ "patch_path": null, "path": "macros/generic_tests/not_null_proportion.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__test_not_null_proportion" }, "macro.dbt_utils.default__test_recency": { "arguments": [], - "created_at": 1696458269.8727272, + "created_at": 1719485736.633423, "depends_on": { "macros": [ "macro.dbt.dateadd", @@ -8870,14 +11177,12 @@ "patch_path": null, "path": "macros/generic_tests/recency.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__test_recency" }, "macro.dbt_utils.default__test_relationships_where": { "arguments": [], - "created_at": 1696458269.8695161, + "created_at": 1719485736.630768, "depends_on": { "macros": [] }, @@ -8894,14 +11199,12 @@ "patch_path": null, "path": "macros/generic_tests/relationships_where.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__test_relationships_where" }, "macro.dbt_utils.default__test_sequential_values": { "arguments": [], - "created_at": 1696458269.892125, + "created_at": 1719485736.652041, "depends_on": { "macros": [ "macro.dbt_utils.slugify", @@ -8922,14 +11225,12 @@ "patch_path": null, "path": "macros/generic_tests/sequential_values.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__test_sequential_values" }, "macro.dbt_utils.default__test_unique_combination_of_columns": { "arguments": [], - "created_at": 1696458269.883317, + "created_at": 1719485736.643641, "depends_on": { "macros": [] }, @@ -8946,14 +11247,12 @@ "patch_path": null, "path": "macros/generic_tests/unique_combination_of_columns.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__test_unique_combination_of_columns" }, "macro.dbt_utils.default__union_relations": { "arguments": [], - "created_at": 1696458269.953401, + "created_at": 1719485736.6993861, "depends_on": { "macros": [ "macro.dbt_utils._is_relation", @@ -8975,14 +11274,12 @@ "patch_path": null, "path": "macros/sql/union.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__union_relations" }, "macro.dbt_utils.default__unpivot": { "arguments": [], - "created_at": 1696458269.940675, + "created_at": 1719485736.689354, "depends_on": { "macros": [ "macro.dbt_utils._is_relation", @@ -9004,14 +11301,12 @@ "patch_path": null, "path": "macros/sql/unpivot.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__unpivot" }, "macro.dbt_utils.default__width_bucket": { "arguments": [], - "created_at": 1696458269.981664, + "created_at": 1719485736.7257888, "depends_on": { "macros": [ "macro.dbt.safe_cast", @@ -9031,14 +11326,12 @@ "patch_path": null, "path": "macros/sql/width_bucket.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.default__width_bucket" }, "macro.dbt_utils.degrees_to_radians": { "arguments": [], - "created_at": 1696458269.991828, + "created_at": 1719485736.735244, "depends_on": { "macros": [] }, @@ -9055,14 +11348,12 @@ "patch_path": null, "path": "macros/sql/haversine_distance.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.degrees_to_radians" }, "macro.dbt_utils.generate_series": { "arguments": [], - "created_at": 1696458269.924184, + "created_at": 1719485736.678059, "depends_on": { "macros": [ "macro.dbt_utils.default__generate_series" @@ -9081,14 +11372,12 @@ "patch_path": null, "path": "macros/sql/generate_series.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.generate_series" }, "macro.dbt_utils.generate_surrogate_key": { "arguments": [], - "created_at": 1696458269.984725, + "created_at": 1719485736.7287118, "depends_on": { "macros": [ "macro.dbt_utils.default__generate_surrogate_key" @@ -9107,14 +11396,12 @@ "patch_path": null, "path": "macros/sql/generate_surrogate_key.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.generate_surrogate_key" }, "macro.dbt_utils.get_column_values": { "arguments": [], - "created_at": 1696458269.9700558, + "created_at": 1719485736.7158039, "depends_on": { "macros": [ "macro.dbt_utils.default__get_column_values" @@ -9133,14 +11420,12 @@ "patch_path": null, "path": "macros/sql/get_column_values.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.get_column_values" }, "macro.dbt_utils.get_filtered_columns_in_relation": { "arguments": [], - "created_at": 1696458269.977875, + "created_at": 1719485736.7211049, "depends_on": { "macros": [ "macro.dbt_utils.default__get_filtered_columns_in_relation" @@ -9159,14 +11444,12 @@ "patch_path": null, "path": "macros/sql/get_filtered_columns_in_relation.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.get_filtered_columns_in_relation" }, "macro.dbt_utils.get_intervals_between": { "arguments": [], - "created_at": 1696458269.913064, + "created_at": 1719485736.668108, "depends_on": { "macros": [ "macro.dbt_utils.default__get_intervals_between" @@ -9185,14 +11468,12 @@ "patch_path": null, "path": "macros/sql/date_spine.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.get_intervals_between" }, "macro.dbt_utils.get_powers_of_two": { "arguments": [], - "created_at": 1696458269.923075, + "created_at": 1719485736.677409, "depends_on": { "macros": [ "macro.dbt_utils.default__get_powers_of_two" @@ -9211,14 +11492,12 @@ "patch_path": null, "path": "macros/sql/generate_series.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.get_powers_of_two" }, "macro.dbt_utils.get_query_results_as_dict": { "arguments": [], - "created_at": 1696458269.982841, + "created_at": 1719485736.726866, "depends_on": { "macros": [ "macro.dbt_utils.default__get_query_results_as_dict" @@ -9237,14 +11516,12 @@ "patch_path": null, "path": "macros/sql/get_query_results_as_dict.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.get_query_results_as_dict" }, "macro.dbt_utils.get_relations_by_pattern": { "arguments": [], - "created_at": 1696458269.9195552, + "created_at": 1719485736.6725519, "depends_on": { "macros": [ "macro.dbt_utils.default__get_relations_by_pattern" @@ -9263,14 +11540,12 @@ "patch_path": null, "path": "macros/sql/get_relations_by_pattern.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.get_relations_by_pattern" }, "macro.dbt_utils.get_relations_by_prefix": { "arguments": [], - "created_at": 1696458269.926965, + "created_at": 1719485736.67938, "depends_on": { "macros": [ "macro.dbt_utils.default__get_relations_by_prefix" @@ -9289,14 +11564,12 @@ "patch_path": null, "path": "macros/sql/get_relations_by_prefix.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.get_relations_by_prefix" }, "macro.dbt_utils.get_single_value": { "arguments": [], - "created_at": 1696458269.9885209, + "created_at": 1719485736.732079, "depends_on": { "macros": [ "macro.dbt_utils.default__get_single_value" @@ -9315,14 +11588,12 @@ "patch_path": null, "path": "macros/sql/get_single_value.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.get_single_value" }, "macro.dbt_utils.get_table_types_sql": { "arguments": [], - "created_at": 1696458269.986836, + "created_at": 1719485736.730877, "depends_on": { "macros": [ "macro.dbt_utils.postgres__get_table_types_sql" @@ -9341,14 +11612,12 @@ "patch_path": null, "path": "macros/sql/get_table_types_sql.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.get_table_types_sql" }, "macro.dbt_utils.get_tables_by_pattern_sql": { "arguments": [], - "created_at": 1696458269.9652421, + "created_at": 1719485736.7118368, "depends_on": { "macros": [ "macro.dbt_utils.default__get_tables_by_pattern_sql" @@ -9367,14 +11636,12 @@ "patch_path": null, "path": "macros/sql/get_tables_by_pattern_sql.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.get_tables_by_pattern_sql" }, "macro.dbt_utils.get_tables_by_prefix_sql": { "arguments": [], - "created_at": 1696458269.929534, + "created_at": 1719485736.680733, "depends_on": { "macros": [ "macro.dbt_utils.default__get_tables_by_prefix_sql" @@ -9393,14 +11660,12 @@ "patch_path": null, "path": "macros/sql/get_tables_by_prefix_sql.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.get_tables_by_prefix_sql" }, "macro.dbt_utils.get_url_host": { "arguments": [], - "created_at": 1696458269.856265, + "created_at": 1719485736.6209002, "depends_on": { "macros": [ "macro.dbt_utils.default__get_url_host" @@ -9419,14 +11684,12 @@ "patch_path": null, "path": "macros/web/get_url_host.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.get_url_host" }, "macro.dbt_utils.get_url_parameter": { "arguments": [], - "created_at": 1696458269.860372, + "created_at": 1719485736.6233978, "depends_on": { "macros": [ "macro.dbt_utils.default__get_url_parameter" @@ -9445,14 +11708,12 @@ "patch_path": null, "path": "macros/web/get_url_parameter.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.get_url_parameter" }, "macro.dbt_utils.get_url_path": { "arguments": [], - "created_at": 1696458269.858241, + "created_at": 1719485736.62192, "depends_on": { "macros": [ "macro.dbt_utils.default__get_url_path" @@ -9471,14 +11732,12 @@ "patch_path": null, "path": "macros/web/get_url_path.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.get_url_path" }, "macro.dbt_utils.group_by": { "arguments": [], - "created_at": 1696458269.954188, + "created_at": 1719485736.700075, "depends_on": { "macros": [ "macro.dbt_utils.default__group_by" @@ -9497,14 +11756,12 @@ "patch_path": null, "path": "macros/sql/groupby.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.group_by" }, "macro.dbt_utils.haversine_distance": { "arguments": [], - "created_at": 1696458269.992314, + "created_at": 1719485736.7356942, "depends_on": { "macros": [ "macro.dbt_utils.default__haversine_distance" @@ -9523,14 +11780,12 @@ "patch_path": null, "path": "macros/sql/haversine_distance.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.haversine_distance" }, "macro.dbt_utils.log_info": { "arguments": [], - "created_at": 1696458269.90854, + "created_at": 1719485736.664978, "depends_on": { "macros": [ "macro.dbt_utils.default__log_info" @@ -9549,14 +11804,12 @@ "patch_path": null, "path": "macros/jinja_helpers/log_info.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.log_info" }, "macro.dbt_utils.nullcheck": { "arguments": [], - "created_at": 1696458269.961787, + "created_at": 1719485736.708089, "depends_on": { "macros": [ "macro.dbt_utils.default__nullcheck" @@ -9575,14 +11828,12 @@ "patch_path": null, "path": "macros/sql/nullcheck.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.nullcheck" }, "macro.dbt_utils.nullcheck_table": { "arguments": [], - "created_at": 1696458269.917547, + "created_at": 1719485736.671076, "depends_on": { "macros": [ "macro.dbt_utils.default__nullcheck_table" @@ -9601,14 +11852,12 @@ "patch_path": null, "path": "macros/sql/nullcheck_table.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.nullcheck_table" }, "macro.dbt_utils.pivot": { "arguments": [], - "created_at": 1696458269.975067, + "created_at": 1719485736.719448, "depends_on": { "macros": [ "macro.dbt_utils.default__pivot" @@ -9627,14 +11876,12 @@ "patch_path": null, "path": "macros/sql/pivot.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.pivot" }, "macro.dbt_utils.postgres__deduplicate": { "arguments": [], - "created_at": 1696458269.957185, + "created_at": 1719485736.703091, "depends_on": { "macros": [] }, @@ -9651,14 +11898,12 @@ "patch_path": null, "path": "macros/sql/deduplicate.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.postgres__deduplicate" }, "macro.dbt_utils.postgres__get_table_types_sql": { "arguments": [], - "created_at": 1696458269.9872892, + "created_at": 1719485736.731321, "depends_on": { "macros": [] }, @@ -9675,14 +11920,12 @@ "patch_path": null, "path": "macros/sql/get_table_types_sql.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.postgres__get_table_types_sql" }, "macro.dbt_utils.pretty_log_format": { "arguments": [], - "created_at": 1696458269.9058151, + "created_at": 1719485736.6625931, "depends_on": { "macros": [ "macro.dbt_utils.default__pretty_log_format" @@ -9701,14 +11944,12 @@ "patch_path": null, "path": "macros/jinja_helpers/pretty_log_format.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.pretty_log_format" }, "macro.dbt_utils.pretty_time": { "arguments": [], - "created_at": 1696458269.9076412, + "created_at": 1719485736.664063, "depends_on": { "macros": [ "macro.dbt_utils.default__pretty_time" @@ -9727,14 +11968,12 @@ "patch_path": null, "path": "macros/jinja_helpers/pretty_time.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.pretty_time" }, "macro.dbt_utils.redshift__deduplicate": { "arguments": [], - "created_at": 1696458269.9568481, + "created_at": 1719485736.7025979, "depends_on": { "macros": [ "macro.dbt_utils.default__deduplicate" @@ -9753,14 +11992,12 @@ "patch_path": null, "path": "macros/sql/deduplicate.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.redshift__deduplicate" }, "macro.dbt_utils.safe_add": { "arguments": [], - "created_at": 1696458269.95998, + "created_at": 1719485736.7061272, "depends_on": { "macros": [ "macro.dbt_utils.default__safe_add" @@ -9779,14 +12016,12 @@ "patch_path": null, "path": "macros/sql/safe_add.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.safe_add" }, "macro.dbt_utils.safe_divide": { "arguments": [], - "created_at": 1696458269.9414778, + "created_at": 1719485736.689705, "depends_on": { "macros": [ "macro.dbt_utils.default__safe_divide" @@ -9805,14 +12040,12 @@ "patch_path": null, "path": "macros/sql/safe_divide.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.safe_divide" }, "macro.dbt_utils.safe_subtract": { "arguments": [], - "created_at": 1696458269.915779, + "created_at": 1719485736.670065, "depends_on": { "macros": [ "macro.dbt_utils.default__safe_subtract" @@ -9831,14 +12064,12 @@ "patch_path": null, "path": "macros/sql/safe_subtract.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.safe_subtract" }, "macro.dbt_utils.slugify": { "arguments": [], - "created_at": 1696458269.9100301, + "created_at": 1719485736.6662788, "depends_on": { "macros": [] }, @@ -9855,14 +12086,12 @@ "patch_path": null, "path": "macros/jinja_helpers/slugify.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.slugify" }, "macro.dbt_utils.snowflake__deduplicate": { "arguments": [], - "created_at": 1696458269.95747, + "created_at": 1719485736.703385, "depends_on": { "macros": [] }, @@ -9879,14 +12108,12 @@ "patch_path": null, "path": "macros/sql/deduplicate.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.snowflake__deduplicate" }, "macro.dbt_utils.snowflake__width_bucket": { "arguments": [], - "created_at": 1696458269.9819782, + "created_at": 1719485736.72608, "depends_on": { "macros": [] }, @@ -9903,14 +12130,12 @@ "patch_path": null, "path": "macros/sql/width_bucket.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.snowflake__width_bucket" }, "macro.dbt_utils.star": { "arguments": [], - "created_at": 1696458269.9325912, + "created_at": 1719485736.6838112, "depends_on": { "macros": [ "macro.dbt_utils.default__star" @@ -9929,14 +12154,12 @@ "patch_path": null, "path": "macros/sql/star.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.star" }, "macro.dbt_utils.surrogate_key": { "arguments": [], - "created_at": 1696458269.9586718, + "created_at": 1719485736.704803, "depends_on": { "macros": [ "macro.dbt_utils.default__surrogate_key" @@ -9955,14 +12178,12 @@ "patch_path": null, "path": "macros/sql/surrogate_key.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.surrogate_key" }, "macro.dbt_utils.test_accepted_range": { "arguments": [], - "created_at": 1696458269.875644, + "created_at": 1719485736.6351202, "depends_on": { "macros": [ "macro.dbt_utils.default__test_accepted_range" @@ -9981,14 +12202,12 @@ "patch_path": null, "path": "macros/generic_tests/accepted_range.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.test_accepted_range" }, "macro.dbt_utils.test_at_least_one": { "arguments": [], - "created_at": 1696458269.8795462, + "created_at": 1719485736.637673, "depends_on": { "macros": [ "macro.dbt_utils.default__test_at_least_one" @@ -10007,14 +12226,12 @@ "patch_path": null, "path": "macros/generic_tests/at_least_one.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.test_at_least_one" }, "macro.dbt_utils.test_cardinality_equality": { "arguments": [], - "created_at": 1696458269.884375, + "created_at": 1719485736.64627, "depends_on": { "macros": [ "macro.dbt_utils.default__test_cardinality_equality" @@ -10033,14 +12250,12 @@ "patch_path": null, "path": "macros/generic_tests/cardinality_equality.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.test_cardinality_equality" }, "macro.dbt_utils.test_equal_rowcount": { "arguments": [], - "created_at": 1696458269.866045, + "created_at": 1719485736.6276648, "depends_on": { "macros": [ "macro.dbt_utils.default__test_equal_rowcount" @@ -10059,14 +12274,12 @@ "patch_path": null, "path": "macros/generic_tests/equal_rowcount.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.test_equal_rowcount" }, "macro.dbt_utils.test_equality": { "arguments": [], - "created_at": 1696458269.893547, + "created_at": 1719485736.6529899, "depends_on": { "macros": [ "macro.dbt_utils.default__test_equality" @@ -10085,14 +12298,12 @@ "patch_path": null, "path": "macros/generic_tests/equality.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.test_equality" }, "macro.dbt_utils.test_expression_is_true": { "arguments": [], - "created_at": 1696458269.885707, + "created_at": 1719485736.64721, "depends_on": { "macros": [ "macro.dbt_utils.default__test_expression_is_true" @@ -10111,14 +12322,12 @@ "patch_path": null, "path": "macros/generic_tests/expression_is_true.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.test_expression_is_true" }, "macro.dbt_utils.test_fewer_rows_than": { "arguments": [], - "created_at": 1696458269.862845, + "created_at": 1719485736.625401, "depends_on": { "macros": [ "macro.dbt_utils.default__test_fewer_rows_than" @@ -10137,14 +12346,12 @@ "patch_path": null, "path": "macros/generic_tests/fewer_rows_than.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.test_fewer_rows_than" }, "macro.dbt_utils.test_mutually_exclusive_ranges": { "arguments": [], - "created_at": 1696458269.902503, + "created_at": 1719485736.6603332, "depends_on": { "macros": [ "macro.dbt_utils.default__test_mutually_exclusive_ranges" @@ -10163,14 +12370,12 @@ "patch_path": null, "path": "macros/generic_tests/mutually_exclusive_ranges.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.test_mutually_exclusive_ranges" }, "macro.dbt_utils.test_not_accepted_values": { "arguments": [], - "created_at": 1696458269.877504, + "created_at": 1719485736.6365619, "depends_on": { "macros": [ "macro.dbt_utils.default__test_not_accepted_values" @@ -10189,14 +12394,12 @@ "patch_path": null, "path": "macros/generic_tests/not_accepted_values.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.test_not_accepted_values" }, "macro.dbt_utils.test_not_constant": { "arguments": [], - "created_at": 1696458269.873664, + "created_at": 1719485736.6339169, "depends_on": { "macros": [ "macro.dbt_utils.default__test_not_constant" @@ -10215,14 +12418,12 @@ "patch_path": null, "path": "macros/generic_tests/not_constant.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.test_not_constant" }, "macro.dbt_utils.test_not_empty_string": { "arguments": [], - "created_at": 1696458269.896234, + "created_at": 1719485736.655252, "depends_on": { "macros": [ "macro.dbt_utils.default__test_not_empty_string" @@ -10241,14 +12442,12 @@ "patch_path": null, "path": "macros/generic_tests/not_empty_string.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.test_not_empty_string" }, "macro.dbt_utils.test_not_null_proportion": { "arguments": [], - "created_at": 1696458269.88746, + "created_at": 1719485736.64849, "depends_on": { "macros": [ "macro.dbt_utils.default__test_not_null_proportion" @@ -10267,14 +12466,12 @@ "patch_path": null, "path": "macros/generic_tests/not_null_proportion.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.test_not_null_proportion" }, "macro.dbt_utils.test_recency": { "arguments": [], - "created_at": 1696458269.8711538, + "created_at": 1719485736.632009, "depends_on": { "macros": [ "macro.dbt_utils.default__test_recency" @@ -10293,14 +12490,12 @@ "patch_path": null, "path": "macros/generic_tests/recency.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.test_recency" }, "macro.dbt_utils.test_relationships_where": { "arguments": [], - "created_at": 1696458269.868972, + "created_at": 1719485736.6303911, "depends_on": { "macros": [ "macro.dbt_utils.default__test_relationships_where" @@ -10319,14 +12514,12 @@ "patch_path": null, "path": "macros/generic_tests/relationships_where.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.test_relationships_where" }, "macro.dbt_utils.test_sequential_values": { "arguments": [], - "created_at": 1696458269.890569, + "created_at": 1719485736.650635, "depends_on": { "macros": [ "macro.dbt_utils.default__test_sequential_values" @@ -10345,14 +12538,12 @@ "patch_path": null, "path": "macros/generic_tests/sequential_values.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.test_sequential_values" }, "macro.dbt_utils.test_unique_combination_of_columns": { "arguments": [], - "created_at": 1696458269.882238, + "created_at": 1719485736.642268, "depends_on": { "macros": [ "macro.dbt_utils.default__test_unique_combination_of_columns" @@ -10371,14 +12562,12 @@ "patch_path": null, "path": "macros/generic_tests/unique_combination_of_columns.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.test_unique_combination_of_columns" }, "macro.dbt_utils.union_relations": { "arguments": [], - "created_at": 1696458269.9474702, + "created_at": 1719485736.6939468, "depends_on": { "macros": [ "macro.dbt_utils.default__union_relations" @@ -10397,14 +12586,12 @@ "patch_path": null, "path": "macros/sql/union.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.union_relations" }, "macro.dbt_utils.unpivot": { "arguments": [], - "created_at": 1696458269.937675, + "created_at": 1719485736.68751, "depends_on": { "macros": [ "macro.dbt_utils.default__unpivot" @@ -10423,14 +12610,12 @@ "patch_path": null, "path": "macros/sql/unpivot.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.unpivot" }, "macro.dbt_utils.width_bucket": { "arguments": [], - "created_at": 1696458269.980886, + "created_at": 1719485736.724451, "depends_on": { "macros": [ "macro.dbt_utils.default__width_bucket" @@ -10449,14 +12634,12 @@ "patch_path": null, "path": "macros/sql/width_bucket.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop/dbt_packages/dbt_utils", "supported_languages": null, - "tags": [], "unique_id": "macro.dbt_utils.width_bucket" }, - "macro.jaffle_shop.drop_table": { + "macro.jaffle_shop.drop_table_by_name": { "arguments": [], - "created_at": 1696458269.549238, + "created_at": 1719485736.288547, "depends_on": { "macros": [ "macro.dbt.run_query" @@ -10467,42 +12650,43 @@ "node_color": null, "show": true }, - "macro_sql": "{%- macro drop_table(table_name) -%}\n {%- set drop_query -%}\n DROP TABLE IF EXISTS {{ target.schema }}.{{ table_name }} CASCADE\n {%- endset -%}\n {% do run_query(drop_query) %}\n{%- endmacro -%}", + "macro_sql": "{%- macro drop_table_by_name(table_name) -%}\n {%- set drop_query -%}\n DROP TABLE IF EXISTS {{ target.schema }}.{{ table_name }} CASCADE\n {%- endset -%}\n {% do run_query(drop_query) %}\n{%- endmacro -%}", "meta": {}, - "name": "drop_table", + "name": "drop_table_by_name", "original_file_path": "macros/drop_table.sql", "package_name": "jaffle_shop", "patch_path": null, "path": "macros/drop_table.sql", "resource_type": "macro", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop", "supported_languages": null, - "tags": [], - "unique_id": "macro.jaffle_shop.drop_table" + "unique_id": "macro.jaffle_shop.drop_table_by_name" } }, "metadata": { "adapter_type": "postgres", - "dbt_schema_version": "https://schemas.getdbt.com/dbt/manifest/v7.json", - "dbt_version": "1.3.1", + "dbt_schema_version": "https://schemas.getdbt.com/dbt/manifest/v12.json", + "dbt_version": "1.8.0", "env": {}, - "generated_at": "2023-10-04T22:24:29.500301Z", - "invocation_id": "fc9520c6-dfc1-4650-9483-1fd3751dfe42", + "generated_at": "2024-06-27T10:55:36.063508Z", + "invocation_id": "31cdaee6-885b-4bdf-b794-4065f9530edb", "project_id": "06e5b98c2db46f8a72cc4f66410e9b3b", + "project_name": "jaffle_shop", "send_anonymous_usage_stats": true, - "user_id": "43f90c72-db98-41b6-8fce-1337e4d59f98" + "user_id": "f5b1bc43-6cc6-4fd4-849c-18b31ffa1e2d" }, "metrics": {}, "nodes": { "model.jaffle_shop.customers": { + "access": "protected", "alias": "customers", "build_path": null, "checksum": { - "checksum": "455b90a31f418ae776213ad9932c7cb72d19a5269a8c722bd9f4e44957313ce8", + "checksum": "60bd72e33da43fff3a7e7609135c17cd4468bd22afec0735dd36018bfb5af30a", "name": "sha256" }, "columns": { "customer_id": { + "constraints": [], "data_type": null, "description": "This is a unique identifier for a customer", "meta": {}, @@ -10511,6 +12695,7 @@ "tags": [] }, "first_name": { + "constraints": [], "data_type": null, "description": "Customer's first name. PII.", "meta": {}, @@ -10519,6 +12704,7 @@ "tags": [] }, "first_order": { + "constraints": [], "data_type": null, "description": "Date (UTC) of a customer's first order", "meta": {}, @@ -10527,6 +12713,7 @@ "tags": [] }, "last_name": { + "constraints": [], "data_type": null, "description": "Customer's last name. PII.", "meta": {}, @@ -10535,6 +12722,7 @@ "tags": [] }, "most_recent_order": { + "constraints": [], "data_type": null, "description": "Date (UTC) of a customer's most recent order", "meta": {}, @@ -10543,6 +12731,7 @@ "tags": [] }, "number_of_orders": { + "constraints": [], "data_type": null, "description": "Count of the number of orders a customer has placed", "meta": {}, @@ -10551,6 +12740,7 @@ "tags": [] }, "total_order_amount": { + "constraints": [], "data_type": null, "description": "Total value (AUD) of a customer's orders", "meta": {}, @@ -10561,8 +12751,13 @@ }, "compiled_path": null, "config": { + "access": "protected", "alias": null, "column_types": {}, + "contract": { + "alias_types": true, + "enforced": false + }, "database": null, "docs": { "node_color": null, @@ -10571,9 +12766,11 @@ "enabled": true, "full_refresh": null, "grants": {}, + "group": null, "incremental_strategy": null, "materialized": "table", "meta": {}, + "on_configuration_change": "apply", "on_schema_change": "ignore", "packages": [], "persist_docs": {}, @@ -10584,9 +12781,14 @@ "tags": [], "unique_key": null }, - "created_at": 1696458270.331094, + "constraints": [], + "contract": { + "alias_types": true, + "checksum": null, + "enforced": false + }, + "created_at": 1719485737.5078778, "database": "postgres", - "deferred": false, "depends_on": { "macros": [], "nodes": [ @@ -10595,6 +12797,7 @@ "model.jaffle_shop.stg_payments" ] }, + "deprecation_date": null, "description": "This table has basic information about a customer, as well as some derived facts based on a customer's orders", "docs": { "node_color": null, @@ -10604,7 +12807,9 @@ "jaffle_shop", "customers" ], + "group": null, "language": "sql", + "latest_version": null, "meta": {}, "metrics": [], "name": "customers", @@ -10614,35 +12819,44 @@ "path": "customers.sql", "raw_code": "with customers as (\n\n select * from {{ ref('stg_customers') }}\n\n),\n\norders as (\n\n select * from {{ ref('stg_orders') }}\n\n),\n\npayments as (\n\n select * from {{ ref('stg_payments') }}\n\n),\n\ncustomer_orders as (\n\n select\n customer_id,\n\n min(order_date) as first_order,\n max(order_date) as most_recent_order,\n count(order_id) as number_of_orders\n from orders\n\n group by customer_id\n\n),\n\ncustomer_payments as (\n\n select\n orders.customer_id,\n sum(amount) as total_amount\n\n from payments\n\n left join orders on\n payments.order_id = orders.order_id\n\n group by orders.customer_id\n\n),\n\nfinal as (\n\n select\n customers.customer_id,\n customers.first_name,\n customers.last_name,\n customer_orders.first_order,\n customer_orders.most_recent_order,\n customer_orders.number_of_orders,\n customer_payments.total_amount as customer_lifetime_value\n\n from customers\n\n left join customer_orders\n on customers.customer_id = customer_orders.customer_id\n\n left join customer_payments\n on customers.customer_id = customer_payments.customer_id\n\n)\n\nselect * from final", "refs": [ - [ - "stg_customers" - ], - [ - "stg_orders" - ], - [ - "stg_payments" - ] + { + "name": "stg_customers", + "package": null, + "version": null + }, + { + "name": "stg_orders", + "package": null, + "version": null + }, + { + "name": "stg_payments", + "package": null, + "version": null + } ], + "relation_name": "\"postgres\".\"public\".\"customers\"", "resource_type": "model", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop", "schema": "public", "sources": [], "tags": [], "unique_id": "model.jaffle_shop.customers", "unrendered_config": { "materialized": "table" - } + }, + "version": null }, "model.jaffle_shop.orders": { + "access": "protected", "alias": "orders", "build_path": null, "checksum": { - "checksum": "53950235d8e29690d259e95ee49bda6a5b7911b44c739b738a646dc6014bcfcd", + "checksum": "27f8c79aad1cfd8411ab9c3d2ce8da1d787f7f05c58bbee1d247510dc426be0f", "name": "sha256" }, "columns": { "amount": { + "constraints": [], "data_type": null, "description": "Total amount (AUD) of the order", "meta": {}, @@ -10651,6 +12865,7 @@ "tags": [] }, "bank_transfer_amount": { + "constraints": [], "data_type": null, "description": "Amount of the order (AUD) paid for by bank transfer", "meta": {}, @@ -10659,6 +12874,7 @@ "tags": [] }, "coupon_amount": { + "constraints": [], "data_type": null, "description": "Amount of the order (AUD) paid for by coupon", "meta": {}, @@ -10667,6 +12883,7 @@ "tags": [] }, "credit_card_amount": { + "constraints": [], "data_type": null, "description": "Amount of the order (AUD) paid for by credit card", "meta": {}, @@ -10675,6 +12892,7 @@ "tags": [] }, "customer_id": { + "constraints": [], "data_type": null, "description": "Foreign key to the customers table", "meta": {}, @@ -10683,6 +12901,7 @@ "tags": [] }, "gift_card_amount": { + "constraints": [], "data_type": null, "description": "Amount of the order (AUD) paid for by gift card", "meta": {}, @@ -10691,6 +12910,7 @@ "tags": [] }, "order_date": { + "constraints": [], "data_type": null, "description": "Date (UTC) that the order was placed", "meta": {}, @@ -10699,6 +12919,7 @@ "tags": [] }, "order_id": { + "constraints": [], "data_type": null, "description": "This is a unique identifier for an order", "meta": {}, @@ -10707,6 +12928,7 @@ "tags": [] }, "status": { + "constraints": [], "data_type": null, "description": "Orders can be one of the following statuses:\n\n| status | description |\n|----------------|------------------------------------------------------------------------------------------------------------------------|\n| placed | The order has been placed but has not yet left the warehouse |\n| shipped | The order has ben shipped to the customer and is currently in transit |\n| completed | The order has been received by the customer |\n| return_pending | The customer has indicated that they would like to return the order, but it has not yet been received at the warehouse |\n| returned | The order has been returned by the customer and received at the warehouse |", "meta": {}, @@ -10717,8 +12939,13 @@ }, "compiled_path": null, "config": { + "access": "protected", "alias": null, "column_types": {}, + "contract": { + "alias_types": true, + "enforced": false + }, "database": null, "docs": { "node_color": null, @@ -10727,9 +12954,11 @@ "enabled": true, "full_refresh": null, "grants": {}, + "group": null, "incremental_strategy": null, "materialized": "table", "meta": {}, + "on_configuration_change": "apply", "on_schema_change": "ignore", "packages": [], "persist_docs": {}, @@ -10740,9 +12969,14 @@ "tags": [], "unique_key": null }, - "created_at": 1696458270.3345122, + "constraints": [], + "contract": { + "alias_types": true, + "checksum": null, + "enforced": false + }, + "created_at": 1719485737.511349, "database": "postgres", - "deferred": false, "depends_on": { "macros": [], "nodes": [ @@ -10750,6 +12984,7 @@ "model.jaffle_shop.stg_payments" ] }, + "deprecation_date": null, "description": "This table has basic information about orders, as well as some derived facts based on payments", "docs": { "node_color": null, @@ -10759,7 +12994,9 @@ "jaffle_shop", "orders" ], + "group": null, "language": "sql", + "latest_version": null, "meta": {}, "metrics": [], "name": "orders", @@ -10769,32 +13006,39 @@ "path": "orders.sql", "raw_code": "{% set payment_methods = ['credit_card', 'coupon', 'bank_transfer', 'gift_card'] %}\n\nwith orders as (\n\n select * from {{ ref('stg_orders') }}\n\n),\n\npayments as (\n\n select * from {{ ref('stg_payments') }}\n\n),\n\norder_payments as (\n\n select\n order_id,\n\n {% for payment_method in payment_methods -%}\n sum(case when payment_method = '{{ payment_method }}' then amount else 0 end) as {{ payment_method }}_amount,\n {% endfor -%}\n\n sum(amount) as total_amount\n\n from payments\n\n group by order_id\n\n),\n\nfinal as (\n\n select\n orders.order_id,\n orders.customer_id,\n orders.order_date,\n orders.status,\n\n {% for payment_method in payment_methods -%}\n\n order_payments.{{ payment_method }}_amount,\n\n {% endfor -%}\n\n order_payments.total_amount as amount\n\n from orders\n\n\n left join order_payments\n on orders.order_id = order_payments.order_id\n\n)\n\nselect * from final", "refs": [ - [ - "stg_orders" - ], - [ - "stg_payments" - ] + { + "name": "stg_orders", + "package": null, + "version": null + }, + { + "name": "stg_payments", + "package": null, + "version": null + } ], + "relation_name": "\"postgres\".\"public\".\"orders\"", "resource_type": "model", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop", "schema": "public", "sources": [], "tags": [], "unique_id": "model.jaffle_shop.orders", "unrendered_config": { "materialized": "table" - } + }, + "version": null }, "model.jaffle_shop.stg_customers": { + "access": "protected", "alias": "stg_customers", "build_path": null, "checksum": { - "checksum": "6f18a29204dad1de6dbb0c288144c4990742e0a1e065c3b2a67b5f98334c22ba", + "checksum": "80e3223cd54387e11fa16cd0f4cbe15f8ff74dcd9900b93856b9e39416178c9d", "name": "sha256" }, "columns": { "customer_id": { + "constraints": [], "data_type": null, "description": "", "meta": {}, @@ -10805,8 +13049,13 @@ }, "compiled_path": null, "config": { + "access": "protected", "alias": null, "column_types": {}, + "contract": { + "alias_types": true, + "enforced": false + }, "database": null, "docs": { "node_color": null, @@ -10815,9 +13064,11 @@ "enabled": true, "full_refresh": null, "grants": {}, + "group": null, "incremental_strategy": null, "materialized": "view", "meta": {}, + "on_configuration_change": "apply", "on_schema_change": "ignore", "packages": [], "persist_docs": {}, @@ -10828,15 +13079,21 @@ "tags": [], "unique_key": null }, - "created_at": 1696458270.372876, + "constraints": [], + "contract": { + "alias_types": true, + "checksum": null, + "enforced": false + }, + "created_at": 1719485737.629529, "database": "postgres", - "deferred": false, "depends_on": { "macros": [], "nodes": [ "seed.jaffle_shop.raw_customers" ] }, + "deprecation_date": null, "description": "", "docs": { "node_color": null, @@ -10847,7 +13104,9 @@ "staging", "stg_customers" ], + "group": null, "language": "sql", + "latest_version": null, "meta": {}, "metrics": [], "name": "stg_customers", @@ -10857,29 +13116,34 @@ "path": "staging/stg_customers.sql", "raw_code": "with source as (\n\n {#-\n Normally we would select from the table here, but we are using seeds to load\n our data in this project\n #}\n select * from {{ ref('raw_customers') }}\n\n),\n\nrenamed as (\n\n select\n id as customer_id,\n first_name,\n last_name\n\n from source\n\n)\n\nselect * from renamed", "refs": [ - [ - "raw_customers" - ] + { + "name": "raw_customers", + "package": null, + "version": null + } ], + "relation_name": "\"postgres\".\"public\".\"stg_customers\"", "resource_type": "model", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop", "schema": "public", "sources": [], "tags": [], "unique_id": "model.jaffle_shop.stg_customers", "unrendered_config": { "materialized": "view" - } + }, + "version": null }, "model.jaffle_shop.stg_orders": { + "access": "protected", "alias": "stg_orders", "build_path": null, "checksum": { - "checksum": "afffa9cbc57e5fd2cf5898ebf571d444a62c9d6d7929d8133d30567fb9a2ce97", + "checksum": "f4f881cb09d2c4162200fc331d7401df6d1abd4fed492554a7db70dede347108", "name": "sha256" }, "columns": { "order_id": { + "constraints": [], "data_type": null, "description": "", "meta": {}, @@ -10888,6 +13152,7 @@ "tags": [] }, "status": { + "constraints": [], "data_type": null, "description": "", "meta": {}, @@ -10898,8 +13163,13 @@ }, "compiled_path": null, "config": { + "access": "protected", "alias": null, "column_types": {}, + "contract": { + "alias_types": true, + "enforced": false + }, "database": null, "docs": { "node_color": null, @@ -10908,9 +13178,11 @@ "enabled": true, "full_refresh": null, "grants": {}, + "group": null, "incremental_strategy": null, "materialized": "view", "meta": {}, + "on_configuration_change": "apply", "on_schema_change": "ignore", "packages": [], "persist_docs": {}, @@ -10921,15 +13193,21 @@ "tags": [], "unique_key": null }, - "created_at": 1696458270.374181, + "constraints": [], + "contract": { + "alias_types": true, + "checksum": null, + "enforced": false + }, + "created_at": 1719485737.630356, "database": "postgres", - "deferred": false, "depends_on": { "macros": [], "nodes": [ "seed.jaffle_shop.raw_orders" ] }, + "deprecation_date": null, "description": "", "docs": { "node_color": null, @@ -10940,7 +13218,9 @@ "staging", "stg_orders" ], + "group": null, "language": "sql", + "latest_version": null, "meta": {}, "metrics": [], "name": "stg_orders", @@ -10950,29 +13230,34 @@ "path": "staging/stg_orders.sql", "raw_code": "with source as (\n\n {#-\n Normally we would select from the table here, but we are using seeds to load\n our data in this project\n #}\n select * from {{ ref('raw_orders') }}\n\n),\n\nrenamed as (\n\n select\n id as order_id,\n user_id as customer_id,\n order_date,\n status\n\n from source\n\n)\n\nselect * from renamed", "refs": [ - [ - "raw_orders" - ] + { + "name": "raw_orders", + "package": null, + "version": null + } ], + "relation_name": "\"postgres\".\"public\".\"stg_orders\"", "resource_type": "model", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop", "schema": "public", "sources": [], "tags": [], "unique_id": "model.jaffle_shop.stg_orders", "unrendered_config": { "materialized": "view" - } + }, + "version": null }, "model.jaffle_shop.stg_payments": { + "access": "protected", "alias": "stg_payments", "build_path": null, "checksum": { - "checksum": "a626f1ad5883c5391dc123be01f81491be5f496654d226d876651a0b8c036362", + "checksum": "30f346f66ef7bca4c8865a471086303720c3daab58870c805b6f45e92d19fd65", "name": "sha256" }, "columns": { "payment_id": { + "constraints": [], "data_type": null, "description": "", "meta": {}, @@ -10981,6 +13266,7 @@ "tags": [] }, "payment_method": { + "constraints": [], "data_type": null, "description": "", "meta": {}, @@ -10991,8 +13277,13 @@ }, "compiled_path": null, "config": { + "access": "protected", "alias": null, "column_types": {}, + "contract": { + "alias_types": true, + "enforced": false + }, "database": null, "docs": { "node_color": null, @@ -11001,9 +13292,11 @@ "enabled": true, "full_refresh": null, "grants": {}, + "group": null, "incremental_strategy": null, "materialized": "view", "meta": {}, + "on_configuration_change": "apply", "on_schema_change": "ignore", "packages": [], "persist_docs": {}, @@ -11014,15 +13307,21 @@ "tags": [], "unique_key": null }, - "created_at": 1696458270.375178, + "constraints": [], + "contract": { + "alias_types": true, + "checksum": null, + "enforced": false + }, + "created_at": 1719485737.6316102, "database": "postgres", - "deferred": false, "depends_on": { "macros": [], "nodes": [ "seed.jaffle_shop.raw_payments" ] }, + "deprecation_date": null, "description": "", "docs": { "node_color": null, @@ -11033,7 +13332,9 @@ "staging", "stg_payments" ], + "group": null, "language": "sql", + "latest_version": null, "meta": {}, "metrics": [], "name": "stg_payments", @@ -11043,33 +13344,40 @@ "path": "staging/stg_payments.sql", "raw_code": "with source as (\n\n {#-\n Normally we would select from the table here, but we are using seeds to load\n our data in this project\n #}\n select * from {{ ref('raw_payments') }}\n\n),\n\nrenamed as (\n\n select\n id as payment_id,\n order_id,\n payment_method,\n\n -- `amount` is currently stored in cents, so we convert it to dollars\n amount / 100 as amount\n\n from source\n\n)\n\nselect * from renamed", "refs": [ - [ - "raw_payments" - ] + { + "name": "raw_payments", + "package": null, + "version": null + } ], + "relation_name": "\"postgres\".\"public\".\"stg_payments\"", "resource_type": "model", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop", "schema": "public", "sources": [], "tags": [], "unique_id": "model.jaffle_shop.stg_payments", "unrendered_config": { "materialized": "view" - } + }, + "version": null }, "seed.jaffle_shop.raw_customers": { "alias": "raw_customers", "build_path": null, "checksum": { - "checksum": "24579b4b26098d43265376f3c50be8b10faf8e8fd95f5508074f10f76a12671d", + "checksum": "357d173dda65a741ad97d6683502286cc2655bb396ab5f4dfad12b8c39bd2a63", "name": "sha256" }, "columns": {}, - "compiled_path": null, "config": { "alias": null, "column_types": {}, + "contract": { + "alias_types": true, + "enforced": false + }, "database": null, + "delimiter": ",", "docs": { "node_color": null, "show": true @@ -11077,9 +13385,11 @@ "enabled": true, "full_refresh": null, "grants": {}, + "group": null, "incremental_strategy": null, "materialized": "seed", "meta": {}, + "on_configuration_change": "apply", "on_schema_change": "ignore", "packages": [], "persist_docs": {}, @@ -11091,12 +13401,10 @@ "tags": [], "unique_key": null }, - "created_at": 1696458270.302308, + "created_at": 1719485737.409044, "database": "postgres", - "deferred": false, "depends_on": { - "macros": [], - "nodes": [] + "macros": [] }, "description": "", "docs": { @@ -11107,20 +13415,18 @@ "jaffle_shop", "raw_customers" ], - "language": "sql", + "group": null, "meta": {}, - "metrics": [], "name": "raw_customers", "original_file_path": "seeds/raw_customers.csv", "package_name": "jaffle_shop", "patch_path": null, "path": "raw_customers.csv", "raw_code": "", - "refs": [], + "relation_name": "\"postgres\".\"public\".\"raw_customers\"", "resource_type": "seed", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop", + "root_path": "/Users/tati/Code/cosmos-clean/astronomer-cosmos/dev/dags/dbt/jaffle_shop", "schema": "public", - "sources": [], "tags": [], "unique_id": "seed.jaffle_shop.raw_customers", "unrendered_config": {} @@ -11129,15 +13435,19 @@ "alias": "raw_orders", "build_path": null, "checksum": { - "checksum": "c5f309d84ba32f2a39235c59f2d4f6c855aedba7e215847c957f1a5f2fa80d3e", + "checksum": "6228dde8e17b9621f35c13e272ec67d3ff55b55499433f47d303adf2be72c17f", "name": "sha256" }, "columns": {}, - "compiled_path": null, "config": { "alias": null, "column_types": {}, + "contract": { + "alias_types": true, + "enforced": false + }, "database": null, + "delimiter": ",", "docs": { "node_color": null, "show": true @@ -11145,9 +13455,11 @@ "enabled": true, "full_refresh": null, "grants": {}, + "group": null, "incremental_strategy": null, "materialized": "seed", "meta": {}, + "on_configuration_change": "apply", "on_schema_change": "ignore", "packages": [], "persist_docs": {}, @@ -11159,12 +13471,10 @@ "tags": [], "unique_key": null }, - "created_at": 1696458270.3040352, + "created_at": 1719485737.411613, "database": "postgres", - "deferred": false, "depends_on": { - "macros": [], - "nodes": [] + "macros": [] }, "description": "", "docs": { @@ -11175,20 +13485,18 @@ "jaffle_shop", "raw_orders" ], - "language": "sql", + "group": null, "meta": {}, - "metrics": [], "name": "raw_orders", "original_file_path": "seeds/raw_orders.csv", "package_name": "jaffle_shop", "patch_path": null, "path": "raw_orders.csv", "raw_code": "", - "refs": [], + "relation_name": "\"postgres\".\"public\".\"raw_orders\"", "resource_type": "seed", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop", + "root_path": "/Users/tati/Code/cosmos-clean/astronomer-cosmos/dev/dags/dbt/jaffle_shop", "schema": "public", - "sources": [], "tags": [], "unique_id": "seed.jaffle_shop.raw_orders", "unrendered_config": {} @@ -11197,15 +13505,19 @@ "alias": "raw_payments", "build_path": null, "checksum": { - "checksum": "03fd407f3135f84456431a923f22fc185a2154079e210c20b690e3ab11687d11", + "checksum": "6de0626a8db9c1750eefd1b2e17fac4c2a4b9f778eb50532d8b377b90de395e6", "name": "sha256" }, "columns": {}, - "compiled_path": null, "config": { "alias": null, "column_types": {}, + "contract": { + "alias_types": true, + "enforced": false + }, "database": null, + "delimiter": ",", "docs": { "node_color": null, "show": true @@ -11213,9 +13525,11 @@ "enabled": true, "full_refresh": null, "grants": {}, + "group": null, "incremental_strategy": null, "materialized": "seed", "meta": {}, + "on_configuration_change": "apply", "on_schema_change": "ignore", "packages": [], "persist_docs": {}, @@ -11227,12 +13541,10 @@ "tags": [], "unique_key": null }, - "created_at": 1696458270.30559, + "created_at": 1719485737.4141219, "database": "postgres", - "deferred": false, "depends_on": { - "macros": [], - "nodes": [] + "macros": [] }, "description": "", "docs": { @@ -11243,26 +13555,25 @@ "jaffle_shop", "raw_payments" ], - "language": "sql", + "group": null, "meta": {}, - "metrics": [], "name": "raw_payments", "original_file_path": "seeds/raw_payments.csv", "package_name": "jaffle_shop", "patch_path": null, "path": "raw_payments.csv", "raw_code": "", - "refs": [], + "relation_name": "\"postgres\".\"public\".\"raw_payments\"", "resource_type": "seed", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop", + "root_path": "/Users/tati/Code/cosmos-clean/astronomer-cosmos/dev/dags/dbt/jaffle_shop", "schema": "public", - "sources": [], "tags": [], "unique_id": "seed.jaffle_shop.raw_payments", "unrendered_config": {} }, "test.jaffle_shop.accepted_values_orders_status__placed__shipped__completed__return_pending__returned.be6b5b5ec3": { "alias": "accepted_values_orders_1ce6ab157c285f7cd2ac656013faf758", + "attached_node": "model.jaffle_shop.orders", "build_path": null, "checksum": { "checksum": "", @@ -11277,19 +13588,25 @@ "enabled": true, "error_if": "!= 0", "fail_calc": "count(*)", + "group": null, "limit": null, "materialized": "test", "meta": {}, "schema": "dbt_test__audit", "severity": "ERROR", "store_failures": null, + "store_failures_as": null, "tags": [], "warn_if": "!= 0", "where": null }, - "created_at": 1696458270.35778, + "contract": { + "alias_types": true, + "checksum": null, + "enforced": false + }, + "created_at": 1719485737.607407, "database": "postgres", - "deferred": false, "depends_on": { "macros": [ "macro.dbt.test_accepted_values", @@ -11309,6 +13626,7 @@ "jaffle_shop", "accepted_values_orders_status__placed__shipped__completed__return_pending__returned" ], + "group": null, "language": "sql", "meta": {}, "metrics": [], @@ -11319,12 +13637,14 @@ "path": "accepted_values_orders_1ce6ab157c285f7cd2ac656013faf758.sql", "raw_code": "{{ test_accepted_values(**_dbt_generic_test_kwargs) }}{{ config(alias=\"accepted_values_orders_1ce6ab157c285f7cd2ac656013faf758\") }}", "refs": [ - [ - "orders" - ] + { + "name": "orders", + "package": null, + "version": null + } ], + "relation_name": null, "resource_type": "test", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop", "schema": "public_dbt_test__audit", "sources": [], "tags": [], @@ -11350,6 +13670,7 @@ }, "test.jaffle_shop.accepted_values_stg_orders_status__placed__shipped__completed__return_pending__returned.080fb20aad": { "alias": "accepted_values_stg_orders_4f514bf94b77b7ea437830eec4421c58", + "attached_node": "model.jaffle_shop.stg_orders", "build_path": null, "checksum": { "checksum": "", @@ -11364,19 +13685,25 @@ "enabled": true, "error_if": "!= 0", "fail_calc": "count(*)", + "group": null, "limit": null, "materialized": "test", "meta": {}, "schema": "dbt_test__audit", "severity": "ERROR", "store_failures": null, + "store_failures_as": null, "tags": [], "warn_if": "!= 0", "where": null }, - "created_at": 1696458270.381258, + "contract": { + "alias_types": true, + "checksum": null, + "enforced": false + }, + "created_at": 1719485737.637758, "database": "postgres", - "deferred": false, "depends_on": { "macros": [ "macro.dbt.test_accepted_values", @@ -11397,6 +13724,7 @@ "staging", "accepted_values_stg_orders_status__placed__shipped__completed__return_pending__returned" ], + "group": null, "language": "sql", "meta": {}, "metrics": [], @@ -11407,12 +13735,14 @@ "path": "accepted_values_stg_orders_4f514bf94b77b7ea437830eec4421c58.sql", "raw_code": "{{ test_accepted_values(**_dbt_generic_test_kwargs) }}{{ config(alias=\"accepted_values_stg_orders_4f514bf94b77b7ea437830eec4421c58\") }}", "refs": [ - [ - "stg_orders" - ] + { + "name": "stg_orders", + "package": null, + "version": null + } ], + "relation_name": null, "resource_type": "test", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop", "schema": "public_dbt_test__audit", "sources": [], "tags": [], @@ -11438,6 +13768,7 @@ }, "test.jaffle_shop.accepted_values_stg_payments_payment_method__credit_card__coupon__bank_transfer__gift_card.3c3820f278": { "alias": "accepted_values_stg_payments_c7909fb19b1f0177c2bf99c7912f06ef", + "attached_node": "model.jaffle_shop.stg_payments", "build_path": null, "checksum": { "checksum": "", @@ -11452,19 +13783,25 @@ "enabled": true, "error_if": "!= 0", "fail_calc": "count(*)", + "group": null, "limit": null, "materialized": "test", "meta": {}, "schema": "dbt_test__audit", "severity": "ERROR", "store_failures": null, + "store_failures_as": null, "tags": [], "warn_if": "!= 0", "where": null }, - "created_at": 1696458270.3883939, + "contract": { + "alias_types": true, + "checksum": null, + "enforced": false + }, + "created_at": 1719485737.6424909, "database": "postgres", - "deferred": false, "depends_on": { "macros": [ "macro.dbt.test_accepted_values", @@ -11485,6 +13822,7 @@ "staging", "accepted_values_stg_payments_payment_method__credit_card__coupon__bank_transfer__gift_card" ], + "group": null, "language": "sql", "meta": {}, "metrics": [], @@ -11495,12 +13833,14 @@ "path": "accepted_values_stg_payments_c7909fb19b1f0177c2bf99c7912f06ef.sql", "raw_code": "{{ test_accepted_values(**_dbt_generic_test_kwargs) }}{{ config(alias=\"accepted_values_stg_payments_c7909fb19b1f0177c2bf99c7912f06ef\") }}", "refs": [ - [ - "stg_payments" - ] + { + "name": "stg_payments", + "package": null, + "version": null + } ], + "relation_name": null, "resource_type": "test", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop", "schema": "public_dbt_test__audit", "sources": [], "tags": [], @@ -11525,6 +13865,7 @@ }, "test.jaffle_shop.not_null_customers_customer_id.5c9bf9911d": { "alias": "not_null_customers_customer_id", + "attached_node": "model.jaffle_shop.customers", "build_path": null, "checksum": { "checksum": "", @@ -11539,19 +13880,25 @@ "enabled": true, "error_if": "!= 0", "fail_calc": "count(*)", + "group": null, "limit": null, "materialized": "test", "meta": {}, "schema": "dbt_test__audit", "severity": "ERROR", "store_failures": null, + "store_failures_as": null, "tags": [], "warn_if": "!= 0", "where": null }, - "created_at": 1696458270.342222, + "contract": { + "alias_types": true, + "checksum": null, + "enforced": false + }, + "created_at": 1719485737.5844128, "database": "postgres", - "deferred": false, "depends_on": { "macros": [ "macro.dbt.test_not_null" @@ -11570,6 +13917,7 @@ "jaffle_shop", "not_null_customers_customer_id" ], + "group": null, "language": "sql", "meta": {}, "metrics": [], @@ -11580,12 +13928,14 @@ "path": "not_null_customers_customer_id.sql", "raw_code": "{{ test_not_null(**_dbt_generic_test_kwargs) }}", "refs": [ - [ - "customers" - ] + { + "name": "customers", + "package": null, + "version": null + } ], + "relation_name": null, "resource_type": "test", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop", "schema": "public_dbt_test__audit", "sources": [], "tags": [], @@ -11602,6 +13952,7 @@ }, "test.jaffle_shop.not_null_orders_amount.106140f9fd": { "alias": "not_null_orders_amount", + "attached_node": "model.jaffle_shop.orders", "build_path": null, "checksum": { "checksum": "", @@ -11616,19 +13967,25 @@ "enabled": true, "error_if": "!= 0", "fail_calc": "count(*)", + "group": null, "limit": null, "materialized": "test", "meta": {}, "schema": "dbt_test__audit", "severity": "ERROR", "store_failures": null, + "store_failures_as": null, "tags": [], "warn_if": "!= 0", "where": null }, - "created_at": 1696458270.365846, + "contract": { + "alias_types": true, + "checksum": null, + "enforced": false + }, + "created_at": 1719485737.6199622, "database": "postgres", - "deferred": false, "depends_on": { "macros": [ "macro.dbt.test_not_null" @@ -11647,6 +14004,7 @@ "jaffle_shop", "not_null_orders_amount" ], + "group": null, "language": "sql", "meta": {}, "metrics": [], @@ -11657,12 +14015,14 @@ "path": "not_null_orders_amount.sql", "raw_code": "{{ test_not_null(**_dbt_generic_test_kwargs) }}", "refs": [ - [ - "orders" - ] + { + "name": "orders", + "package": null, + "version": null + } ], + "relation_name": null, "resource_type": "test", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop", "schema": "public_dbt_test__audit", "sources": [], "tags": [], @@ -11679,6 +14039,7 @@ }, "test.jaffle_shop.not_null_orders_bank_transfer_amount.7743500c49": { "alias": "not_null_orders_bank_transfer_amount", + "attached_node": "model.jaffle_shop.orders", "build_path": null, "checksum": { "checksum": "", @@ -11693,19 +14054,25 @@ "enabled": true, "error_if": "!= 0", "fail_calc": "count(*)", + "group": null, "limit": null, "materialized": "test", "meta": {}, "schema": "dbt_test__audit", "severity": "ERROR", "store_failures": null, + "store_failures_as": null, "tags": [], "warn_if": "!= 0", "where": null }, - "created_at": 1696458270.369942, + "contract": { + "alias_types": true, + "checksum": null, + "enforced": false + }, + "created_at": 1719485737.6258051, "database": "postgres", - "deferred": false, "depends_on": { "macros": [ "macro.dbt.test_not_null" @@ -11724,6 +14091,7 @@ "jaffle_shop", "not_null_orders_bank_transfer_amount" ], + "group": null, "language": "sql", "meta": {}, "metrics": [], @@ -11734,12 +14102,14 @@ "path": "not_null_orders_bank_transfer_amount.sql", "raw_code": "{{ test_not_null(**_dbt_generic_test_kwargs) }}", "refs": [ - [ - "orders" - ] + { + "name": "orders", + "package": null, + "version": null + } ], + "relation_name": null, "resource_type": "test", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop", "schema": "public_dbt_test__audit", "sources": [], "tags": [], @@ -11756,6 +14126,7 @@ }, "test.jaffle_shop.not_null_orders_coupon_amount.ab90c90625": { "alias": "not_null_orders_coupon_amount", + "attached_node": "model.jaffle_shop.orders", "build_path": null, "checksum": { "checksum": "", @@ -11770,19 +14141,25 @@ "enabled": true, "error_if": "!= 0", "fail_calc": "count(*)", + "group": null, "limit": null, "materialized": "test", "meta": {}, "schema": "dbt_test__audit", "severity": "ERROR", "store_failures": null, + "store_failures_as": null, "tags": [], "warn_if": "!= 0", "where": null }, - "created_at": 1696458270.368623, + "contract": { + "alias_types": true, + "checksum": null, + "enforced": false + }, + "created_at": 1719485737.624228, "database": "postgres", - "deferred": false, "depends_on": { "macros": [ "macro.dbt.test_not_null" @@ -11801,6 +14178,7 @@ "jaffle_shop", "not_null_orders_coupon_amount" ], + "group": null, "language": "sql", "meta": {}, "metrics": [], @@ -11811,12 +14189,14 @@ "path": "not_null_orders_coupon_amount.sql", "raw_code": "{{ test_not_null(**_dbt_generic_test_kwargs) }}", "refs": [ - [ - "orders" - ] + { + "name": "orders", + "package": null, + "version": null + } ], + "relation_name": null, "resource_type": "test", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop", "schema": "public_dbt_test__audit", "sources": [], "tags": [], @@ -11833,6 +14213,7 @@ }, "test.jaffle_shop.not_null_orders_credit_card_amount.d3ca593b59": { "alias": "not_null_orders_credit_card_amount", + "attached_node": "model.jaffle_shop.orders", "build_path": null, "checksum": { "checksum": "", @@ -11847,19 +14228,25 @@ "enabled": true, "error_if": "!= 0", "fail_calc": "count(*)", + "group": null, "limit": null, "materialized": "test", "meta": {}, "schema": "dbt_test__audit", "severity": "ERROR", "store_failures": null, + "store_failures_as": null, "tags": [], "warn_if": "!= 0", "where": null }, - "created_at": 1696458270.367114, + "contract": { + "alias_types": true, + "checksum": null, + "enforced": false + }, + "created_at": 1719485737.622118, "database": "postgres", - "deferred": false, "depends_on": { "macros": [ "macro.dbt.test_not_null" @@ -11878,6 +14265,7 @@ "jaffle_shop", "not_null_orders_credit_card_amount" ], + "group": null, "language": "sql", "meta": {}, "metrics": [], @@ -11888,12 +14276,14 @@ "path": "not_null_orders_credit_card_amount.sql", "raw_code": "{{ test_not_null(**_dbt_generic_test_kwargs) }}", "refs": [ - [ - "orders" - ] + { + "name": "orders", + "package": null, + "version": null + } ], + "relation_name": null, "resource_type": "test", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop", "schema": "public_dbt_test__audit", "sources": [], "tags": [], @@ -11910,6 +14300,7 @@ }, "test.jaffle_shop.not_null_orders_customer_id.c5f02694af": { "alias": "not_null_orders_customer_id", + "attached_node": "model.jaffle_shop.orders", "build_path": null, "checksum": { "checksum": "", @@ -11924,19 +14315,25 @@ "enabled": true, "error_if": "!= 0", "fail_calc": "count(*)", + "group": null, "limit": null, "materialized": "test", "meta": {}, "schema": "dbt_test__audit", "severity": "ERROR", "store_failures": null, + "store_failures_as": null, "tags": [], "warn_if": "!= 0", "where": null }, - "created_at": 1696458270.345952, + "contract": { + "alias_types": true, + "checksum": null, + "enforced": false + }, + "created_at": 1719485737.587272, "database": "postgres", - "deferred": false, "depends_on": { "macros": [ "macro.dbt.test_not_null" @@ -11955,6 +14352,7 @@ "jaffle_shop", "not_null_orders_customer_id" ], + "group": null, "language": "sql", "meta": {}, "metrics": [], @@ -11965,12 +14363,14 @@ "path": "not_null_orders_customer_id.sql", "raw_code": "{{ test_not_null(**_dbt_generic_test_kwargs) }}", "refs": [ - [ - "orders" - ] + { + "name": "orders", + "package": null, + "version": null + } ], + "relation_name": null, "resource_type": "test", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop", "schema": "public_dbt_test__audit", "sources": [], "tags": [], @@ -11987,6 +14387,7 @@ }, "test.jaffle_shop.not_null_orders_gift_card_amount.413a0d2d7a": { "alias": "not_null_orders_gift_card_amount", + "attached_node": "model.jaffle_shop.orders", "build_path": null, "checksum": { "checksum": "", @@ -12001,19 +14402,25 @@ "enabled": true, "error_if": "!= 0", "fail_calc": "count(*)", + "group": null, "limit": null, "materialized": "test", "meta": {}, "schema": "dbt_test__audit", "severity": "ERROR", "store_failures": null, + "store_failures_as": null, "tags": [], "warn_if": "!= 0", "where": null }, - "created_at": 1696458270.371219, + "contract": { + "alias_types": true, + "checksum": null, + "enforced": false + }, + "created_at": 1719485737.6277661, "database": "postgres", - "deferred": false, "depends_on": { "macros": [ "macro.dbt.test_not_null" @@ -12032,6 +14439,7 @@ "jaffle_shop", "not_null_orders_gift_card_amount" ], + "group": null, "language": "sql", "meta": {}, "metrics": [], @@ -12042,12 +14450,14 @@ "path": "not_null_orders_gift_card_amount.sql", "raw_code": "{{ test_not_null(**_dbt_generic_test_kwargs) }}", "refs": [ - [ - "orders" - ] + { + "name": "orders", + "package": null, + "version": null + } ], + "relation_name": null, "resource_type": "test", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop", "schema": "public_dbt_test__audit", "sources": [], "tags": [], @@ -12064,6 +14474,7 @@ }, "test.jaffle_shop.not_null_orders_order_id.cf6c17daed": { "alias": "not_null_orders_order_id", + "attached_node": "model.jaffle_shop.orders", "build_path": null, "checksum": { "checksum": "", @@ -12078,19 +14489,25 @@ "enabled": true, "error_if": "!= 0", "fail_calc": "count(*)", + "group": null, "limit": null, "materialized": "test", "meta": {}, "schema": "dbt_test__audit", "severity": "ERROR", "store_failures": null, + "store_failures_as": null, "tags": [], "warn_if": "!= 0", "where": null }, - "created_at": 1696458270.344596, + "contract": { + "alias_types": true, + "checksum": null, + "enforced": false + }, + "created_at": 1719485737.586345, "database": "postgres", - "deferred": false, "depends_on": { "macros": [ "macro.dbt.test_not_null" @@ -12109,6 +14526,7 @@ "jaffle_shop", "not_null_orders_order_id" ], + "group": null, "language": "sql", "meta": {}, "metrics": [], @@ -12119,12 +14537,14 @@ "path": "not_null_orders_order_id.sql", "raw_code": "{{ test_not_null(**_dbt_generic_test_kwargs) }}", "refs": [ - [ - "orders" - ] + { + "name": "orders", + "package": null, + "version": null + } ], + "relation_name": null, "resource_type": "test", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop", "schema": "public_dbt_test__audit", "sources": [], "tags": [], @@ -12141,6 +14561,7 @@ }, "test.jaffle_shop.not_null_stg_customers_customer_id.e2cfb1f9aa": { "alias": "not_null_stg_customers_customer_id", + "attached_node": "model.jaffle_shop.stg_customers", "build_path": null, "checksum": { "checksum": "", @@ -12155,19 +14576,25 @@ "enabled": true, "error_if": "!= 0", "fail_calc": "count(*)", + "group": null, "limit": null, "materialized": "test", "meta": {}, "schema": "dbt_test__audit", "severity": "ERROR", "store_failures": null, + "store_failures_as": null, "tags": [], "warn_if": "!= 0", "where": null }, - "created_at": 1696458270.3773491, + "contract": { + "alias_types": true, + "checksum": null, + "enforced": false + }, + "created_at": 1719485737.633585, "database": "postgres", - "deferred": false, "depends_on": { "macros": [ "macro.dbt.test_not_null" @@ -12187,6 +14614,7 @@ "staging", "not_null_stg_customers_customer_id" ], + "group": null, "language": "sql", "meta": {}, "metrics": [], @@ -12197,12 +14625,14 @@ "path": "not_null_stg_customers_customer_id.sql", "raw_code": "{{ test_not_null(**_dbt_generic_test_kwargs) }}", "refs": [ - [ - "stg_customers" - ] + { + "name": "stg_customers", + "package": null, + "version": null + } ], + "relation_name": null, "resource_type": "test", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop", "schema": "public_dbt_test__audit", "sources": [], "tags": [], @@ -12219,6 +14649,7 @@ }, "test.jaffle_shop.not_null_stg_orders_order_id.81cfe2fe64": { "alias": "not_null_stg_orders_order_id", + "attached_node": "model.jaffle_shop.stg_orders", "build_path": null, "checksum": { "checksum": "", @@ -12233,19 +14664,25 @@ "enabled": true, "error_if": "!= 0", "fail_calc": "count(*)", + "group": null, "limit": null, "materialized": "test", "meta": {}, "schema": "dbt_test__audit", "severity": "ERROR", "store_failures": null, + "store_failures_as": null, "tags": [], "warn_if": "!= 0", "where": null }, - "created_at": 1696458270.380042, + "contract": { + "alias_types": true, + "checksum": null, + "enforced": false + }, + "created_at": 1719485737.6366348, "database": "postgres", - "deferred": false, "depends_on": { "macros": [ "macro.dbt.test_not_null" @@ -12265,6 +14702,7 @@ "staging", "not_null_stg_orders_order_id" ], + "group": null, "language": "sql", "meta": {}, "metrics": [], @@ -12275,12 +14713,14 @@ "path": "not_null_stg_orders_order_id.sql", "raw_code": "{{ test_not_null(**_dbt_generic_test_kwargs) }}", "refs": [ - [ - "stg_orders" - ] + { + "name": "stg_orders", + "package": null, + "version": null + } ], + "relation_name": null, "resource_type": "test", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop", "schema": "public_dbt_test__audit", "sources": [], "tags": [], @@ -12297,6 +14737,7 @@ }, "test.jaffle_shop.not_null_stg_payments_payment_id.c19cc50075": { "alias": "not_null_stg_payments_payment_id", + "attached_node": "model.jaffle_shop.stg_payments", "build_path": null, "checksum": { "checksum": "", @@ -12311,19 +14752,25 @@ "enabled": true, "error_if": "!= 0", "fail_calc": "count(*)", + "group": null, "limit": null, "materialized": "test", "meta": {}, "schema": "dbt_test__audit", "severity": "ERROR", "store_failures": null, + "store_failures_as": null, "tags": [], "warn_if": "!= 0", "where": null }, - "created_at": 1696458270.387188, + "contract": { + "alias_types": true, + "checksum": null, + "enforced": false + }, + "created_at": 1719485737.641508, "database": "postgres", - "deferred": false, "depends_on": { "macros": [ "macro.dbt.test_not_null" @@ -12343,6 +14790,7 @@ "staging", "not_null_stg_payments_payment_id" ], + "group": null, "language": "sql", "meta": {}, "metrics": [], @@ -12353,12 +14801,14 @@ "path": "not_null_stg_payments_payment_id.sql", "raw_code": "{{ test_not_null(**_dbt_generic_test_kwargs) }}", "refs": [ - [ - "stg_payments" - ] + { + "name": "stg_payments", + "package": null, + "version": null + } ], + "relation_name": null, "resource_type": "test", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop", "schema": "public_dbt_test__audit", "sources": [], "tags": [], @@ -12375,6 +14825,7 @@ }, "test.jaffle_shop.relationships_orders_customer_id__customer_id__ref_customers_.c6ec7f58f2": { "alias": "relationships_orders_customer_id__customer_id__ref_customers_", + "attached_node": "model.jaffle_shop.orders", "build_path": null, "checksum": { "checksum": "", @@ -12389,19 +14840,25 @@ "enabled": true, "error_if": "!= 0", "fail_calc": "count(*)", + "group": null, "limit": null, "materialized": "test", "meta": {}, "schema": "dbt_test__audit", "severity": "ERROR", "store_failures": null, + "store_failures_as": null, "tags": [], "warn_if": "!= 0", "where": null }, - "created_at": 1696458270.347137, + "contract": { + "alias_types": true, + "checksum": null, + "enforced": false + }, + "created_at": 1719485737.590184, "database": "postgres", - "deferred": false, "depends_on": { "macros": [ "macro.dbt.test_relationships", @@ -12422,6 +14879,7 @@ "jaffle_shop", "relationships_orders_customer_id__customer_id__ref_customers_" ], + "group": null, "language": "sql", "meta": {}, "metrics": [], @@ -12432,15 +14890,19 @@ "path": "relationships_orders_customer_id__customer_id__ref_customers_.sql", "raw_code": "{{ test_relationships(**_dbt_generic_test_kwargs) }}", "refs": [ - [ - "customers" - ], - [ - "orders" - ] + { + "name": "customers", + "package": null, + "version": null + }, + { + "name": "orders", + "package": null, + "version": null + } ], + "relation_name": null, "resource_type": "test", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop", "schema": "public_dbt_test__audit", "sources": [], "tags": [], @@ -12459,6 +14921,7 @@ }, "test.jaffle_shop.unique_customers_customer_id.c5af1ff4b1": { "alias": "unique_customers_customer_id", + "attached_node": "model.jaffle_shop.customers", "build_path": null, "checksum": { "checksum": "", @@ -12473,19 +14936,25 @@ "enabled": true, "error_if": "!= 0", "fail_calc": "count(*)", + "group": null, "limit": null, "materialized": "test", "meta": {}, "schema": "dbt_test__audit", "severity": "ERROR", "store_failures": null, + "store_failures_as": null, "tags": [], "warn_if": "!= 0", "where": null }, - "created_at": 1696458270.340836, + "contract": { + "alias_types": true, + "checksum": null, + "enforced": false + }, + "created_at": 1719485737.583136, "database": "postgres", - "deferred": false, "depends_on": { "macros": [ "macro.dbt.test_unique" @@ -12504,6 +14973,7 @@ "jaffle_shop", "unique_customers_customer_id" ], + "group": null, "language": "sql", "meta": {}, "metrics": [], @@ -12514,12 +14984,14 @@ "path": "unique_customers_customer_id.sql", "raw_code": "{{ test_unique(**_dbt_generic_test_kwargs) }}", "refs": [ - [ - "customers" - ] + { + "name": "customers", + "package": null, + "version": null + } ], + "relation_name": null, "resource_type": "test", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop", "schema": "public_dbt_test__audit", "sources": [], "tags": [], @@ -12536,6 +15008,7 @@ }, "test.jaffle_shop.unique_orders_order_id.fed79b3a6e": { "alias": "unique_orders_order_id", + "attached_node": "model.jaffle_shop.orders", "build_path": null, "checksum": { "checksum": "", @@ -12550,19 +15023,25 @@ "enabled": true, "error_if": "!= 0", "fail_calc": "count(*)", + "group": null, "limit": null, "materialized": "test", "meta": {}, "schema": "dbt_test__audit", "severity": "ERROR", "store_failures": null, + "store_failures_as": null, "tags": [], "warn_if": "!= 0", "where": null }, - "created_at": 1696458270.343404, + "contract": { + "alias_types": true, + "checksum": null, + "enforced": false + }, + "created_at": 1719485737.5853949, "database": "postgres", - "deferred": false, "depends_on": { "macros": [ "macro.dbt.test_unique" @@ -12581,6 +15060,7 @@ "jaffle_shop", "unique_orders_order_id" ], + "group": null, "language": "sql", "meta": {}, "metrics": [], @@ -12591,12 +15071,14 @@ "path": "unique_orders_order_id.sql", "raw_code": "{{ test_unique(**_dbt_generic_test_kwargs) }}", "refs": [ - [ - "orders" - ] + { + "name": "orders", + "package": null, + "version": null + } ], + "relation_name": null, "resource_type": "test", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop", "schema": "public_dbt_test__audit", "sources": [], "tags": [], @@ -12613,6 +15095,7 @@ }, "test.jaffle_shop.unique_stg_customers_customer_id.c7614daada": { "alias": "unique_stg_customers_customer_id", + "attached_node": "model.jaffle_shop.stg_customers", "build_path": null, "checksum": { "checksum": "", @@ -12627,19 +15110,25 @@ "enabled": true, "error_if": "!= 0", "fail_calc": "count(*)", + "group": null, "limit": null, "materialized": "test", "meta": {}, "schema": "dbt_test__audit", "severity": "ERROR", "store_failures": null, + "store_failures_as": null, "tags": [], "warn_if": "!= 0", "where": null }, - "created_at": 1696458270.375885, + "contract": { + "alias_types": true, + "checksum": null, + "enforced": false + }, + "created_at": 1719485737.6324039, "database": "postgres", - "deferred": false, "depends_on": { "macros": [ "macro.dbt.test_unique" @@ -12659,6 +15148,7 @@ "staging", "unique_stg_customers_customer_id" ], + "group": null, "language": "sql", "meta": {}, "metrics": [], @@ -12669,12 +15159,14 @@ "path": "unique_stg_customers_customer_id.sql", "raw_code": "{{ test_unique(**_dbt_generic_test_kwargs) }}", "refs": [ - [ - "stg_customers" - ] + { + "name": "stg_customers", + "package": null, + "version": null + } ], + "relation_name": null, "resource_type": "test", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop", "schema": "public_dbt_test__audit", "sources": [], "tags": [], @@ -12691,6 +15183,7 @@ }, "test.jaffle_shop.unique_stg_orders_order_id.e3b841c71a": { "alias": "unique_stg_orders_order_id", + "attached_node": "model.jaffle_shop.stg_orders", "build_path": null, "checksum": { "checksum": "", @@ -12705,19 +15198,25 @@ "enabled": true, "error_if": "!= 0", "fail_calc": "count(*)", + "group": null, "limit": null, "materialized": "test", "meta": {}, "schema": "dbt_test__audit", "severity": "ERROR", "store_failures": null, + "store_failures_as": null, "tags": [], "warn_if": "!= 0", "where": null }, - "created_at": 1696458270.3786829, + "contract": { + "alias_types": true, + "checksum": null, + "enforced": false + }, + "created_at": 1719485737.635364, "database": "postgres", - "deferred": false, "depends_on": { "macros": [ "macro.dbt.test_unique" @@ -12737,6 +15236,7 @@ "staging", "unique_stg_orders_order_id" ], + "group": null, "language": "sql", "meta": {}, "metrics": [], @@ -12747,12 +15247,14 @@ "path": "unique_stg_orders_order_id.sql", "raw_code": "{{ test_unique(**_dbt_generic_test_kwargs) }}", "refs": [ - [ - "stg_orders" - ] + { + "name": "stg_orders", + "package": null, + "version": null + } ], + "relation_name": null, "resource_type": "test", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop", "schema": "public_dbt_test__audit", "sources": [], "tags": [], @@ -12769,6 +15271,7 @@ }, "test.jaffle_shop.unique_stg_payments_payment_id.3744510712": { "alias": "unique_stg_payments_payment_id", + "attached_node": "model.jaffle_shop.stg_payments", "build_path": null, "checksum": { "checksum": "", @@ -12783,19 +15286,25 @@ "enabled": true, "error_if": "!= 0", "fail_calc": "count(*)", + "group": null, "limit": null, "materialized": "test", "meta": {}, "schema": "dbt_test__audit", "severity": "ERROR", "store_failures": null, + "store_failures_as": null, "tags": [], "warn_if": "!= 0", "where": null }, - "created_at": 1696458270.385969, + "contract": { + "alias_types": true, + "checksum": null, + "enforced": false + }, + "created_at": 1719485737.640539, "database": "postgres", - "deferred": false, "depends_on": { "macros": [ "macro.dbt.test_unique" @@ -12815,6 +15324,7 @@ "staging", "unique_stg_payments_payment_id" ], + "group": null, "language": "sql", "meta": {}, "metrics": [], @@ -12825,12 +15335,14 @@ "path": "unique_stg_payments_payment_id.sql", "raw_code": "{{ test_unique(**_dbt_generic_test_kwargs) }}", "refs": [ - [ - "stg_payments" - ] + { + "name": "stg_payments", + "package": null, + "version": null + } ], + "relation_name": null, "resource_type": "test", - "root_path": "/Users/julian/Astronomer/astronomer-cosmos/dev/dags/dbt/jaffle_shop", "schema": "public_dbt_test__audit", "sources": [], "tags": [], @@ -12930,6 +15442,9 @@ "model.jaffle_shop.stg_payments" ] }, + "saved_queries": {}, "selectors": {}, - "sources": {} + "semantic_models": {}, + "sources": {}, + "unit_tests": {} } diff --git a/tests/dbt/test_graph.py b/tests/dbt/test_graph.py index 064d34a132..639f5d6981 100644 --- a/tests/dbt/test_graph.py +++ b/tests/dbt/test_graph.py @@ -1,4 +1,5 @@ import importlib +import logging import os import shutil import sys @@ -650,8 +651,6 @@ def test_load_via_dbt_ls_caching_partial_parsing( When using RenderConfig.enable_mock_profile=False and defining DbtGraph.cache_dir, Cosmos should leverage dbt partial parsing. """ - import logging - caplog.set_level(logging.DEBUG) is_profile_cache_enabled.return_value = enable_cache_profile @@ -681,6 +680,55 @@ def test_load_via_dbt_ls_caching_partial_parsing( assert not "Unable to do partial parsing" in caplog.text +@pytest.mark.integration +def test_load_via_dbt_ls_uses_partial_parse_when_cache_is_disabled( + tmp_dbt_project_dir, postgres_profile_config, caplog, tmp_path +): + """ + When using RenderConfig.enable_mock_profile=False and defining DbtGraph.cache_dir, + Cosmos should leverage dbt partial parsing. + """ + target_dir = tmp_dbt_project_dir / DBT_PROJECT_NAME / DBT_TARGET_DIR_NAME + target_dir.mkdir(parents=True, exist_ok=True) + + partial_parse_cache_dir = Path("/tmp/cosmos") + partial_parse_cache_dir.mkdir(parents=True, exist_ok=True) + + caplog.set_level(logging.DEBUG) + project_config = ProjectConfig(dbt_project_path=tmp_dbt_project_dir / DBT_PROJECT_NAME) + render_config = RenderConfig( + dbt_project_path=tmp_dbt_project_dir / DBT_PROJECT_NAME, dbt_deps=True, enable_mock_profile=False + ) + execution_config = ExecutionConfig(dbt_project_path=tmp_dbt_project_dir / DBT_PROJECT_NAME) + dbt_graph = DbtGraph( + project=project_config, + render_config=render_config, + execution_config=execution_config, + profile_config=postgres_profile_config, + cache_dir=partial_parse_cache_dir, + ) + # Creates partial parse file with the expected version + dbt_graph.load_via_dbt_ls_without_cache() # should not not raise exception + + caplog.clear() + + # We copy a valid partial parse to the project directory + shutil.copy(partial_parse_cache_dir / "target/partial_parse.msgpack", target_dir / "partial_parse.msgpack") + + # Run dbt ls without cache_dir, which disables cache: + dbt_graph = DbtGraph( + project=project_config, + render_config=render_config, + execution_config=execution_config, + profile_config=postgres_profile_config, + cache_dir="", # Cache is disabled + ) + # Should use the partial parse available in the original project folder + dbt_graph.load_via_dbt_ls_without_cache() # should not not raise exception + + assert not "Unable to do partial parsing" in caplog.text + + @pytest.mark.integration @patch("cosmos.dbt.graph.Popen") def test_load_via_dbt_ls_with_zero_returncode_and_non_empty_stderr( diff --git a/tests/test_cache.py b/tests/test_cache.py index 9edf10f903..738890bcb0 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -106,7 +106,7 @@ def test_update_partial_parse_cache(mock_get_partial_parse_path, mock_copyfile): mock_get_partial_parse_path.side_effect = lambda cache_dir: cache_dir / "partial_parse.yml" latest_partial_parse_filepath = Path("/path/to/latest_partial_parse.yml") - cache_dir = Path("/path/to/cache_directory") + cache_dir = Path("/tmp/path/to/cache_directory") # Expected paths cache_path = cache_dir / "partial_parse.yml" From 72749e714cd7b04b8d633ce4c93bf3fc8fd2dc62 Mon Sep 17 00:00:00 2001 From: Pankaj Koti Date: Fri, 28 Jun 2024 04:10:16 +0530 Subject: [PATCH 216/223] Release 1.5.0 (#1071) New Features * Speed up ``LoadMode.DBT_LS`` by caching dbt ls output in Airflow Variable by @tatiana in #1014 * Support to cache profiles created via ``ProfileMapping`` by @pankajastro in #1046 * Support for running dbt tasks in AWS EKS in #944 by @VolkerSchiewe * Add Clickhouse profile mapping by @roadan and @pankajastro in #353 and #1016 * Add node config to TaskInstance Context by @linchun3 in #1044 Bug fixes * Support partial parsing when cache is disabled by @tatiana in #1070 * Fix disk permission error in restricted env by @pankajastro in #1051 * Add CSP header to iframe contents by @dwreeves in #1055 * Stop attaching log adaptors to root logger to reduce logging costs by @glebkrapivin in #1047 Enhancements * Support ``static_index.html`` docs by @dwreeves in #999 * Support deep linking dbt docs via Airflow UI by @dwreeves in #1038 * Add ability to specify host/port for Snowflake connection by @whummer in #1063 Docs * Fix rendering for env ``enable_cache_dbt_ls`` by @pankajastro in #1069 Others * Update documentation for DbtDocs generator by @arjunanan6 in #1043 * Use uv in CI by @dwreeves in #1013 * Cache hatch folder in the CI by @tatiana in #1056 * Change example DAGs to use ``example_conn`` as opposed to ``airflow_db`` by @tatiana in #1054 * Mark plugin integration tests as integration by @tatiana in #1057 * Ensure compliance with linting rule D300 by using triple quotes for docstrings by @pankajastro in #1049 * Pre-commit hook updates in #1039, #1050, #1064 * Remove duplicates in changelog by @jedcunningham in #1068 --- CHANGELOG.rst | 11 +++++++++-- cosmos/__init__.py | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a760e1d95d..66e9af5eaa 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,18 +1,20 @@ Changelog ========= -1.5.0a9 (2024-06-25) --------------------- +1.5.0 (2024-06-27) +------------------ New Features * Speed up ``LoadMode.DBT_LS`` by caching dbt ls output in Airflow Variable by @tatiana in #1014 +* Support to cache profiles created via ``ProfileMapping`` by @pankajastro in #1046 * Support for running dbt tasks in AWS EKS in #944 by @VolkerSchiewe * Add Clickhouse profile mapping by @roadan and @pankajastro in #353 and #1016 * Add node config to TaskInstance Context by @linchun3 in #1044 Bug fixes +* Support partial parsing when cache is disabled by @tatiana in #1070 * Fix disk permission error in restricted env by @pankajastro in #1051 * Add CSP header to iframe contents by @dwreeves in #1055 * Stop attaching log adaptors to root logger to reduce logging costs by @glebkrapivin in #1047 @@ -23,6 +25,10 @@ Enhancements * Support deep linking dbt docs via Airflow UI by @dwreeves in #1038 * Add ability to specify host/port for Snowflake connection by @whummer in #1063 +Docs + +* Fix rendering for env ``enable_cache_dbt_ls`` by @pankajastro in #1069 + Others * Update documentation for DbtDocs generator by @arjunanan6 in #1043 @@ -32,6 +38,7 @@ Others * Mark plugin integration tests as integration by @tatiana in #1057 * Ensure compliance with linting rule D300 by using triple quotes for docstrings by @pankajastro in #1049 * Pre-commit hook updates in #1039, #1050, #1064 +* Remove duplicates in changelog by @jedcunningham in #1068 1.4.3 (2024-06-07) diff --git a/cosmos/__init__.py b/cosmos/__init__.py index ee860228ac..cfaa5def3e 100644 --- a/cosmos/__init__.py +++ b/cosmos/__init__.py @@ -5,7 +5,7 @@ Contains dags, task groups, and operators. """ -__version__ = "1.5.0a9" +__version__ = "1.5.0" from cosmos.airflow.dag import DbtDag From b972685d52df28b832cd6ec5aa4bd18298de9d00 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 2 Jul 2024 14:33:55 +0100 Subject: [PATCH 217/223] =?UTF-8?q?=E2=AC=86=20[pre-commit.ci]=20pre-commi?= =?UTF-8?q?t=20autoupdate=20(#1074)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.4.10 → v0.5.0](https://github.com/astral-sh/ruff-pre-commit/compare/v0.4.10...v0.5.0) - [github.com/asottile/blacken-docs: 1.16.0 → 1.18.0](https://github.com/asottile/blacken-docs/compare/1.16.0...1.18.0) - [github.com/pre-commit/mirrors-mypy: v1.10.0 → v1.10.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.10.0...v1.10.1) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a95bc2bdf4..53d0b9c701 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -54,7 +54,7 @@ repos: - --py37-plus - --keep-runtime-typing - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.4.10 + rev: v0.5.0 hooks: - id: ruff args: @@ -65,13 +65,13 @@ repos: - id: black args: ["--config", "./pyproject.toml"] - repo: https://github.com/asottile/blacken-docs - rev: 1.16.0 + rev: 1.18.0 hooks: - id: blacken-docs alias: black additional_dependencies: [black>=22.10.0] - repo: https://github.com/pre-commit/mirrors-mypy - rev: "v1.10.0" + rev: "v1.10.1" hooks: - id: mypy From 25ffd29d161241cab6acd5b787591d4c4f53b018 Mon Sep 17 00:00:00 2001 From: Satish Chinthanippu Date: Wed, 3 Jul 2024 14:27:03 +0530 Subject: [PATCH 218/223] Added new profile mapping configuration for Teradata (#1077) Teradata has [Provider](https://airflow.apache.org/docs/apache-airflow-providers-teradata/stable/index.html) in airflow and [adapter](https://github.com/Teradata/dbt-teradata) in dbt. The cosmos library doesn't have profile configuration with mapping support. This PR address this issue. Closes: #1053 --- cosmos/profiles/__init__.py | 3 + cosmos/profiles/teradata/__init__.py | 0 cosmos/profiles/teradata/user_pass.py | 51 +++++ pyproject.toml | 2 + tests/profiles/teradata/__init__.py | 0 .../teradata/test_teradata_user_pass.py | 176 ++++++++++++++++++ 6 files changed, 232 insertions(+) create mode 100644 cosmos/profiles/teradata/__init__.py create mode 100644 cosmos/profiles/teradata/user_pass.py create mode 100644 tests/profiles/teradata/__init__.py create mode 100644 tests/profiles/teradata/test_teradata_user_pass.py diff --git a/cosmos/profiles/__init__.py b/cosmos/profiles/__init__.py index b182bacf77..392b4f78b8 100644 --- a/cosmos/profiles/__init__.py +++ b/cosmos/profiles/__init__.py @@ -19,6 +19,7 @@ from .snowflake.user_pass import SnowflakeUserPasswordProfileMapping from .snowflake.user_privatekey import SnowflakePrivateKeyPemProfileMapping from .spark.thrift import SparkThriftProfileMapping +from .teradata.user_pass import TeradataUserPasswordProfileMapping from .trino.certificate import TrinoCertificateProfileMapping from .trino.jwt import TrinoJWTProfileMapping from .trino.ldap import TrinoLDAPProfileMapping @@ -39,6 +40,7 @@ SnowflakePrivateKeyPemProfileMapping, SparkThriftProfileMapping, ExasolUserPasswordProfileMapping, + TeradataUserPasswordProfileMapping, TrinoLDAPProfileMapping, TrinoCertificateProfileMapping, TrinoJWTProfileMapping, @@ -79,6 +81,7 @@ def get_automatic_profile_mapping( "SnowflakeEncryptedPrivateKeyFilePemProfileMapping", "SparkThriftProfileMapping", "ExasolUserPasswordProfileMapping", + "TeradataUserPasswordProfileMapping", "TrinoLDAPProfileMapping", "TrinoCertificateProfileMapping", "TrinoJWTProfileMapping", diff --git a/cosmos/profiles/teradata/__init__.py b/cosmos/profiles/teradata/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cosmos/profiles/teradata/user_pass.py b/cosmos/profiles/teradata/user_pass.py new file mode 100644 index 0000000000..059e4a9f09 --- /dev/null +++ b/cosmos/profiles/teradata/user_pass.py @@ -0,0 +1,51 @@ +"""Maps Airflow Snowflake connections to dbt profiles if they use a user/password.""" + +from __future__ import annotations + +from typing import Any + +from ..base import BaseProfileMapping + + +class TeradataUserPasswordProfileMapping(BaseProfileMapping): + """ + Maps Airflow Teradata connections using user + password authentication to dbt profiles. + https://docs.getdbt.com/docs/core/connect-data-platform/teradata-setup + https://airflow.apache.org/docs/apache-airflow-providers-teradata/stable/connections/teradata.html + """ + + airflow_connection_type: str = "teradata" + dbt_profile_type: str = "teradata" + is_community = True + + required_fields = [ + "host", + "user", + "password", + ] + secret_fields = [ + "password", + ] + airflow_param_mapping = { + "host": "host", + "user": "login", + "password": "password", + "schema": "schema", + "tmode": "extra.tmode", + } + + @property + def profile(self) -> dict[str, Any]: + """Gets profile. The password is stored in an environment variable.""" + profile = { + **self.mapped_params, + **self.profile_args, + # password should always get set as env var + "password": self.get_env_var_format("password"), + } + # schema is not mandatory in teradata. In teradata user itself a database so if schema is not mentioned + # in both airflow connection and profile_args then treating user as schema. + if "schema" not in self.profile_args and self.mapped_params.get("schema") is None: + profile["schema"] = profile["user"] + + return self.filter_null(profile) diff --git a/pyproject.toml b/pyproject.toml index 6c518613b6..5cbc93a988 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,7 @@ dbt-all = [ "dbt-redshift", "dbt-snowflake", "dbt-spark", + "dbt-teradata", "dbt-vertica", ] dbt-athena = ["dbt-athena-community", "apache-airflow-providers-amazon>=8.0.0"] @@ -62,6 +63,7 @@ dbt-postgres = ["dbt-postgres"] dbt-redshift = ["dbt-redshift"] dbt-snowflake = ["dbt-snowflake"] dbt-spark = ["dbt-spark"] +dbt-teradata = ["dbt-teradata"] dbt-vertica = ["dbt-vertica<=1.5.4"] openlineage = ["openlineage-integration-common!=1.15.0", "openlineage-airflow"] all = ["astronomer-cosmos[dbt-all]", "astronomer-cosmos[openlineage]"] diff --git a/tests/profiles/teradata/__init__.py b/tests/profiles/teradata/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/profiles/teradata/test_teradata_user_pass.py b/tests/profiles/teradata/test_teradata_user_pass.py new file mode 100644 index 0000000000..ff28977fe2 --- /dev/null +++ b/tests/profiles/teradata/test_teradata_user_pass.py @@ -0,0 +1,176 @@ +"""Tests for the postgres profile.""" + +from unittest.mock import patch + +import pytest +from airflow.models.connection import Connection + +from cosmos.profiles import get_automatic_profile_mapping +from cosmos.profiles.teradata.user_pass import TeradataUserPasswordProfileMapping + + +@pytest.fixture() +def mock_teradata_conn(): # type: ignore + """ + Sets the connection as an environment variable. + """ + conn = Connection( + conn_id="my_teradata_connection", + conn_type="teradata", + host="my_host", + login="my_user", + password="my_password", + ) + + with patch("airflow.hooks.base.BaseHook.get_connection", return_value=conn): + yield conn + + +@pytest.fixture() +def mock_teradata_conn_custom_tmode(): # type: ignore + """ + Sets the connection as an environment variable. + """ + conn = Connection( + conn_id="my_teradata_connection", + conn_type="teradata", + host="my_host", + login="my_user", + password="my_password", + schema="my_database", + extra='{"tmode": "TERA"}', + ) + + with patch("airflow.hooks.base.BaseHook.get_connection", return_value=conn): + yield conn + + +def test_connection_claiming() -> None: + """ + Tests that the teradata profile mapping claims the correct connection type. + """ + # should only claim when: + # - conn_type == teradata + # and the following exist: + # - host + # - user + # - password + potential_values: dict[str, str] = { + "conn_type": "teradata", + "host": "my_host", + "login": "my_user", + "password": "my_password", + } + + # if we're missing any of the values, it shouldn't claim + for key in potential_values: + values = potential_values.copy() + del values[key] + conn = Connection(**values) # type: ignore + + with patch("airflow.hooks.base.BaseHook.get_connection", return_value=conn): + profile_mapping = TeradataUserPasswordProfileMapping(conn) + assert not profile_mapping.can_claim_connection() + + # Even there is no schema, making user as schema as user itself schema in teradata + conn = Connection(**{k: v for k, v in potential_values.items() if k != "schema"}) + with patch("airflow.hooks.base.BaseHook.get_connection", return_value=conn): + profile_mapping = TeradataUserPasswordProfileMapping(conn, {"schema": None}) + assert profile_mapping.can_claim_connection() + # if we have them all, it should claim + conn = Connection(**potential_values) # type: ignore + with patch("airflow.hooks.base.BaseHook.get_connection", return_value=conn): + profile_mapping = TeradataUserPasswordProfileMapping(conn, {"schema": "my_schema"}) + assert profile_mapping.can_claim_connection() + + +def test_profile_mapping_selected( + mock_teradata_conn: Connection, +) -> None: + """ + Tests that the correct profile mapping is selected. + """ + profile_mapping = get_automatic_profile_mapping( + mock_teradata_conn.conn_id, + ) + assert isinstance(profile_mapping, TeradataUserPasswordProfileMapping) + + +def test_profile_mapping_keeps_port(mock_teradata_conn: Connection) -> None: + # port is not handled in airflow connection so adding it as profile_args + profile = TeradataUserPasswordProfileMapping(mock_teradata_conn.conn_id, profile_args={"port": 1025}) + assert profile.profile["port"] == 1025 + + +def test_profile_mapping_keeps_custom_tmode(mock_teradata_conn_custom_tmode: Connection) -> None: + profile = TeradataUserPasswordProfileMapping(mock_teradata_conn_custom_tmode.conn_id) + assert profile.profile["tmode"] == "TERA" + + +def test_profile_args( + mock_teradata_conn: Connection, +) -> None: + """ + Tests that the profile values get set correctly. + """ + profile_mapping = get_automatic_profile_mapping( + mock_teradata_conn.conn_id, + profile_args={"schema": "my_database"}, + ) + assert profile_mapping.profile_args == { + "schema": "my_database", + } + + assert profile_mapping.profile == { + "type": mock_teradata_conn.conn_type, + "host": mock_teradata_conn.host, + "user": mock_teradata_conn.login, + "password": "{{ env_var('COSMOS_CONN_TERADATA_PASSWORD') }}", + "schema": "my_database", + } + + +def test_profile_args_overrides( + mock_teradata_conn: Connection, +) -> None: + """ + Tests that you can override the profile values. + """ + profile_mapping = get_automatic_profile_mapping( + mock_teradata_conn.conn_id, + profile_args={"schema": "my_schema"}, + ) + assert profile_mapping.profile_args == { + "schema": "my_schema", + } + + assert profile_mapping.profile == { + "type": mock_teradata_conn.conn_type, + "host": mock_teradata_conn.host, + "user": mock_teradata_conn.login, + "password": "{{ env_var('COSMOS_CONN_TERADATA_PASSWORD') }}", + "schema": "my_schema", + } + + +def test_profile_env_vars( + mock_teradata_conn: Connection, +) -> None: + """ + Tests that the environment variables get set correctly. + """ + profile_mapping = get_automatic_profile_mapping( + mock_teradata_conn.conn_id, + profile_args={"schema": "my_schema"}, + ) + assert profile_mapping.env_vars == { + "COSMOS_CONN_TERADATA_PASSWORD": mock_teradata_conn.password, + } + + +def test_mock_profile() -> None: + """ + Tests that the mock profile port value get set correctly. + """ + profile = TeradataUserPasswordProfileMapping("mock_conn_id") + assert profile.mock_profile.get("host") == "mock_value" From 50b8fe19b98cf6975bcdb4b9d0b5d95089a0347e Mon Sep 17 00:00:00 2001 From: Piotr Kubicki Date: Mon, 8 Jul 2024 09:28:34 +0100 Subject: [PATCH 219/223] Fix getting temporary AWS credentials with assume_role (#1081) When Airflow is getting temporary AWS credentials by assuming role with `role_arn` as only `Connection` parameter, this cause task to fail due to missing credentials. This is due to the latest changes related to profile caching. The `env_vars` are accessed before `profile` which, in this case, means required values are not populated yet. --- cosmos/config.py | 8 ++---- tests/dbt/test_graph.py | 31 ++++++++++++++++++++ tests/test_config.py | 64 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 96 insertions(+), 7 deletions(-) diff --git a/cosmos/config.py b/cosmos/config.py index 948d009f7b..e1e5d56f9a 100644 --- a/cosmos/config.py +++ b/cosmos/config.py @@ -287,21 +287,17 @@ def ensure_profile( if self.profiles_yml_filepath: logger.info("Using user-supplied profiles.yml at %s", self.profiles_yml_filepath) yield Path(self.profiles_yml_filepath), {} - elif self.profile_mapping: - if use_mock_values: - env_vars = {} - else: - env_vars = self.profile_mapping.env_vars - if is_profile_cache_enabled(): logger.info("Profile caching is enable.") cached_profile_path = self._get_profile_path(use_mock_values) + env_vars = {} if use_mock_values else self.profile_mapping.env_vars yield cached_profile_path, env_vars else: profile_contents = self.profile_mapping.get_profile_file_contents( profile_name=self.profile_name, target_name=self.target_name, use_mock_values=use_mock_values ) + env_vars = {} if use_mock_values else self.profile_mapping.env_vars if desired_profile_path: logger.info( diff --git a/tests/dbt/test_graph.py b/tests/dbt/test_graph.py index 639f5d6981..05aad822ad 100644 --- a/tests/dbt/test_graph.py +++ b/tests/dbt/test_graph.py @@ -1071,6 +1071,37 @@ def test_load_via_dbt_ls_project_config_env_vars( assert mock_popen.call_args.kwargs["env"]["MY_ENV_VAR"] == "my_value" +@patch("cosmos.dbt.graph.DbtGraph.should_use_dbt_ls_cache", return_value=False) +@patch("cosmos.config.is_profile_cache_enabled", return_value=False) +@patch("cosmos.dbt.graph.Popen") +@patch("cosmos.dbt.graph.DbtGraph.update_node_dependency") +@patch("cosmos.config.RenderConfig.validate_dbt_command") +def test_profile_created_correctly_with_profile_mapping( + mock_validate, + mock_update_nodes, + mock_popen, + mock_enable_profile_cache, + mock_enable_cache, + tmp_dbt_project_dir, + postgres_profile_config, +): + """Tests that the temporary profile is created without errors.""" + mock_popen().communicate.return_value = ("", "") + mock_popen().returncode = 0 + project_config = ProjectConfig(env_vars={}) + render_config = RenderConfig(dbt_project_path=tmp_dbt_project_dir / DBT_PROJECT_NAME) + profile_config = postgres_profile_config + execution_config = ExecutionConfig(dbt_project_path=tmp_dbt_project_dir / DBT_PROJECT_NAME) + dbt_graph = DbtGraph( + project=project_config, + render_config=render_config, + execution_config=execution_config, + profile_config=profile_config, + ) + + assert dbt_graph.load_via_dbt_ls() == None + + @patch("cosmos.dbt.graph.DbtGraph.should_use_dbt_ls_cache", return_value=False) @patch("cosmos.dbt.graph.Popen") @patch("cosmos.dbt.graph.DbtGraph.update_node_dependency") diff --git a/tests/test_config.py b/tests/test_config.py index d7dc24cbe6..5bf0f69b56 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,12 +1,13 @@ from contextlib import nullcontext as does_not_raise from pathlib import Path -from unittest.mock import patch +from unittest.mock import Mock, PropertyMock, call, patch import pytest from cosmos.config import CosmosConfigException, ExecutionConfig, ProfileConfig, ProjectConfig, RenderConfig from cosmos.constants import ExecutionMode, InvocationMode from cosmos.exceptions import CosmosValueError +from cosmos.profiles.athena.access_key import AthenaAccessKeyProfileMapping from cosmos.profiles.postgres.user_pass import PostgresUserPasswordProfileMapping DBT_PROJECTS_ROOT_DIR = Path(__file__).parent / "sample/" @@ -142,6 +143,67 @@ def test_profile_config_validate_profiles_yml(): assert err_info.value.args[0] == "The file /tmp/no-exists does not exist." +@patch("cosmos.config.is_profile_cache_enabled", return_value=False) +@patch("cosmos.profiles.athena.access_key.AthenaAccessKeyProfileMapping.env_vars", new_callable=PropertyMock) +@patch("cosmos.profiles.athena.access_key.AthenaAccessKeyProfileMapping.get_profile_file_contents") +@patch("cosmos.config.Path") +def test_profile_config_ensure_profile_without_caching_calls_get_profile_file_content_before_env_vars( + mock_path, mock_get_profile_file_contents, mock_env_vars, mock_cache +): + """ + The `env_vars` should not be called if profile file is not populated. + """ + profile_mapping = AthenaAccessKeyProfileMapping(conn_id="test", profile_args={}) + profile_config = ProfileConfig(profile_name="test", target_name="test", profile_mapping=profile_mapping) + mock_manager = Mock() + mock_manager.attach_mock(mock_get_profile_file_contents, "get_profile_file_contents") + mock_manager.attach_mock(mock_env_vars, "env_vars") + + with profile_config.ensure_profile(desired_profile_path=mock_path): + mock_get_profile_file_contents.assert_called_once() + mock_env_vars.assert_called_once() + expected_calls = [ + call.get_profile_file_contents(profile_name="test", target_name="test", use_mock_values=False), + call.env_vars, + ] + mock_manager.assert_has_calls(expected_calls, any_order=False) + + +@patch("cosmos.config.create_cache_profile") +@patch("cosmos.profiles.athena.access_key.AthenaAccessKeyProfileMapping.version") +@patch("cosmos.config.get_cached_profile", return_value=None) +@patch("cosmos.config.is_profile_cache_enabled", return_value=True) +@patch("cosmos.profiles.athena.access_key.AthenaAccessKeyProfileMapping.env_vars", new_callable=PropertyMock) +@patch("cosmos.profiles.athena.access_key.AthenaAccessKeyProfileMapping.get_profile_file_contents") +@patch("cosmos.config.Path") +def test_profile_config_ensure_profile_with_caching_calls_get_profile_file_content_before_env_vars( + mock_path, + mock_get_profile_file_contents, + mock_env_vars, + mock_cache, + mock_get_cached_profile, + mock_version, + mock_create_cache_profile, +): + """ + The `env_vars` should not be called if profile file is not populated. + """ + profile_mapping = AthenaAccessKeyProfileMapping(conn_id="test", profile_args={}) + profile_config = ProfileConfig(profile_name="test", target_name="test", profile_mapping=profile_mapping) + mock_manager = Mock() + mock_manager.attach_mock(mock_get_profile_file_contents, "get_profile_file_contents") + mock_manager.attach_mock(mock_env_vars, "env_vars") + + with profile_config.ensure_profile(desired_profile_path=mock_path): + mock_get_profile_file_contents.assert_called_once() + mock_env_vars.assert_called_once() + expected_calls = [ + call.get_profile_file_contents(profile_name="test", target_name="test", use_mock_values=False), + call.env_vars, + ] + mock_manager.assert_has_calls(expected_calls, any_order=False) + + @patch("cosmos.config.shutil.which", return_value=None) def test_render_config_without_dbt_cmd(mock_which): render_config = RenderConfig() From c5780aa33f41d82125a9dd2680bc4088a55388b3 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 9 Jul 2024 13:21:18 +0530 Subject: [PATCH 220/223] =?UTF-8?q?=E2=AC=86=20[pre-commit.ci]=20pre-commi?= =?UTF-8?q?t=20autoupdate=20(#1083)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.5.0 → v0.5.1](https://github.com/astral-sh/ruff-pre-commit/compare/v0.5.0...v0.5.1) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 53d0b9c701..fcd19b42a7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -54,7 +54,7 @@ repos: - --py37-plus - --keep-runtime-typing - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.5.0 + rev: v0.5.1 hooks: - id: ruff args: From 5fa3752d0bd6650981be524701e42a032ebb5114 Mon Sep 17 00:00:00 2001 From: Satish Chinthanippu Date: Wed, 10 Jul 2024 03:18:32 -0700 Subject: [PATCH 221/223] Teradata Profile Mapping Issue - Credentials in profile "generated_profile", target "dev" invalid: Runtime Error Must specify `schema` in Teradata profile (#1088) `TeradataUserPassword` profile mapping throws below error for mock profile ``` Credentials in profile "generated_profile", target "dev" invalid: Runtime Error Must specify the schema in Teradata profile ``` Closes https://github.com/astronomer/astronomer-cosmos/issues/1087 --- cosmos/profiles/teradata/user_pass.py | 7 +++++++ tests/profiles/teradata/test_teradata_user_pass.py | 9 +++++++++ 2 files changed, 16 insertions(+) diff --git a/cosmos/profiles/teradata/user_pass.py b/cosmos/profiles/teradata/user_pass.py index 059e4a9f09..535a7c717c 100644 --- a/cosmos/profiles/teradata/user_pass.py +++ b/cosmos/profiles/teradata/user_pass.py @@ -49,3 +49,10 @@ def profile(self) -> dict[str, Any]: profile["schema"] = profile["user"] return self.filter_null(profile) + + @property + def mock_profile(self) -> dict[str, Any | None]: + """Gets mock profile. Assigning user to schema as default""" + mock_profile = super().mock_profile + mock_profile["schema"] = mock_profile["user"] + return mock_profile diff --git a/tests/profiles/teradata/test_teradata_user_pass.py b/tests/profiles/teradata/test_teradata_user_pass.py index ff28977fe2..795e461a48 100644 --- a/tests/profiles/teradata/test_teradata_user_pass.py +++ b/tests/profiles/teradata/test_teradata_user_pass.py @@ -96,6 +96,12 @@ def test_profile_mapping_selected( assert isinstance(profile_mapping, TeradataUserPasswordProfileMapping) +def test_profile_mapping_schema_validation(mock_teradata_conn: Connection) -> None: + # port is not handled in airflow connection so adding it as profile_args + profile = TeradataUserPasswordProfileMapping(mock_teradata_conn.conn_id) + assert profile.profile["schema"] == "my_user" + + def test_profile_mapping_keeps_port(mock_teradata_conn: Connection) -> None: # port is not handled in airflow connection so adding it as profile_args profile = TeradataUserPasswordProfileMapping(mock_teradata_conn.conn_id, profile_args={"port": 1025}) @@ -174,3 +180,6 @@ def test_mock_profile() -> None: """ profile = TeradataUserPasswordProfileMapping("mock_conn_id") assert profile.mock_profile.get("host") == "mock_value" + assert profile.mock_profile.get("user") == "mock_value" + assert profile.mock_profile.get("password") == "mock_value" + assert profile.mock_profile.get("schema") == "mock_value" From d9c26316a9bd7fb2cc3e4d03eb1dfac60d4a8b88 Mon Sep 17 00:00:00 2001 From: DanMawdsleyBA Date: Sat, 4 Nov 2023 10:56:05 +0000 Subject: [PATCH 222/223] Intial change for Snowflake encrypted private key --- cosmos/profiles/__init__.py | 2 ++ cosmos/profiles/snowflake/__init__.py | 2 ++ .../test_snowflake_user_encrypted_privatekey_env_variable.py | 2 +- .../snowflake/test_snowflake_user_encrypted_privatekey_file.py | 2 +- 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/cosmos/profiles/__init__.py b/cosmos/profiles/__init__.py index 392b4f78b8..4877e44505 100644 --- a/cosmos/profiles/__init__.py +++ b/cosmos/profiles/__init__.py @@ -18,6 +18,8 @@ from .snowflake.user_encrypted_privatekey_file import SnowflakeEncryptedPrivateKeyFilePemProfileMapping from .snowflake.user_pass import SnowflakeUserPasswordProfileMapping from .snowflake.user_privatekey import SnowflakePrivateKeyPemProfileMapping +from .snowflake.user_encrypted_privatekey_file import SnowflakeEncryptedPrivateKeyFilePemProfileMapping +from .snowflake.user_encrypted_privatekey_env_variable import SnowflakeEncryptedPrivateKeyPemProfileMapping from .spark.thrift import SparkThriftProfileMapping from .teradata.user_pass import TeradataUserPasswordProfileMapping from .trino.certificate import TrinoCertificateProfileMapping diff --git a/cosmos/profiles/snowflake/__init__.py b/cosmos/profiles/snowflake/__init__.py index 7e81a96c7d..ac71f27ac5 100644 --- a/cosmos/profiles/snowflake/__init__.py +++ b/cosmos/profiles/snowflake/__init__.py @@ -4,6 +4,8 @@ from .user_encrypted_privatekey_file import SnowflakeEncryptedPrivateKeyFilePemProfileMapping from .user_pass import SnowflakeUserPasswordProfileMapping from .user_privatekey import SnowflakePrivateKeyPemProfileMapping +from .user_encrypted_privatekey_file import SnowflakeEncryptedPrivateKeyFilePemProfileMapping +from .user_encrypted_privatekey_env_variable import SnowflakeEncryptedPrivateKeyPemProfileMapping __all__ = [ "SnowflakeUserPasswordProfileMapping", diff --git a/tests/profiles/snowflake/test_snowflake_user_encrypted_privatekey_env_variable.py b/tests/profiles/snowflake/test_snowflake_user_encrypted_privatekey_env_variable.py index ff00ee6980..d6c1e30aee 100644 --- a/tests/profiles/snowflake/test_snowflake_user_encrypted_privatekey_env_variable.py +++ b/tests/profiles/snowflake/test_snowflake_user_encrypted_privatekey_env_variable.py @@ -214,4 +214,4 @@ def test_old_snowflake_format() -> None: "account": conn.extra_dejson.get("account"), "database": conn.extra_dejson.get("database"), "warehouse": conn.extra_dejson.get("warehouse"), - } + } \ No newline at end of file diff --git a/tests/profiles/snowflake/test_snowflake_user_encrypted_privatekey_file.py b/tests/profiles/snowflake/test_snowflake_user_encrypted_privatekey_file.py index 73f2d947d5..762895e7e0 100644 --- a/tests/profiles/snowflake/test_snowflake_user_encrypted_privatekey_file.py +++ b/tests/profiles/snowflake/test_snowflake_user_encrypted_privatekey_file.py @@ -213,4 +213,4 @@ def test_old_snowflake_format() -> None: "account": conn.extra_dejson.get("account"), "database": conn.extra_dejson.get("database"), "warehouse": conn.extra_dejson.get("warehouse"), - } + } \ No newline at end of file From c0e280ed89e7c512e9038259d9b50ab2a756b6c6 Mon Sep 17 00:00:00 2001 From: DanMawdsleyBA Date: Sat, 4 Nov 2023 11:13:32 +0000 Subject: [PATCH 223/223] Work around for user/password mapping --- cosmos/profiles/snowflake/user_pass.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cosmos/profiles/snowflake/user_pass.py b/cosmos/profiles/snowflake/user_pass.py index 3fc6595c93..a0c6ade678 100644 --- a/cosmos/profiles/snowflake/user_pass.py +++ b/cosmos/profiles/snowflake/user_pass.py @@ -92,4 +92,4 @@ def transform_account(self, account: str) -> str: if region and region not in account: account = f"{account}.{region}" - return str(account) + return str(account) \ No newline at end of file