From 54bdef64c743cf24c73702991acab82706b22d4c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 21 Mar 2026 18:40:35 +0000 Subject: [PATCH 01/10] feat: add optional extras for cloud storage and notification clients Add pip extras (s3, gcs, azure, slack, teams) for optional dependencies. Make all cloud/notification imports lazy to prevent unconditional loading. Dependencies remain in default install for backwards compatibility (Phase 1). Closes #2155 Co-Authored-By: Itamar Hartstein --- elementary/clients/azure/client.py | 6 ++-- elementary/clients/gcs/client.py | 15 ++++---- elementary/clients/s3/client.py | 4 +-- elementary/clients/slack/client.py | 32 ++++++++++++----- .../clients/slack/slack_message_builder.py | 14 ++++---- elementary/config/config.py | 12 ++++--- elementary/messages/formats/block_kit.py | 13 ++++--- .../messaging_integrations/slack_web.py | 23 +++++++++--- .../messaging_integrations/slack_webhook.py | 15 +++++--- .../alerts/integrations/integrations.py | 36 ++++++++++++------- .../alerts/integrations/slack/slack.py | 9 ++--- .../report/data_monitoring_report.py | 16 +++++---- elementary/utils/deps.py | 25 +++++++++++++ pyproject.toml | 5 +++ 14 files changed, 158 insertions(+), 67 deletions(-) create mode 100644 elementary/utils/deps.py diff --git a/elementary/clients/azure/client.py b/elementary/clients/azure/client.py index 2c9c8b898..4efc41541 100644 --- a/elementary/clients/azure/client.py +++ b/elementary/clients/azure/client.py @@ -1,10 +1,9 @@ from os import path from typing import Optional, Tuple -from azure.storage.blob import BlobServiceClient - from elementary.config.config import Config from elementary.tracking.tracking_interface import Tracking +from elementary.utils.deps import import_optional_dependency from elementary.utils.log import get_logger logger = get_logger(__name__) @@ -13,7 +12,8 @@ class AzureClient: def __init__(self, config: Config, tracking: Optional[Tracking] = None): self.config = config - self.blob_service_client = BlobServiceClient.from_connection_string( + azure_blob = import_optional_dependency("azure.storage.blob", "azure") + self.blob_service_client = azure_blob.BlobServiceClient.from_connection_string( self.config.azure_connection_string ) self.tracking = tracking diff --git a/elementary/clients/gcs/client.py b/elementary/clients/gcs/client.py index 1f8505c47..e7ec603f5 100644 --- a/elementary/clients/gcs/client.py +++ b/elementary/clients/gcs/client.py @@ -2,14 +2,10 @@ from typing import Optional, Tuple from urllib.parse import urljoin -import google # type: ignore[import] -from google.auth.credentials import Credentials # type: ignore[import] -from google.cloud import storage # type: ignore[attr-defined, import] -from google.oauth2 import service_account # type: ignore[import] - from elementary.config.config import Config from elementary.tracking.tracking_interface import Tracking from elementary.utils import bucket_path +from elementary.utils.deps import import_optional_dependency from elementary.utils.log import get_logger logger = get_logger(__name__) @@ -87,16 +83,21 @@ def get_bucket_website_url( return bucket_website_url def get_client(self, config: Config): + storage = import_optional_dependency("google.cloud.storage", "gcs") creds = self.get_credentials(config) if config.google_project_name: return storage.Client(config.google_project_name, credentials=creds) return storage.Client(credentials=creds) @staticmethod - def get_credentials(config: Config) -> Credentials: + def get_credentials(config: Config): if config.google_service_account_path: + service_account = import_optional_dependency( + "google.oauth2.service_account", "gcs" + ) return service_account.Credentials.from_service_account_file( config.google_service_account_path ) - credentials, _ = google.auth.default() + google_auth = import_optional_dependency("google.auth", "gcs") + credentials, _ = google_auth.default() return credentials diff --git a/elementary/clients/s3/client.py b/elementary/clients/s3/client.py index b2996d79d..42105cfc9 100644 --- a/elementary/clients/s3/client.py +++ b/elementary/clients/s3/client.py @@ -1,11 +1,10 @@ from os import path from typing import Optional, Tuple -import boto3 - from elementary.config.config import Config from elementary.tracking.tracking_interface import Tracking from elementary.utils import bucket_path +from elementary.utils.deps import import_optional_dependency from elementary.utils.log import get_logger logger = get_logger(__name__) @@ -14,6 +13,7 @@ class S3Client: def __init__(self, config: Config, tracking: Optional[Tracking] = None): self.config = config + boto3 = import_optional_dependency("boto3", "s3") aws_session = boto3.Session( profile_name=config.aws_profile_name, region_name=config.aws_region_name, diff --git a/elementary/clients/slack/client.py b/elementary/clients/slack/client.py index 0c74a28a3..5a5d4bcfd 100644 --- a/elementary/clients/slack/client.py +++ b/elementary/clients/slack/client.py @@ -1,18 +1,21 @@ +from __future__ import annotations + import json import ssl from abc import ABC, abstractmethod -from typing import Dict, List, Optional, Tuple, Union +from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union + +if TYPE_CHECKING: + from slack_sdk.errors import SlackApiError + from slack_sdk.webhook import WebhookResponse import requests from ratelimit import limits, sleep_and_retry -from slack_sdk import WebClient, WebhookClient -from slack_sdk.errors import SlackApiError -from slack_sdk.http_retry.builtin_handlers import RateLimitErrorRetryHandler -from slack_sdk.webhook.webhook_response import WebhookResponse from elementary.clients.slack.schema import SlackMessageSchema from elementary.config.config import Config from elementary.tracking.tracking_interface import Tracking +from elementary.utils.deps import import_optional_dependency from elementary.utils.log import get_logger from elementary.utils.ssl import create_ssl_context @@ -59,7 +62,10 @@ def _initial_client(self, ssl_context: Optional[ssl.SSLContext]): raise NotImplementedError def _initial_retry_handlers(self): - if isinstance(self.client, WebClient): + slack_sdk = import_optional_dependency("slack_sdk", "slack") + if isinstance(self.client, slack_sdk.WebClient): + from slack_sdk.http_retry.builtin_handlers import RateLimitErrorRetryHandler + rate_limit_handler = RateLimitErrorRetryHandler(max_retry_count=5) self.client.retry_handlers.append(rate_limit_handler) @@ -96,13 +102,16 @@ def __init__( super().__init__(tracking, ssl_context) def _initial_client(self, ssl_context: Optional[ssl.SSLContext]): - return WebClient(token=self.token, ssl=ssl_context) + slack_sdk = import_optional_dependency("slack_sdk", "slack") + return slack_sdk.WebClient(token=self.token, ssl=ssl_context) @sleep_and_retry @limits(calls=1, period=ONE_SECOND) def send_message( self, channel_name: str, message: SlackMessageSchema, **kwargs ) -> bool: + from slack_sdk.errors import SlackApiError + try: self.client.chat_postMessage( channel=channel_name, @@ -128,6 +137,8 @@ def send_file( file_path: str, message: Optional[SlackMessageSchema] = None, ) -> bool: + from slack_sdk.errors import SlackApiError + channel_id = self._get_channel_id(channel_name) try: self.client.files_upload_v2( @@ -157,6 +168,8 @@ def send_report(self, channel_name: str, report_file_path: str): @sleep_and_retry @limits(calls=50, period=ONE_MINUTE) def get_user_id_from_email(self, email: str) -> Optional[str]: + from slack_sdk.errors import SlackApiError + try: if email not in self.email_to_user_id_cache: user_id = self.client.users_lookupByEmail(email=email)["user"]["id"] @@ -197,6 +210,8 @@ def _get_channel_id(self, channel_name: str) -> Optional[str]: return None def _join_channel(self, channel_id: str) -> bool: + from slack_sdk.errors import SlackApiError + try: self.client.conversations_join(channel=channel_id) logger.info("Elementary app joined the channel successfully.") @@ -249,7 +264,8 @@ def _initial_client(self, ssl_context: Optional[ssl.SSLContext]): # requests.Session() uses the requests default CA bundle (certifi). return requests.Session() - return WebhookClient( + slack_sdk = import_optional_dependency("slack_sdk", "slack") + return slack_sdk.WebhookClient( url=self.webhook, default_headers={"Content-type": "application/json"}, ssl=ssl_context, diff --git a/elementary/clients/slack/slack_message_builder.py b/elementary/clients/slack/slack_message_builder.py index 368dafbd8..6a0cb16d9 100644 --- a/elementary/clients/slack/slack_message_builder.py +++ b/elementary/clients/slack/slack_message_builder.py @@ -1,12 +1,14 @@ from enum import Enum from typing import List, Optional, Union -from slack_sdk.models.blocks import HeaderBlock, SectionBlock - from elementary.clients.slack.schema import SlackBlocksType, SlackMessageSchema from elementary.utils.json_utils import unpack_and_flatten_str_to_list from elementary.utils.pydantic_shim import BaseModel +# Slack Block Kit limits (avoid module-level slack_sdk import) +_HEADER_TEXT_MAX_LENGTH = 150 +_SECTION_TEXT_MAX_LENGTH = 3000 + class OptionSchema(BaseModel): value: str @@ -56,11 +58,11 @@ def _add_blocks_as_attachments(self, blocks: SlackBlocksType): @staticmethod def get_limited_markdown_msg(section_msg: str) -> str: - if len(section_msg) < SectionBlock.text_max_length: + if len(section_msg) < _SECTION_TEXT_MAX_LENGTH: return section_msg return ( section_msg[ - : SectionBlock.text_max_length + : _SECTION_TEXT_MAX_LENGTH - len(SlackMessageBuilder._CONTINUATION_SYMBOL) - SlackMessageBuilder._LONGEST_MARKDOWN_SUFFIX_LEN ] @@ -120,8 +122,8 @@ def create_context_block(context_msgs: list) -> dict: @staticmethod def create_header_block(msg: str) -> dict: - if len(msg) > HeaderBlock.text_max_length: - final_msg = msg[: HeaderBlock.text_max_length - 3] + "..." + if len(msg) > _HEADER_TEXT_MAX_LENGTH: + final_msg = msg[: _HEADER_TEXT_MAX_LENGTH - 3] + "..." else: final_msg = msg diff --git a/elementary/config/config.py b/elementary/config/config.py index fbce626c5..6f23c6f33 100644 --- a/elementary/config/config.py +++ b/elementary/config/config.py @@ -2,9 +2,7 @@ from pathlib import Path from typing import Optional -import google.auth # type: ignore[import] from dateutil import tz -from google.auth.exceptions import DefaultCredentialsError # type: ignore[import] from elementary.exceptions.exceptions import InvalidArgumentsError from elementary.monitor.alerts.grouping_type import GroupingType @@ -265,9 +263,15 @@ def has_gcloud(self): if self.google_service_account_path: return True try: - google.auth.default() + from elementary.utils.deps import import_optional_dependency + + google_auth = import_optional_dependency("google.auth", "gcs") + google_auth.default() return True - except DefaultCredentialsError: + except ImportError: + return False + except Exception: + # google.auth.exceptions.DefaultCredentialsError or similar return False @property diff --git a/elementary/messages/formats/block_kit.py b/elementary/messages/formats/block_kit.py index a0cc0cd79..9b091c9dc 100644 --- a/elementary/messages/formats/block_kit.py +++ b/elementary/messages/formats/block_kit.py @@ -1,7 +1,6 @@ import json from typing import Any, Callable, List, Optional, Tuple -from slack_sdk.models import blocks as slack_blocks from tabulate import tabulate from elementary.messages.blocks import ( @@ -105,11 +104,15 @@ def _format_table_cell(self, cell_value: Any, column_count: int) -> str: return value[: max_cell_length - 2] + ".." return value + # Slack Block Kit limits (avoid module-level slack_sdk import) + _SECTION_TEXT_MAX_LENGTH = 3000 + _HEADER_TEXT_MAX_LENGTH = 150 + def _format_markdown_section_text(self, text: str) -> dict: - if len(text) > slack_blocks.SectionBlock.text_max_length: + if len(text) > self._SECTION_TEXT_MAX_LENGTH: text = ( text[ - : slack_blocks.SectionBlock.text_max_length + : self._SECTION_TEXT_MAX_LENGTH - len("...") - self._LONGEST_MARKDOWN_SUFFIX_LEN ] @@ -198,8 +201,8 @@ def _add_lines_block(self, block: LinesBlock) -> None: self._add_block(self._format_markdown_section("\n".join(formatted_lines))) def _add_header_block(self, block: HeaderBlock) -> None: - if len(block.text) > slack_blocks.HeaderBlock.text_max_length: - text = block.text[: slack_blocks.HeaderBlock.text_max_length - 3] + "..." + if len(block.text) > self._HEADER_TEXT_MAX_LENGTH: + text = block.text[: self._HEADER_TEXT_MAX_LENGTH - 3] + "..." else: text = block.text self._add_block( diff --git a/elementary/messages/messaging_integrations/slack_web.py b/elementary/messages/messaging_integrations/slack_web.py index f83b2ae51..ca48043b7 100644 --- a/elementary/messages/messaging_integrations/slack_web.py +++ b/elementary/messages/messaging_integrations/slack_web.py @@ -1,14 +1,17 @@ +from __future__ import annotations + import json import ssl import time -from typing import Any, Dict, Iterator, Optional +from typing import TYPE_CHECKING, Any, Dict, Iterator, Optional from ratelimit import limits, sleep_and_retry -from slack_sdk import WebClient -from slack_sdk.errors import SlackApiError -from slack_sdk.http_retry.builtin_handlers import RateLimitErrorRetryHandler from typing_extensions import TypeAlias +if TYPE_CHECKING: + from slack_sdk import WebClient + from slack_sdk.errors import SlackApiError + from elementary.messages.formats.block_kit import ( FormattedBlockKitMessage, format_block_kit, @@ -22,6 +25,7 @@ MessagingIntegrationError, ) from elementary.tracking.tracking_interface import Tracking +from elementary.utils.deps import import_optional_dependency from elementary.utils.log import get_logger from elementary.utils.pydantic_shim import BaseModel @@ -61,7 +65,10 @@ def from_token( ssl_context: Optional[ssl.SSLContext] = None, **kwargs: Any, ) -> "SlackWebMessagingIntegration": - client = WebClient(token=token, ssl=ssl_context) + slack_sdk = import_optional_dependency("slack_sdk", "slack") + from slack_sdk.http_retry.builtin_handlers import RateLimitErrorRetryHandler + + client = slack_sdk.WebClient(token=token, ssl=ssl_context) client.retry_handlers.append(RateLimitErrorRetryHandler(max_retry_count=5)) return cls(client, tracking, **kwargs) @@ -103,6 +110,8 @@ def _send_message( thread_ts: Optional[str] = None, reply_broadcast: bool = False, ) -> MessageSendResult[SlackWebMessageContext]: + from slack_sdk.errors import SlackApiError + try: response = self.client.chat_postMessage( channel=destination, @@ -180,6 +189,8 @@ def _get_channel_id(self, channel_name: str, only_public: bool = False) -> str: raise MessagingIntegrationError(f"Channel {channel_name} not found") def _join_channel(self, channel_id: str) -> None: + from slack_sdk.errors import SlackApiError + try: self.client.conversations_join(channel=channel_id) except SlackApiError as e: @@ -190,6 +201,8 @@ def _join_channel(self, channel_id: str) -> None: @sleep_and_retry @limits(calls=50, period=ONE_MINUTE) def get_user_id_from_email(self, email: str) -> Optional[str]: + from slack_sdk.errors import SlackApiError + if email in self._email_to_user_id_cache: return self._email_to_user_id_cache[email] try: diff --git a/elementary/messages/messaging_integrations/slack_webhook.py b/elementary/messages/messaging_integrations/slack_webhook.py index 8c65230fe..f5e287dae 100644 --- a/elementary/messages/messaging_integrations/slack_webhook.py +++ b/elementary/messages/messaging_integrations/slack_webhook.py @@ -1,11 +1,14 @@ +from __future__ import annotations + import ssl from datetime import datetime, timezone from http import HTTPStatus -from typing import Any, Optional +from typing import TYPE_CHECKING, Any, Optional from ratelimit import limits, sleep_and_retry -from slack_sdk import WebhookClient -from slack_sdk.http_retry.builtin_handlers import RateLimitErrorRetryHandler + +if TYPE_CHECKING: + from slack_sdk import WebhookClient from elementary.messages.formats.block_kit import ( FormattedBlockKitMessage, @@ -23,6 +26,7 @@ MessagingIntegrationError, ) from elementary.tracking.tracking_interface import Tracking +from elementary.utils.deps import import_optional_dependency ONE_SECOND = 1 @@ -43,7 +47,10 @@ def from_url( tracking: Optional[Tracking] = None, ssl_context: Optional[ssl.SSLContext] = None, ) -> "SlackWebhookMessagingIntegration": - client = WebhookClient(url, ssl=ssl_context) + slack_sdk = import_optional_dependency("slack_sdk", "slack") + from slack_sdk.http_retry.builtin_handlers import RateLimitErrorRetryHandler + + client = slack_sdk.WebhookClient(url, ssl=ssl_context) client.retry_handlers.append(RateLimitErrorRetryHandler(max_retry_count=5)) return cls(client, tracking) diff --git a/elementary/monitor/data_monitoring/alerts/integrations/integrations.py b/elementary/monitor/data_monitoring/alerts/integrations/integrations.py index aeba80dae..32f71332a 100644 --- a/elementary/monitor/data_monitoring/alerts/integrations/integrations.py +++ b/elementary/monitor/data_monitoring/alerts/integrations/integrations.py @@ -6,21 +6,9 @@ BaseMessagingIntegration, DestinationType, ) -from elementary.messages.messaging_integrations.slack_web import ( - SlackWebMessagingIntegration, -) -from elementary.messages.messaging_integrations.slack_webhook import ( - SlackWebhookMessagingIntegration, -) -from elementary.messages.messaging_integrations.teams_webhook import ( - TeamsWebhookMessagingIntegration, -) from elementary.monitor.data_monitoring.alerts.integrations.base_integration import ( BaseIntegration, ) -from elementary.monitor.data_monitoring.alerts.integrations.slack.slack import ( - SlackIntegration, -) from elementary.tracking.tracking_interface import Tracking from elementary.utils.log import get_logger from elementary.utils.ssl import create_ssl_context @@ -44,6 +32,16 @@ def get_integration( tracking: Optional[Tracking] = None, ) -> Union[BaseMessagingIntegration, BaseIntegration]: if config.has_slack: + from elementary.messages.messaging_integrations.slack_web import ( + SlackWebMessagingIntegration, + ) + from elementary.messages.messaging_integrations.slack_webhook import ( + SlackWebhookMessagingIntegration, + ) + from elementary.monitor.data_monitoring.alerts.integrations.slack.slack import ( + SlackIntegration, + ) + ssl_context = create_ssl_context(config.ssl_ca_bundle) if config.is_slack_workflow: return SlackIntegration( @@ -61,6 +59,10 @@ def get_integration( else: raise UnsupportedAlertIntegrationError elif config.has_teams: + from elementary.messages.messaging_integrations.teams_webhook import ( + TeamsWebhookMessagingIntegration, + ) + return TeamsWebhookMessagingIntegration(config.teams_webhook) else: raise UnsupportedAlertIntegrationError @@ -72,6 +74,16 @@ def get_destination( metadata: dict, override_config_defaults: bool = False, ) -> DestinationType: + from elementary.messages.messaging_integrations.slack_web import ( + SlackWebMessagingIntegration, + ) + from elementary.messages.messaging_integrations.slack_webhook import ( + SlackWebhookMessagingIntegration, + ) + from elementary.messages.messaging_integrations.teams_webhook import ( + TeamsWebhookMessagingIntegration, + ) + if ( isinstance(integration, TeamsWebhookMessagingIntegration) and config.has_teams diff --git a/elementary/monitor/data_monitoring/alerts/integrations/slack/slack.py b/elementary/monitor/data_monitoring/alerts/integrations/slack/slack.py index a0e5ce30d..8e07822ee 100644 --- a/elementary/monitor/data_monitoring/alerts/integrations/slack/slack.py +++ b/elementary/monitor/data_monitoring/alerts/integrations/slack/slack.py @@ -3,11 +3,12 @@ from datetime import datetime, timedelta from typing import Any, Dict, List, Optional, Sequence, Union -from slack_sdk.models.blocks import SectionBlock - from elementary.clients.slack.client import SlackClient, SlackWebClient from elementary.clients.slack.schema import SlackBlocksType, SlackMessageSchema -from elementary.clients.slack.slack_message_builder import MessageColor +from elementary.clients.slack.slack_message_builder import ( + _SECTION_TEXT_MAX_LENGTH, + MessageColor, +) from elementary.config.config import Config from elementary.monitor.alerts.alerts_groups import AlertsGroup, GroupedByTableAlerts from elementary.monitor.alerts.alerts_groups.base_alerts_group import BaseAlertsGroup @@ -238,7 +239,7 @@ def _get_dbt_test_template( result.append(self.message_builder.create_context_block(["*Test query*"])) msg = f"```{alert.test_results_query}```" - if len(msg) > SectionBlock.text_max_length: + if len(msg) > _SECTION_TEXT_MAX_LENGTH: msg = ( f"_The test query was too long, here's a query to get it._\n" f"```SELECT test_results_query FROM {alert.elementary_database_and_schema}.elementary_test_results WHERE test_execution_id = '{alert.id}'```" diff --git a/elementary/monitor/data_monitoring/report/data_monitoring_report.py b/elementary/monitor/data_monitoring/report/data_monitoring_report.py index 7493b96e7..fab927284 100644 --- a/elementary/monitor/data_monitoring/report/data_monitoring_report.py +++ b/elementary/monitor/data_monitoring/report/data_monitoring_report.py @@ -5,10 +5,6 @@ import webbrowser from typing import Optional, Tuple -from elementary.clients.azure.client import AzureClient -from elementary.clients.gcs.client import GCSClient -from elementary.clients.s3.client import S3Client -from elementary.clients.slack.client import SlackClient from elementary.config.config import Config from elementary.monitor.api.invocations.invocations import InvocationsAPI from elementary.monitor.api.report.report import ReportAPI @@ -43,6 +39,12 @@ def __init__( config, tracking, force_update_dbt_package, disable_samples, selector_filter ) self.report_api = ReportAPI(self.internal_dbt_runner) + + from elementary.clients.azure.client import AzureClient + from elementary.clients.gcs.client import GCSClient + from elementary.clients.s3.client import S3Client + from elementary.clients.slack.client import SlackClient + self.s3_client = S3Client.create_client(self.config, tracking=self.tracking) self.gcs_client = GCSClient.create_client(self.config, tracking=self.tracking) self.azure_client = AzureClient.create_client( @@ -165,9 +167,9 @@ def _add_report_tracking( report_data.tracking = dict( posthog_api_key=self.tracking.POSTHOG_PROJECT_API_KEY, report_generator_anonymous_user_id=self.tracking.anonymous_user_id, - anonymous_warehouse_id=self.warehouse_info.id - if self.warehouse_info - else None, + anonymous_warehouse_id=( + self.warehouse_info.id if self.warehouse_info else None + ), ) def send_report( diff --git a/elementary/utils/deps.py b/elementary/utils/deps.py new file mode 100644 index 000000000..e52b9ceb0 --- /dev/null +++ b/elementary/utils/deps.py @@ -0,0 +1,25 @@ +"""Helpers for optional dependency imports.""" + + +def import_optional_dependency(module_name: str, extra_name: str): + """Import and return an optional dependency module, raising a clear error if missing. + + Args: + module_name: Fully qualified module name (e.g. "boto3", "google.cloud.storage"). + extra_name: The pip extra that provides this dependency (e.g. "s3", "gcs"). + + Returns: + The imported module. + + Raises: + ImportError: With an actionable message telling the user how to install the extra. + """ + import importlib + + try: + return importlib.import_module(module_name) + except ImportError as exc: + raise ImportError( + f"Missing optional dependency '{module_name}'. " + f"Install it with: pip install 'elementary-data[{extra_name}]'" + ) from exc diff --git a/pyproject.toml b/pyproject.toml index f7577e4ee..a5923b18d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,6 +76,11 @@ fabric = ["dbt-fabric"] fabricspark = ["dbt-fabricspark"] sqlserver = ["dbt-sqlserver"] vertica = ["dbt-vertica"] +s3 = ["boto3"] +gcs = ["google-cloud-storage"] +azure = ["azure-storage-blob"] +slack = ["slack-sdk"] +teams = ["pymsteams"] # dbt-fabricspark is excluded due to broken upstream dependencies (azure-cli pre-release pins). # dbt-vertica is excluded because it pins dbt-core==1.8.5, forcing the entire resolution to dbt 1.8. # Both are still available as individual extras (e.g. pip install elementary-data[vertica]). From a4c320c2ee1958b2ded30dddba3d0b144934a486 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 21 Mar 2026 18:50:54 +0000 Subject: [PATCH 02/10] refactor: simplify optional extras per review feedback - Revert individual client file changes (no more lazy imports in clients) - Remove deps.py helper (not needed) - Only init clients in data_monitoring_report.py if config says they're needed - Inline imports with ImportError handling and actionable warnings - Move cloud/notification deps to optional=true in pyproject.toml - Add blank lines between dbt adapter extras and integration extras - Add integration extras to 'all' meta-extra - Keep google.auth lazy import in config.py has_gcloud property Co-Authored-By: Itamar Hartstein --- elementary/clients/azure/client.py | 6 +- elementary/clients/gcs/client.py | 15 ++-- elementary/clients/s3/client.py | 4 +- elementary/clients/slack/client.py | 32 +++------ .../clients/slack/slack_message_builder.py | 14 ++-- elementary/config/config.py | 5 +- elementary/messages/formats/block_kit.py | 13 ++-- .../messaging_integrations/slack_web.py | 23 ++----- .../messaging_integrations/slack_webhook.py | 15 ++-- .../alerts/integrations/integrations.py | 36 ++++------ .../alerts/integrations/slack/slack.py | 9 ++- .../report/data_monitoring_report.py | 68 +++++++++++++++---- elementary/utils/deps.py | 25 ------- pyproject.toml | 15 ++-- 14 files changed, 122 insertions(+), 158 deletions(-) delete mode 100644 elementary/utils/deps.py diff --git a/elementary/clients/azure/client.py b/elementary/clients/azure/client.py index 4efc41541..2c9c8b898 100644 --- a/elementary/clients/azure/client.py +++ b/elementary/clients/azure/client.py @@ -1,9 +1,10 @@ from os import path from typing import Optional, Tuple +from azure.storage.blob import BlobServiceClient + from elementary.config.config import Config from elementary.tracking.tracking_interface import Tracking -from elementary.utils.deps import import_optional_dependency from elementary.utils.log import get_logger logger = get_logger(__name__) @@ -12,8 +13,7 @@ class AzureClient: def __init__(self, config: Config, tracking: Optional[Tracking] = None): self.config = config - azure_blob = import_optional_dependency("azure.storage.blob", "azure") - self.blob_service_client = azure_blob.BlobServiceClient.from_connection_string( + self.blob_service_client = BlobServiceClient.from_connection_string( self.config.azure_connection_string ) self.tracking = tracking diff --git a/elementary/clients/gcs/client.py b/elementary/clients/gcs/client.py index e7ec603f5..1f8505c47 100644 --- a/elementary/clients/gcs/client.py +++ b/elementary/clients/gcs/client.py @@ -2,10 +2,14 @@ from typing import Optional, Tuple from urllib.parse import urljoin +import google # type: ignore[import] +from google.auth.credentials import Credentials # type: ignore[import] +from google.cloud import storage # type: ignore[attr-defined, import] +from google.oauth2 import service_account # type: ignore[import] + from elementary.config.config import Config from elementary.tracking.tracking_interface import Tracking from elementary.utils import bucket_path -from elementary.utils.deps import import_optional_dependency from elementary.utils.log import get_logger logger = get_logger(__name__) @@ -83,21 +87,16 @@ def get_bucket_website_url( return bucket_website_url def get_client(self, config: Config): - storage = import_optional_dependency("google.cloud.storage", "gcs") creds = self.get_credentials(config) if config.google_project_name: return storage.Client(config.google_project_name, credentials=creds) return storage.Client(credentials=creds) @staticmethod - def get_credentials(config: Config): + def get_credentials(config: Config) -> Credentials: if config.google_service_account_path: - service_account = import_optional_dependency( - "google.oauth2.service_account", "gcs" - ) return service_account.Credentials.from_service_account_file( config.google_service_account_path ) - google_auth = import_optional_dependency("google.auth", "gcs") - credentials, _ = google_auth.default() + credentials, _ = google.auth.default() return credentials diff --git a/elementary/clients/s3/client.py b/elementary/clients/s3/client.py index 42105cfc9..b2996d79d 100644 --- a/elementary/clients/s3/client.py +++ b/elementary/clients/s3/client.py @@ -1,10 +1,11 @@ from os import path from typing import Optional, Tuple +import boto3 + from elementary.config.config import Config from elementary.tracking.tracking_interface import Tracking from elementary.utils import bucket_path -from elementary.utils.deps import import_optional_dependency from elementary.utils.log import get_logger logger = get_logger(__name__) @@ -13,7 +14,6 @@ class S3Client: def __init__(self, config: Config, tracking: Optional[Tracking] = None): self.config = config - boto3 = import_optional_dependency("boto3", "s3") aws_session = boto3.Session( profile_name=config.aws_profile_name, region_name=config.aws_region_name, diff --git a/elementary/clients/slack/client.py b/elementary/clients/slack/client.py index 5a5d4bcfd..0c74a28a3 100644 --- a/elementary/clients/slack/client.py +++ b/elementary/clients/slack/client.py @@ -1,21 +1,18 @@ -from __future__ import annotations - import json import ssl from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union - -if TYPE_CHECKING: - from slack_sdk.errors import SlackApiError - from slack_sdk.webhook import WebhookResponse +from typing import Dict, List, Optional, Tuple, Union import requests from ratelimit import limits, sleep_and_retry +from slack_sdk import WebClient, WebhookClient +from slack_sdk.errors import SlackApiError +from slack_sdk.http_retry.builtin_handlers import RateLimitErrorRetryHandler +from slack_sdk.webhook.webhook_response import WebhookResponse from elementary.clients.slack.schema import SlackMessageSchema from elementary.config.config import Config from elementary.tracking.tracking_interface import Tracking -from elementary.utils.deps import import_optional_dependency from elementary.utils.log import get_logger from elementary.utils.ssl import create_ssl_context @@ -62,10 +59,7 @@ def _initial_client(self, ssl_context: Optional[ssl.SSLContext]): raise NotImplementedError def _initial_retry_handlers(self): - slack_sdk = import_optional_dependency("slack_sdk", "slack") - if isinstance(self.client, slack_sdk.WebClient): - from slack_sdk.http_retry.builtin_handlers import RateLimitErrorRetryHandler - + if isinstance(self.client, WebClient): rate_limit_handler = RateLimitErrorRetryHandler(max_retry_count=5) self.client.retry_handlers.append(rate_limit_handler) @@ -102,16 +96,13 @@ def __init__( super().__init__(tracking, ssl_context) def _initial_client(self, ssl_context: Optional[ssl.SSLContext]): - slack_sdk = import_optional_dependency("slack_sdk", "slack") - return slack_sdk.WebClient(token=self.token, ssl=ssl_context) + return WebClient(token=self.token, ssl=ssl_context) @sleep_and_retry @limits(calls=1, period=ONE_SECOND) def send_message( self, channel_name: str, message: SlackMessageSchema, **kwargs ) -> bool: - from slack_sdk.errors import SlackApiError - try: self.client.chat_postMessage( channel=channel_name, @@ -137,8 +128,6 @@ def send_file( file_path: str, message: Optional[SlackMessageSchema] = None, ) -> bool: - from slack_sdk.errors import SlackApiError - channel_id = self._get_channel_id(channel_name) try: self.client.files_upload_v2( @@ -168,8 +157,6 @@ def send_report(self, channel_name: str, report_file_path: str): @sleep_and_retry @limits(calls=50, period=ONE_MINUTE) def get_user_id_from_email(self, email: str) -> Optional[str]: - from slack_sdk.errors import SlackApiError - try: if email not in self.email_to_user_id_cache: user_id = self.client.users_lookupByEmail(email=email)["user"]["id"] @@ -210,8 +197,6 @@ def _get_channel_id(self, channel_name: str) -> Optional[str]: return None def _join_channel(self, channel_id: str) -> bool: - from slack_sdk.errors import SlackApiError - try: self.client.conversations_join(channel=channel_id) logger.info("Elementary app joined the channel successfully.") @@ -264,8 +249,7 @@ def _initial_client(self, ssl_context: Optional[ssl.SSLContext]): # requests.Session() uses the requests default CA bundle (certifi). return requests.Session() - slack_sdk = import_optional_dependency("slack_sdk", "slack") - return slack_sdk.WebhookClient( + return WebhookClient( url=self.webhook, default_headers={"Content-type": "application/json"}, ssl=ssl_context, diff --git a/elementary/clients/slack/slack_message_builder.py b/elementary/clients/slack/slack_message_builder.py index 6a0cb16d9..368dafbd8 100644 --- a/elementary/clients/slack/slack_message_builder.py +++ b/elementary/clients/slack/slack_message_builder.py @@ -1,14 +1,12 @@ from enum import Enum from typing import List, Optional, Union +from slack_sdk.models.blocks import HeaderBlock, SectionBlock + from elementary.clients.slack.schema import SlackBlocksType, SlackMessageSchema from elementary.utils.json_utils import unpack_and_flatten_str_to_list from elementary.utils.pydantic_shim import BaseModel -# Slack Block Kit limits (avoid module-level slack_sdk import) -_HEADER_TEXT_MAX_LENGTH = 150 -_SECTION_TEXT_MAX_LENGTH = 3000 - class OptionSchema(BaseModel): value: str @@ -58,11 +56,11 @@ def _add_blocks_as_attachments(self, blocks: SlackBlocksType): @staticmethod def get_limited_markdown_msg(section_msg: str) -> str: - if len(section_msg) < _SECTION_TEXT_MAX_LENGTH: + if len(section_msg) < SectionBlock.text_max_length: return section_msg return ( section_msg[ - : _SECTION_TEXT_MAX_LENGTH + : SectionBlock.text_max_length - len(SlackMessageBuilder._CONTINUATION_SYMBOL) - SlackMessageBuilder._LONGEST_MARKDOWN_SUFFIX_LEN ] @@ -122,8 +120,8 @@ def create_context_block(context_msgs: list) -> dict: @staticmethod def create_header_block(msg: str) -> dict: - if len(msg) > _HEADER_TEXT_MAX_LENGTH: - final_msg = msg[: _HEADER_TEXT_MAX_LENGTH - 3] + "..." + if len(msg) > HeaderBlock.text_max_length: + final_msg = msg[: HeaderBlock.text_max_length - 3] + "..." else: final_msg = msg diff --git a/elementary/config/config.py b/elementary/config/config.py index 6f23c6f33..1c50ce6cd 100644 --- a/elementary/config/config.py +++ b/elementary/config/config.py @@ -263,10 +263,9 @@ def has_gcloud(self): if self.google_service_account_path: return True try: - from elementary.utils.deps import import_optional_dependency + import google.auth # type: ignore[import] - google_auth = import_optional_dependency("google.auth", "gcs") - google_auth.default() + google.auth.default() return True except ImportError: return False diff --git a/elementary/messages/formats/block_kit.py b/elementary/messages/formats/block_kit.py index 9b091c9dc..a0cc0cd79 100644 --- a/elementary/messages/formats/block_kit.py +++ b/elementary/messages/formats/block_kit.py @@ -1,6 +1,7 @@ import json from typing import Any, Callable, List, Optional, Tuple +from slack_sdk.models import blocks as slack_blocks from tabulate import tabulate from elementary.messages.blocks import ( @@ -104,15 +105,11 @@ def _format_table_cell(self, cell_value: Any, column_count: int) -> str: return value[: max_cell_length - 2] + ".." return value - # Slack Block Kit limits (avoid module-level slack_sdk import) - _SECTION_TEXT_MAX_LENGTH = 3000 - _HEADER_TEXT_MAX_LENGTH = 150 - def _format_markdown_section_text(self, text: str) -> dict: - if len(text) > self._SECTION_TEXT_MAX_LENGTH: + if len(text) > slack_blocks.SectionBlock.text_max_length: text = ( text[ - : self._SECTION_TEXT_MAX_LENGTH + : slack_blocks.SectionBlock.text_max_length - len("...") - self._LONGEST_MARKDOWN_SUFFIX_LEN ] @@ -201,8 +198,8 @@ def _add_lines_block(self, block: LinesBlock) -> None: self._add_block(self._format_markdown_section("\n".join(formatted_lines))) def _add_header_block(self, block: HeaderBlock) -> None: - if len(block.text) > self._HEADER_TEXT_MAX_LENGTH: - text = block.text[: self._HEADER_TEXT_MAX_LENGTH - 3] + "..." + if len(block.text) > slack_blocks.HeaderBlock.text_max_length: + text = block.text[: slack_blocks.HeaderBlock.text_max_length - 3] + "..." else: text = block.text self._add_block( diff --git a/elementary/messages/messaging_integrations/slack_web.py b/elementary/messages/messaging_integrations/slack_web.py index ca48043b7..f83b2ae51 100644 --- a/elementary/messages/messaging_integrations/slack_web.py +++ b/elementary/messages/messaging_integrations/slack_web.py @@ -1,17 +1,14 @@ -from __future__ import annotations - import json import ssl import time -from typing import TYPE_CHECKING, Any, Dict, Iterator, Optional +from typing import Any, Dict, Iterator, Optional from ratelimit import limits, sleep_and_retry +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError +from slack_sdk.http_retry.builtin_handlers import RateLimitErrorRetryHandler from typing_extensions import TypeAlias -if TYPE_CHECKING: - from slack_sdk import WebClient - from slack_sdk.errors import SlackApiError - from elementary.messages.formats.block_kit import ( FormattedBlockKitMessage, format_block_kit, @@ -25,7 +22,6 @@ MessagingIntegrationError, ) from elementary.tracking.tracking_interface import Tracking -from elementary.utils.deps import import_optional_dependency from elementary.utils.log import get_logger from elementary.utils.pydantic_shim import BaseModel @@ -65,10 +61,7 @@ def from_token( ssl_context: Optional[ssl.SSLContext] = None, **kwargs: Any, ) -> "SlackWebMessagingIntegration": - slack_sdk = import_optional_dependency("slack_sdk", "slack") - from slack_sdk.http_retry.builtin_handlers import RateLimitErrorRetryHandler - - client = slack_sdk.WebClient(token=token, ssl=ssl_context) + client = WebClient(token=token, ssl=ssl_context) client.retry_handlers.append(RateLimitErrorRetryHandler(max_retry_count=5)) return cls(client, tracking, **kwargs) @@ -110,8 +103,6 @@ def _send_message( thread_ts: Optional[str] = None, reply_broadcast: bool = False, ) -> MessageSendResult[SlackWebMessageContext]: - from slack_sdk.errors import SlackApiError - try: response = self.client.chat_postMessage( channel=destination, @@ -189,8 +180,6 @@ def _get_channel_id(self, channel_name: str, only_public: bool = False) -> str: raise MessagingIntegrationError(f"Channel {channel_name} not found") def _join_channel(self, channel_id: str) -> None: - from slack_sdk.errors import SlackApiError - try: self.client.conversations_join(channel=channel_id) except SlackApiError as e: @@ -201,8 +190,6 @@ def _join_channel(self, channel_id: str) -> None: @sleep_and_retry @limits(calls=50, period=ONE_MINUTE) def get_user_id_from_email(self, email: str) -> Optional[str]: - from slack_sdk.errors import SlackApiError - if email in self._email_to_user_id_cache: return self._email_to_user_id_cache[email] try: diff --git a/elementary/messages/messaging_integrations/slack_webhook.py b/elementary/messages/messaging_integrations/slack_webhook.py index f5e287dae..8c65230fe 100644 --- a/elementary/messages/messaging_integrations/slack_webhook.py +++ b/elementary/messages/messaging_integrations/slack_webhook.py @@ -1,14 +1,11 @@ -from __future__ import annotations - import ssl from datetime import datetime, timezone from http import HTTPStatus -from typing import TYPE_CHECKING, Any, Optional +from typing import Any, Optional from ratelimit import limits, sleep_and_retry - -if TYPE_CHECKING: - from slack_sdk import WebhookClient +from slack_sdk import WebhookClient +from slack_sdk.http_retry.builtin_handlers import RateLimitErrorRetryHandler from elementary.messages.formats.block_kit import ( FormattedBlockKitMessage, @@ -26,7 +23,6 @@ MessagingIntegrationError, ) from elementary.tracking.tracking_interface import Tracking -from elementary.utils.deps import import_optional_dependency ONE_SECOND = 1 @@ -47,10 +43,7 @@ def from_url( tracking: Optional[Tracking] = None, ssl_context: Optional[ssl.SSLContext] = None, ) -> "SlackWebhookMessagingIntegration": - slack_sdk = import_optional_dependency("slack_sdk", "slack") - from slack_sdk.http_retry.builtin_handlers import RateLimitErrorRetryHandler - - client = slack_sdk.WebhookClient(url, ssl=ssl_context) + client = WebhookClient(url, ssl=ssl_context) client.retry_handlers.append(RateLimitErrorRetryHandler(max_retry_count=5)) return cls(client, tracking) diff --git a/elementary/monitor/data_monitoring/alerts/integrations/integrations.py b/elementary/monitor/data_monitoring/alerts/integrations/integrations.py index 32f71332a..aeba80dae 100644 --- a/elementary/monitor/data_monitoring/alerts/integrations/integrations.py +++ b/elementary/monitor/data_monitoring/alerts/integrations/integrations.py @@ -6,9 +6,21 @@ BaseMessagingIntegration, DestinationType, ) +from elementary.messages.messaging_integrations.slack_web import ( + SlackWebMessagingIntegration, +) +from elementary.messages.messaging_integrations.slack_webhook import ( + SlackWebhookMessagingIntegration, +) +from elementary.messages.messaging_integrations.teams_webhook import ( + TeamsWebhookMessagingIntegration, +) from elementary.monitor.data_monitoring.alerts.integrations.base_integration import ( BaseIntegration, ) +from elementary.monitor.data_monitoring.alerts.integrations.slack.slack import ( + SlackIntegration, +) from elementary.tracking.tracking_interface import Tracking from elementary.utils.log import get_logger from elementary.utils.ssl import create_ssl_context @@ -32,16 +44,6 @@ def get_integration( tracking: Optional[Tracking] = None, ) -> Union[BaseMessagingIntegration, BaseIntegration]: if config.has_slack: - from elementary.messages.messaging_integrations.slack_web import ( - SlackWebMessagingIntegration, - ) - from elementary.messages.messaging_integrations.slack_webhook import ( - SlackWebhookMessagingIntegration, - ) - from elementary.monitor.data_monitoring.alerts.integrations.slack.slack import ( - SlackIntegration, - ) - ssl_context = create_ssl_context(config.ssl_ca_bundle) if config.is_slack_workflow: return SlackIntegration( @@ -59,10 +61,6 @@ def get_integration( else: raise UnsupportedAlertIntegrationError elif config.has_teams: - from elementary.messages.messaging_integrations.teams_webhook import ( - TeamsWebhookMessagingIntegration, - ) - return TeamsWebhookMessagingIntegration(config.teams_webhook) else: raise UnsupportedAlertIntegrationError @@ -74,16 +72,6 @@ def get_destination( metadata: dict, override_config_defaults: bool = False, ) -> DestinationType: - from elementary.messages.messaging_integrations.slack_web import ( - SlackWebMessagingIntegration, - ) - from elementary.messages.messaging_integrations.slack_webhook import ( - SlackWebhookMessagingIntegration, - ) - from elementary.messages.messaging_integrations.teams_webhook import ( - TeamsWebhookMessagingIntegration, - ) - if ( isinstance(integration, TeamsWebhookMessagingIntegration) and config.has_teams diff --git a/elementary/monitor/data_monitoring/alerts/integrations/slack/slack.py b/elementary/monitor/data_monitoring/alerts/integrations/slack/slack.py index 8e07822ee..a0e5ce30d 100644 --- a/elementary/monitor/data_monitoring/alerts/integrations/slack/slack.py +++ b/elementary/monitor/data_monitoring/alerts/integrations/slack/slack.py @@ -3,12 +3,11 @@ from datetime import datetime, timedelta from typing import Any, Dict, List, Optional, Sequence, Union +from slack_sdk.models.blocks import SectionBlock + from elementary.clients.slack.client import SlackClient, SlackWebClient from elementary.clients.slack.schema import SlackBlocksType, SlackMessageSchema -from elementary.clients.slack.slack_message_builder import ( - _SECTION_TEXT_MAX_LENGTH, - MessageColor, -) +from elementary.clients.slack.slack_message_builder import MessageColor from elementary.config.config import Config from elementary.monitor.alerts.alerts_groups import AlertsGroup, GroupedByTableAlerts from elementary.monitor.alerts.alerts_groups.base_alerts_group import BaseAlertsGroup @@ -239,7 +238,7 @@ def _get_dbt_test_template( result.append(self.message_builder.create_context_block(["*Test query*"])) msg = f"```{alert.test_results_query}```" - if len(msg) > _SECTION_TEXT_MAX_LENGTH: + if len(msg) > SectionBlock.text_max_length: msg = ( f"_The test query was too long, here's a query to get it._\n" f"```SELECT test_results_query FROM {alert.elementary_database_and_schema}.elementary_test_results WHERE test_execution_id = '{alert.id}'```" diff --git a/elementary/monitor/data_monitoring/report/data_monitoring_report.py b/elementary/monitor/data_monitoring/report/data_monitoring_report.py index fab927284..3cf3120d4 100644 --- a/elementary/monitor/data_monitoring/report/data_monitoring_report.py +++ b/elementary/monitor/data_monitoring/report/data_monitoring_report.py @@ -40,19 +40,61 @@ def __init__( ) self.report_api = ReportAPI(self.internal_dbt_runner) - from elementary.clients.azure.client import AzureClient - from elementary.clients.gcs.client import GCSClient - from elementary.clients.s3.client import S3Client - from elementary.clients.slack.client import SlackClient - - self.s3_client = S3Client.create_client(self.config, tracking=self.tracking) - self.gcs_client = GCSClient.create_client(self.config, tracking=self.tracking) - self.azure_client = AzureClient.create_client( - self.config, tracking=self.tracking - ) - self.slack_client = SlackClient.create_client( - self.config, tracking=self.tracking - ) + self.s3_client = None + if self.config.has_s3: + try: + from elementary.clients.s3.client import S3Client + + self.s3_client = S3Client.create_client( + self.config, tracking=self.tracking + ) + except ImportError: + logger.warning( + "S3 dependencies are not installed. " + "Install them with: pip install 'elementary-data[s3]'" + ) + + self.gcs_client = None + if self.config.has_gcs: + try: + from elementary.clients.gcs.client import GCSClient + + self.gcs_client = GCSClient.create_client( + self.config, tracking=self.tracking + ) + except ImportError: + logger.warning( + "GCS dependencies are not installed. " + "Install them with: pip install 'elementary-data[gcs]'" + ) + + self.azure_client = None + if self.config.has_blob: + try: + from elementary.clients.azure.client import AzureClient + + self.azure_client = AzureClient.create_client( + self.config, tracking=self.tracking + ) + except ImportError: + logger.warning( + "Azure dependencies are not installed. " + "Install them with: pip install 'elementary-data[azure]'" + ) + + self.slack_client = None + if self.config.has_slack: + try: + from elementary.clients.slack.client import SlackClient + + self.slack_client = SlackClient.create_client( + self.config, tracking=self.tracking + ) + except ImportError: + logger.warning( + "Slack dependencies are not installed. " + "Install them with: pip install 'elementary-data[slack]'" + ) def generate_report( self, diff --git a/elementary/utils/deps.py b/elementary/utils/deps.py deleted file mode 100644 index e52b9ceb0..000000000 --- a/elementary/utils/deps.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Helpers for optional dependency imports.""" - - -def import_optional_dependency(module_name: str, extra_name: str): - """Import and return an optional dependency module, raising a clear error if missing. - - Args: - module_name: Fully qualified module name (e.g. "boto3", "google.cloud.storage"). - extra_name: The pip extra that provides this dependency (e.g. "s3", "gcs"). - - Returns: - The imported module. - - Raises: - ImportError: With an actionable message telling the user how to install the extra. - """ - import importlib - - try: - return importlib.import_module(module_name) - except ImportError as exc: - raise ImportError( - f"Missing optional dependency '{module_name}'. " - f"Install it with: pip install 'elementary-data[{extra_name}]'" - ) from exc diff --git a/pyproject.toml b/pyproject.toml index a5923b18d..6c0510075 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,17 +30,12 @@ requests = ">=2.28.1,<3.0.0" beautifulsoup4 = "<5.0.0" ratelimit = "*" posthog = "<3.0.0" -boto3 = "<2.0.0" -google-cloud-storage = ">=2.4,<3.2" "ruamel.yaml" = "<1.0.0" alive-progress = "<=2.3.1" -slack-sdk = ">=3.20.1,<4.0.0" pydantic = "<3.0" networkx = ">=2.3,<3" packaging = ">=20.9" -azure-storage-blob = ">=12.11.0" -pymsteams = ">=0.2.2,<1.0.0" tabulate = ">= 0.9.0" tenacity = ">=8.0,<10.0" pytz = ">= 2025.1" @@ -60,6 +55,12 @@ dbt-fabric = {version = ">=1.8,<2.0.0", optional = true} dbt-fabricspark = {version = ">=1.8,<2.0.0", optional = true} dbt-sqlserver = {version = ">=1.8,<2.0.0", optional = true} dbt-vertica = {version = ">=1.8,<2.0.0", optional = true} +boto3 = {version = "<2.0.0", optional = true} +google-cloud-storage = {version = ">=2.4,<3.2", optional = true} +azure-storage-blob = {version = ">=12.11.0", optional = true} +slack-sdk = {version = ">=3.20.1,<4.0.0", optional = true} +pymsteams = {version = ">=0.2.2,<1.0.0", optional = true} + [tool.poetry.extras] snowflake = ["dbt-snowflake"] bigquery = ["dbt-bigquery"] @@ -76,15 +77,17 @@ fabric = ["dbt-fabric"] fabricspark = ["dbt-fabricspark"] sqlserver = ["dbt-sqlserver"] vertica = ["dbt-vertica"] + s3 = ["boto3"] gcs = ["google-cloud-storage"] azure = ["azure-storage-blob"] slack = ["slack-sdk"] teams = ["pymsteams"] + # dbt-fabricspark is excluded due to broken upstream dependencies (azure-cli pre-release pins). # dbt-vertica is excluded because it pins dbt-core==1.8.5, forcing the entire resolution to dbt 1.8. # Both are still available as individual extras (e.g. pip install elementary-data[vertica]). -all = ["dbt-snowflake", "dbt-bigquery", "dbt-redshift", "dbt-postgres", "dbt-databricks", "dbt-spark", "dbt-athena-community", "dbt-trino", "dbt-clickhouse", "dbt-duckdb", "dbt-dremio", "dbt-fabric", "dbt-sqlserver"] +all = ["dbt-snowflake", "dbt-bigquery", "dbt-redshift", "dbt-postgres", "dbt-databricks", "dbt-spark", "dbt-athena-community", "dbt-trino", "dbt-clickhouse", "dbt-duckdb", "dbt-dremio", "dbt-fabric", "dbt-sqlserver", "boto3", "google-cloud-storage", "azure-storage-blob", "slack-sdk", "pymsteams"] [build-system] requires = ["poetry-core>=1.0.0"] From 5c8edc3873625098f144859a3881ce7885d50f5a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 21 Mar 2026 19:03:34 +0000 Subject: [PATCH 03/10] fix: mark cloud/notification deps as optional, update CI to install integrations extra Co-Authored-By: Itamar Hartstein --- .github/workflows/run-precommit.yml | 2 +- .github/workflows/test-warehouse.yml | 4 ++-- pyproject.toml | 12 +++++++----- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/workflows/run-precommit.yml b/.github/workflows/run-precommit.yml index 17aa21e88..a0e1e9f54 100644 --- a/.github/workflows/run-precommit.yml +++ b/.github/workflows/run-precommit.yml @@ -17,7 +17,7 @@ jobs: # mainly needed so mypy will have the dependencies it needs - name: Install elementary - run: pip install -e . + run: pip install -e ".[integrations]" - name: Install dev requirements run: pip install -r dev-requirements.txt diff --git a/.github/workflows/test-warehouse.yml b/.github/workflows/test-warehouse.yml index c43b1969f..a8eb64de3 100644 --- a/.github/workflows/test-warehouse.yml +++ b/.github/workflows/test-warehouse.yml @@ -216,9 +216,9 @@ jobs: # using ".[vertica]" would re-resolve dbt-vertica's deps and downgrade # dbt-core to ~=1.8. Install elementary without the adapter extra. if [ "${{ inputs.warehouse-type }}" = "vertica" ]; then - pip install "." + pip install ".[integrations]" else - pip install ".[${{ (inputs.warehouse-type == 'databricks_catalog' && 'databricks') || inputs.warehouse-type }}]" + pip install ".[${{ (inputs.warehouse-type == 'databricks_catalog' && 'databricks') || inputs.warehouse-type }},integrations]" fi - name: Write dbt profiles diff --git a/pyproject.toml b/pyproject.toml index 6c0510075..916d96f78 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,12 @@ tabulate = ">= 0.9.0" tenacity = ">=8.0,<10.0" pytz = ">= 2025.1" +boto3 = {version = "<2.0.0", optional = true} +google-cloud-storage = {version = ">=2.4,<3.2", optional = true} +slack-sdk = {version = ">=3.20.1,<4.0.0", optional = true} +azure-storage-blob = {version = ">=12.11.0", optional = true} +pymsteams = {version = ">=0.2.2,<1.0.0", optional = true} + dbt-snowflake = {version = ">=1.8,<2.0.0", optional = true} dbt-bigquery = {version = ">=1.8,<2.0.0", optional = true} dbt-redshift = {version = ">=1.8,<2.0.0", optional = true} @@ -55,11 +61,6 @@ dbt-fabric = {version = ">=1.8,<2.0.0", optional = true} dbt-fabricspark = {version = ">=1.8,<2.0.0", optional = true} dbt-sqlserver = {version = ">=1.8,<2.0.0", optional = true} dbt-vertica = {version = ">=1.8,<2.0.0", optional = true} -boto3 = {version = "<2.0.0", optional = true} -google-cloud-storage = {version = ">=2.4,<3.2", optional = true} -azure-storage-blob = {version = ">=12.11.0", optional = true} -slack-sdk = {version = ">=3.20.1,<4.0.0", optional = true} -pymsteams = {version = ">=0.2.2,<1.0.0", optional = true} [tool.poetry.extras] snowflake = ["dbt-snowflake"] @@ -83,6 +84,7 @@ gcs = ["google-cloud-storage"] azure = ["azure-storage-blob"] slack = ["slack-sdk"] teams = ["pymsteams"] +integrations = ["boto3", "google-cloud-storage", "azure-storage-blob", "slack-sdk", "pymsteams"] # dbt-fabricspark is excluded due to broken upstream dependencies (azure-cli pre-release pins). # dbt-vertica is excluded because it pins dbt-core==1.8.5, forcing the entire resolution to dbt 1.8. From a5f43dfefeb015092b234c1c52bb9ce83c7492a0 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 21 Mar 2026 19:09:01 +0000 Subject: [PATCH 04/10] fix: keep cloud/notification deps as required for Phase 1 backwards compatibility Co-Authored-By: Itamar Hartstein --- .github/workflows/run-precommit.yml | 2 +- .github/workflows/test-warehouse.yml | 4 ++-- pyproject.toml | 22 +++++++++------------- 3 files changed, 12 insertions(+), 16 deletions(-) diff --git a/.github/workflows/run-precommit.yml b/.github/workflows/run-precommit.yml index a0e1e9f54..17aa21e88 100644 --- a/.github/workflows/run-precommit.yml +++ b/.github/workflows/run-precommit.yml @@ -17,7 +17,7 @@ jobs: # mainly needed so mypy will have the dependencies it needs - name: Install elementary - run: pip install -e ".[integrations]" + run: pip install -e . - name: Install dev requirements run: pip install -r dev-requirements.txt diff --git a/.github/workflows/test-warehouse.yml b/.github/workflows/test-warehouse.yml index a8eb64de3..c43b1969f 100644 --- a/.github/workflows/test-warehouse.yml +++ b/.github/workflows/test-warehouse.yml @@ -216,9 +216,9 @@ jobs: # using ".[vertica]" would re-resolve dbt-vertica's deps and downgrade # dbt-core to ~=1.8. Install elementary without the adapter extra. if [ "${{ inputs.warehouse-type }}" = "vertica" ]; then - pip install ".[integrations]" + pip install "." else - pip install ".[${{ (inputs.warehouse-type == 'databricks_catalog' && 'databricks') || inputs.warehouse-type }},integrations]" + pip install ".[${{ (inputs.warehouse-type == 'databricks_catalog' && 'databricks') || inputs.warehouse-type }}]" fi - name: Write dbt profiles diff --git a/pyproject.toml b/pyproject.toml index 916d96f78..2e9b6eb29 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,11 +40,14 @@ tabulate = ">= 0.9.0" tenacity = ">=8.0,<10.0" pytz = ">= 2025.1" -boto3 = {version = "<2.0.0", optional = true} -google-cloud-storage = {version = ">=2.4,<3.2", optional = true} -slack-sdk = {version = ">=3.20.1,<4.0.0", optional = true} -azure-storage-blob = {version = ">=12.11.0", optional = true} -pymsteams = {version = ">=0.2.2,<1.0.0", optional = true} +# Cloud storage and notification integrations. +# These will move to optional extras in a future release (Phase 2). +# For now they remain required so existing installs are not broken. +boto3 = "<2.0.0" +google-cloud-storage = ">=2.4,<3.2" +slack-sdk = ">=3.20.1,<4.0.0" +azure-storage-blob = ">=12.11.0" +pymsteams = ">=0.2.2,<1.0.0" dbt-snowflake = {version = ">=1.8,<2.0.0", optional = true} dbt-bigquery = {version = ">=1.8,<2.0.0", optional = true} @@ -79,17 +82,10 @@ fabricspark = ["dbt-fabricspark"] sqlserver = ["dbt-sqlserver"] vertica = ["dbt-vertica"] -s3 = ["boto3"] -gcs = ["google-cloud-storage"] -azure = ["azure-storage-blob"] -slack = ["slack-sdk"] -teams = ["pymsteams"] -integrations = ["boto3", "google-cloud-storage", "azure-storage-blob", "slack-sdk", "pymsteams"] - # dbt-fabricspark is excluded due to broken upstream dependencies (azure-cli pre-release pins). # dbt-vertica is excluded because it pins dbt-core==1.8.5, forcing the entire resolution to dbt 1.8. # Both are still available as individual extras (e.g. pip install elementary-data[vertica]). -all = ["dbt-snowflake", "dbt-bigquery", "dbt-redshift", "dbt-postgres", "dbt-databricks", "dbt-spark", "dbt-athena-community", "dbt-trino", "dbt-clickhouse", "dbt-duckdb", "dbt-dremio", "dbt-fabric", "dbt-sqlserver", "boto3", "google-cloud-storage", "azure-storage-blob", "slack-sdk", "pymsteams"] +all = ["dbt-snowflake", "dbt-bigquery", "dbt-redshift", "dbt-postgres", "dbt-databricks", "dbt-spark", "dbt-athena-community", "dbt-trino", "dbt-clickhouse", "dbt-duckdb", "dbt-dremio", "dbt-fabric", "dbt-sqlserver"] [build-system] requires = ["poetry-core>=1.0.0"] From 870175a1b7be234b20c0b06ea9572b251642707e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 21 Mar 2026 19:10:52 +0000 Subject: [PATCH 05/10] feat: add empty extras markers for cloud/notification integrations (Phase 1) Co-Authored-By: Itamar Hartstein --- pyproject.toml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 2e9b6eb29..e1ffec99d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,6 +82,14 @@ fabricspark = ["dbt-fabricspark"] sqlserver = ["dbt-sqlserver"] vertica = ["dbt-vertica"] +# Cloud storage and notification extras (Phase 1: empty, deps are still required). +# In Phase 2 these will reference the actual optional dependencies. +s3 = [] +gcs = [] +azure = [] +slack = [] +teams = [] + # dbt-fabricspark is excluded due to broken upstream dependencies (azure-cli pre-release pins). # dbt-vertica is excluded because it pins dbt-core==1.8.5, forcing the entire resolution to dbt 1.8. # Both are still available as individual extras (e.g. pip install elementary-data[vertica]). From cbdcacefa2d2663821632687805d0f7280a7fa79 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 21 Mar 2026 19:58:39 +0000 Subject: [PATCH 06/10] feat: make slack_sdk imports lazy to support manual dependency exclusion via uv - Replace top-level slack_sdk.models.blocks imports with hardcoded constants in slack_message_builder.py, block_kit.py, and slack/slack.py - Move Slack/Teams integration imports inside methods in integrations.py - Move SlackReportSummaryMessageBuilder import inside method in data_monitoring_report.py - Narrow config.py exception handling to catch DefaultCredentialsError specifically - Use gcs_bucket_name instead of has_gcs for GCS client init check Co-Authored-By: Itamar Hartstein --- .../clients/slack/slack_message_builder.py | 14 ++++---- elementary/config/config.py | 3 +- elementary/messages/formats/block_kit.py | 13 ++++--- .../alerts/integrations/integrations.py | 36 ++++++++++++------- .../alerts/integrations/slack/slack.py | 9 ++--- .../report/data_monitoring_report.py | 9 ++--- 6 files changed, 51 insertions(+), 33 deletions(-) diff --git a/elementary/clients/slack/slack_message_builder.py b/elementary/clients/slack/slack_message_builder.py index 368dafbd8..b1e360b65 100644 --- a/elementary/clients/slack/slack_message_builder.py +++ b/elementary/clients/slack/slack_message_builder.py @@ -1,12 +1,14 @@ from enum import Enum from typing import List, Optional, Union -from slack_sdk.models.blocks import HeaderBlock, SectionBlock - from elementary.clients.slack.schema import SlackBlocksType, SlackMessageSchema from elementary.utils.json_utils import unpack_and_flatten_str_to_list from elementary.utils.pydantic_shim import BaseModel +# Slack Block Kit limits (avoid module-level slack_sdk import). +_HEADER_TEXT_MAX_LENGTH = 150 +_SECTION_TEXT_MAX_LENGTH = 3000 + class OptionSchema(BaseModel): value: str @@ -56,11 +58,11 @@ def _add_blocks_as_attachments(self, blocks: SlackBlocksType): @staticmethod def get_limited_markdown_msg(section_msg: str) -> str: - if len(section_msg) < SectionBlock.text_max_length: + if len(section_msg) < _SECTION_TEXT_MAX_LENGTH: return section_msg return ( section_msg[ - : SectionBlock.text_max_length + : _SECTION_TEXT_MAX_LENGTH - len(SlackMessageBuilder._CONTINUATION_SYMBOL) - SlackMessageBuilder._LONGEST_MARKDOWN_SUFFIX_LEN ] @@ -120,8 +122,8 @@ def create_context_block(context_msgs: list) -> dict: @staticmethod def create_header_block(msg: str) -> dict: - if len(msg) > HeaderBlock.text_max_length: - final_msg = msg[: HeaderBlock.text_max_length - 3] + "..." + if len(msg) > _HEADER_TEXT_MAX_LENGTH: + final_msg = msg[: _HEADER_TEXT_MAX_LENGTH - 3] + "..." else: final_msg = msg diff --git a/elementary/config/config.py b/elementary/config/config.py index 1c50ce6cd..b95b9e89d 100644 --- a/elementary/config/config.py +++ b/elementary/config/config.py @@ -269,8 +269,7 @@ def has_gcloud(self): return True except ImportError: return False - except Exception: - # google.auth.exceptions.DefaultCredentialsError or similar + except google.auth.exceptions.DefaultCredentialsError: return False @property diff --git a/elementary/messages/formats/block_kit.py b/elementary/messages/formats/block_kit.py index a0cc0cd79..ce9252648 100644 --- a/elementary/messages/formats/block_kit.py +++ b/elementary/messages/formats/block_kit.py @@ -1,7 +1,6 @@ import json from typing import Any, Callable, List, Optional, Tuple -from slack_sdk.models import blocks as slack_blocks from tabulate import tabulate from elementary.messages.blocks import ( @@ -105,11 +104,15 @@ def _format_table_cell(self, cell_value: Any, column_count: int) -> str: return value[: max_cell_length - 2] + ".." return value + # Slack Block Kit limits (avoid module-level slack_sdk import). + _SECTION_TEXT_MAX_LENGTH = 3000 + _HEADER_TEXT_MAX_LENGTH = 150 + def _format_markdown_section_text(self, text: str) -> dict: - if len(text) > slack_blocks.SectionBlock.text_max_length: + if len(text) > self._SECTION_TEXT_MAX_LENGTH: text = ( text[ - : slack_blocks.SectionBlock.text_max_length + : self._SECTION_TEXT_MAX_LENGTH - len("...") - self._LONGEST_MARKDOWN_SUFFIX_LEN ] @@ -198,8 +201,8 @@ def _add_lines_block(self, block: LinesBlock) -> None: self._add_block(self._format_markdown_section("\n".join(formatted_lines))) def _add_header_block(self, block: HeaderBlock) -> None: - if len(block.text) > slack_blocks.HeaderBlock.text_max_length: - text = block.text[: slack_blocks.HeaderBlock.text_max_length - 3] + "..." + if len(block.text) > self._HEADER_TEXT_MAX_LENGTH: + text = block.text[: self._HEADER_TEXT_MAX_LENGTH - 3] + "..." else: text = block.text self._add_block( diff --git a/elementary/monitor/data_monitoring/alerts/integrations/integrations.py b/elementary/monitor/data_monitoring/alerts/integrations/integrations.py index aeba80dae..32f71332a 100644 --- a/elementary/monitor/data_monitoring/alerts/integrations/integrations.py +++ b/elementary/monitor/data_monitoring/alerts/integrations/integrations.py @@ -6,21 +6,9 @@ BaseMessagingIntegration, DestinationType, ) -from elementary.messages.messaging_integrations.slack_web import ( - SlackWebMessagingIntegration, -) -from elementary.messages.messaging_integrations.slack_webhook import ( - SlackWebhookMessagingIntegration, -) -from elementary.messages.messaging_integrations.teams_webhook import ( - TeamsWebhookMessagingIntegration, -) from elementary.monitor.data_monitoring.alerts.integrations.base_integration import ( BaseIntegration, ) -from elementary.monitor.data_monitoring.alerts.integrations.slack.slack import ( - SlackIntegration, -) from elementary.tracking.tracking_interface import Tracking from elementary.utils.log import get_logger from elementary.utils.ssl import create_ssl_context @@ -44,6 +32,16 @@ def get_integration( tracking: Optional[Tracking] = None, ) -> Union[BaseMessagingIntegration, BaseIntegration]: if config.has_slack: + from elementary.messages.messaging_integrations.slack_web import ( + SlackWebMessagingIntegration, + ) + from elementary.messages.messaging_integrations.slack_webhook import ( + SlackWebhookMessagingIntegration, + ) + from elementary.monitor.data_monitoring.alerts.integrations.slack.slack import ( + SlackIntegration, + ) + ssl_context = create_ssl_context(config.ssl_ca_bundle) if config.is_slack_workflow: return SlackIntegration( @@ -61,6 +59,10 @@ def get_integration( else: raise UnsupportedAlertIntegrationError elif config.has_teams: + from elementary.messages.messaging_integrations.teams_webhook import ( + TeamsWebhookMessagingIntegration, + ) + return TeamsWebhookMessagingIntegration(config.teams_webhook) else: raise UnsupportedAlertIntegrationError @@ -72,6 +74,16 @@ def get_destination( metadata: dict, override_config_defaults: bool = False, ) -> DestinationType: + from elementary.messages.messaging_integrations.slack_web import ( + SlackWebMessagingIntegration, + ) + from elementary.messages.messaging_integrations.slack_webhook import ( + SlackWebhookMessagingIntegration, + ) + from elementary.messages.messaging_integrations.teams_webhook import ( + TeamsWebhookMessagingIntegration, + ) + if ( isinstance(integration, TeamsWebhookMessagingIntegration) and config.has_teams diff --git a/elementary/monitor/data_monitoring/alerts/integrations/slack/slack.py b/elementary/monitor/data_monitoring/alerts/integrations/slack/slack.py index a0e5ce30d..8e07822ee 100644 --- a/elementary/monitor/data_monitoring/alerts/integrations/slack/slack.py +++ b/elementary/monitor/data_monitoring/alerts/integrations/slack/slack.py @@ -3,11 +3,12 @@ from datetime import datetime, timedelta from typing import Any, Dict, List, Optional, Sequence, Union -from slack_sdk.models.blocks import SectionBlock - from elementary.clients.slack.client import SlackClient, SlackWebClient from elementary.clients.slack.schema import SlackBlocksType, SlackMessageSchema -from elementary.clients.slack.slack_message_builder import MessageColor +from elementary.clients.slack.slack_message_builder import ( + _SECTION_TEXT_MAX_LENGTH, + MessageColor, +) from elementary.config.config import Config from elementary.monitor.alerts.alerts_groups import AlertsGroup, GroupedByTableAlerts from elementary.monitor.alerts.alerts_groups.base_alerts_group import BaseAlertsGroup @@ -238,7 +239,7 @@ def _get_dbt_test_template( result.append(self.message_builder.create_context_block(["*Test query*"])) msg = f"```{alert.test_results_query}```" - if len(msg) > SectionBlock.text_max_length: + if len(msg) > _SECTION_TEXT_MAX_LENGTH: msg = ( f"_The test query was too long, here's a query to get it._\n" f"```SELECT test_results_query FROM {alert.elementary_database_and_schema}.elementary_test_results WHERE test_execution_id = '{alert.id}'```" diff --git a/elementary/monitor/data_monitoring/report/data_monitoring_report.py b/elementary/monitor/data_monitoring/report/data_monitoring_report.py index 3cf3120d4..829b6d223 100644 --- a/elementary/monitor/data_monitoring/report/data_monitoring_report.py +++ b/elementary/monitor/data_monitoring/report/data_monitoring_report.py @@ -11,9 +11,6 @@ from elementary.monitor.api.report.schema import ReportDataSchema from elementary.monitor.api.tests.tests import TestsAPI from elementary.monitor.data_monitoring.data_monitoring import DataMonitoring -from elementary.monitor.data_monitoring.report.slack_report_summary_message_builder import ( - SlackReportSummaryMessageBuilder, -) from elementary.monitor.data_monitoring.schema import FiltersSchema from elementary.tracking.anonymous_tracking import AnonymousTracking from elementary.tracking.tracking_interface import Tracking @@ -55,7 +52,7 @@ def __init__( ) self.gcs_client = None - if self.config.has_gcs: + if self.config.gcs_bucket_name: try: from elementary.clients.gcs.client import GCSClient @@ -342,6 +339,10 @@ def send_test_results_summary( dbt_invocation=invocation, ) if self.slack_client: + from elementary.monitor.data_monitoring.report.slack_report_summary_message_builder import ( + SlackReportSummaryMessageBuilder, + ) + send_succeeded = self.slack_client.send_message( channel_name=self.config.slack_channel_name, message=SlackReportSummaryMessageBuilder().get_slack_message( From 55ec667bcc661dbaeb1bfdd80ecf0d6ddf022e4e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 02:28:26 +0000 Subject: [PATCH 07/10] ci: install s3 and slack extras in CI to verify they work Co-Authored-By: Itamar Hartstein --- .github/workflows/test-warehouse.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-warehouse.yml b/.github/workflows/test-warehouse.yml index c43b1969f..ead53daac 100644 --- a/.github/workflows/test-warehouse.yml +++ b/.github/workflows/test-warehouse.yml @@ -216,9 +216,9 @@ jobs: # using ".[vertica]" would re-resolve dbt-vertica's deps and downgrade # dbt-core to ~=1.8. Install elementary without the adapter extra. if [ "${{ inputs.warehouse-type }}" = "vertica" ]; then - pip install "." + pip install ".[s3,slack]" else - pip install ".[${{ (inputs.warehouse-type == 'databricks_catalog' && 'databricks') || inputs.warehouse-type }}]" + pip install ".[${{ (inputs.warehouse-type == 'databricks_catalog' && 'databricks') || inputs.warehouse-type }},s3,slack]" fi - name: Write dbt profiles From 8d505913eacd49fa34537c50d2b17ed5125ed192 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 02:32:53 +0000 Subject: [PATCH 08/10] ci: install all integration extras (s3, gcs, azure, slack, teams) in CI Co-Authored-By: Itamar Hartstein --- .github/workflows/test-warehouse.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-warehouse.yml b/.github/workflows/test-warehouse.yml index ead53daac..9cf91171b 100644 --- a/.github/workflows/test-warehouse.yml +++ b/.github/workflows/test-warehouse.yml @@ -216,9 +216,9 @@ jobs: # using ".[vertica]" would re-resolve dbt-vertica's deps and downgrade # dbt-core to ~=1.8. Install elementary without the adapter extra. if [ "${{ inputs.warehouse-type }}" = "vertica" ]; then - pip install ".[s3,slack]" + pip install ".[s3,gcs,azure,slack,teams]" else - pip install ".[${{ (inputs.warehouse-type == 'databricks_catalog' && 'databricks') || inputs.warehouse-type }},s3,slack]" + pip install ".[${{ (inputs.warehouse-type == 'databricks_catalog' && 'databricks') || inputs.warehouse-type }},s3,gcs,azure,slack,teams]" fi - name: Write dbt profiles From fa0c1183816c0d8fa636fb27fe814f8c9dc28478 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 02:35:32 +0000 Subject: [PATCH 09/10] ci: remove teams extra from CI (not used in workflow) Co-Authored-By: Itamar Hartstein --- .github/workflows/test-warehouse.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-warehouse.yml b/.github/workflows/test-warehouse.yml index 9cf91171b..68223afff 100644 --- a/.github/workflows/test-warehouse.yml +++ b/.github/workflows/test-warehouse.yml @@ -216,9 +216,9 @@ jobs: # using ".[vertica]" would re-resolve dbt-vertica's deps and downgrade # dbt-core to ~=1.8. Install elementary without the adapter extra. if [ "${{ inputs.warehouse-type }}" = "vertica" ]; then - pip install ".[s3,gcs,azure,slack,teams]" + pip install ".[s3,gcs,azure,slack]" else - pip install ".[${{ (inputs.warehouse-type == 'databricks_catalog' && 'databricks') || inputs.warehouse-type }},s3,gcs,azure,slack,teams]" + pip install ".[${{ (inputs.warehouse-type == 'databricks_catalog' && 'databricks') || inputs.warehouse-type }},s3,gcs,azure,slack]" fi - name: Write dbt profiles From e3e06788b6867989ebc4ef3dab91af34b068399a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 02:36:32 +0000 Subject: [PATCH 10/10] chore: rename teams extra to msteams Co-Authored-By: Itamar Hartstein --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e1ffec99d..1f232a854 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -88,7 +88,7 @@ s3 = [] gcs = [] azure = [] slack = [] -teams = [] +msteams = [] # dbt-fabricspark is excluded due to broken upstream dependencies (azure-cli pre-release pins). # dbt-vertica is excluded because it pins dbt-core==1.8.5, forcing the entire resolution to dbt 1.8.