Skip to content

Commit 54bdef6

Browse files
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 <haritamar@gmail.com>
1 parent ed170b7 commit 54bdef6

14 files changed

Lines changed: 158 additions & 67 deletions

File tree

elementary/clients/azure/client.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
from os import path
22
from typing import Optional, Tuple
33

4-
from azure.storage.blob import BlobServiceClient
5-
64
from elementary.config.config import Config
75
from elementary.tracking.tracking_interface import Tracking
6+
from elementary.utils.deps import import_optional_dependency
87
from elementary.utils.log import get_logger
98

109
logger = get_logger(__name__)
@@ -13,7 +12,8 @@
1312
class AzureClient:
1413
def __init__(self, config: Config, tracking: Optional[Tracking] = None):
1514
self.config = config
16-
self.blob_service_client = BlobServiceClient.from_connection_string(
15+
azure_blob = import_optional_dependency("azure.storage.blob", "azure")
16+
self.blob_service_client = azure_blob.BlobServiceClient.from_connection_string(
1717
self.config.azure_connection_string
1818
)
1919
self.tracking = tracking

elementary/clients/gcs/client.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,10 @@
22
from typing import Optional, Tuple
33
from urllib.parse import urljoin
44

5-
import google # type: ignore[import]
6-
from google.auth.credentials import Credentials # type: ignore[import]
7-
from google.cloud import storage # type: ignore[attr-defined, import]
8-
from google.oauth2 import service_account # type: ignore[import]
9-
105
from elementary.config.config import Config
116
from elementary.tracking.tracking_interface import Tracking
127
from elementary.utils import bucket_path
8+
from elementary.utils.deps import import_optional_dependency
139
from elementary.utils.log import get_logger
1410

1511
logger = get_logger(__name__)
@@ -87,16 +83,21 @@ def get_bucket_website_url(
8783
return bucket_website_url
8884

8985
def get_client(self, config: Config):
86+
storage = import_optional_dependency("google.cloud.storage", "gcs")
9087
creds = self.get_credentials(config)
9188
if config.google_project_name:
9289
return storage.Client(config.google_project_name, credentials=creds)
9390
return storage.Client(credentials=creds)
9491

9592
@staticmethod
96-
def get_credentials(config: Config) -> Credentials:
93+
def get_credentials(config: Config):
9794
if config.google_service_account_path:
95+
service_account = import_optional_dependency(
96+
"google.oauth2.service_account", "gcs"
97+
)
9898
return service_account.Credentials.from_service_account_file(
9999
config.google_service_account_path
100100
)
101-
credentials, _ = google.auth.default()
101+
google_auth = import_optional_dependency("google.auth", "gcs")
102+
credentials, _ = google_auth.default()
102103
return credentials

elementary/clients/s3/client.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
from os import path
22
from typing import Optional, Tuple
33

4-
import boto3
5-
64
from elementary.config.config import Config
75
from elementary.tracking.tracking_interface import Tracking
86
from elementary.utils import bucket_path
7+
from elementary.utils.deps import import_optional_dependency
98
from elementary.utils.log import get_logger
109

1110
logger = get_logger(__name__)
@@ -14,6 +13,7 @@
1413
class S3Client:
1514
def __init__(self, config: Config, tracking: Optional[Tracking] = None):
1615
self.config = config
16+
boto3 = import_optional_dependency("boto3", "s3")
1717
aws_session = boto3.Session(
1818
profile_name=config.aws_profile_name,
1919
region_name=config.aws_region_name,

elementary/clients/slack/client.py

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
1+
from __future__ import annotations
2+
13
import json
24
import ssl
35
from abc import ABC, abstractmethod
4-
from typing import Dict, List, Optional, Tuple, Union
6+
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union
7+
8+
if TYPE_CHECKING:
9+
from slack_sdk.errors import SlackApiError
10+
from slack_sdk.webhook import WebhookResponse
511

612
import requests
713
from ratelimit import limits, sleep_and_retry
8-
from slack_sdk import WebClient, WebhookClient
9-
from slack_sdk.errors import SlackApiError
10-
from slack_sdk.http_retry.builtin_handlers import RateLimitErrorRetryHandler
11-
from slack_sdk.webhook.webhook_response import WebhookResponse
1214

1315
from elementary.clients.slack.schema import SlackMessageSchema
1416
from elementary.config.config import Config
1517
from elementary.tracking.tracking_interface import Tracking
18+
from elementary.utils.deps import import_optional_dependency
1619
from elementary.utils.log import get_logger
1720
from elementary.utils.ssl import create_ssl_context
1821

@@ -59,7 +62,10 @@ def _initial_client(self, ssl_context: Optional[ssl.SSLContext]):
5962
raise NotImplementedError
6063

6164
def _initial_retry_handlers(self):
62-
if isinstance(self.client, WebClient):
65+
slack_sdk = import_optional_dependency("slack_sdk", "slack")
66+
if isinstance(self.client, slack_sdk.WebClient):
67+
from slack_sdk.http_retry.builtin_handlers import RateLimitErrorRetryHandler
68+
6369
rate_limit_handler = RateLimitErrorRetryHandler(max_retry_count=5)
6470
self.client.retry_handlers.append(rate_limit_handler)
6571

@@ -96,13 +102,16 @@ def __init__(
96102
super().__init__(tracking, ssl_context)
97103

98104
def _initial_client(self, ssl_context: Optional[ssl.SSLContext]):
99-
return WebClient(token=self.token, ssl=ssl_context)
105+
slack_sdk = import_optional_dependency("slack_sdk", "slack")
106+
return slack_sdk.WebClient(token=self.token, ssl=ssl_context)
100107

101108
@sleep_and_retry
102109
@limits(calls=1, period=ONE_SECOND)
103110
def send_message(
104111
self, channel_name: str, message: SlackMessageSchema, **kwargs
105112
) -> bool:
113+
from slack_sdk.errors import SlackApiError
114+
106115
try:
107116
self.client.chat_postMessage(
108117
channel=channel_name,
@@ -128,6 +137,8 @@ def send_file(
128137
file_path: str,
129138
message: Optional[SlackMessageSchema] = None,
130139
) -> bool:
140+
from slack_sdk.errors import SlackApiError
141+
131142
channel_id = self._get_channel_id(channel_name)
132143
try:
133144
self.client.files_upload_v2(
@@ -157,6 +168,8 @@ def send_report(self, channel_name: str, report_file_path: str):
157168
@sleep_and_retry
158169
@limits(calls=50, period=ONE_MINUTE)
159170
def get_user_id_from_email(self, email: str) -> Optional[str]:
171+
from slack_sdk.errors import SlackApiError
172+
160173
try:
161174
if email not in self.email_to_user_id_cache:
162175
user_id = self.client.users_lookupByEmail(email=email)["user"]["id"]
@@ -197,6 +210,8 @@ def _get_channel_id(self, channel_name: str) -> Optional[str]:
197210
return None
198211

199212
def _join_channel(self, channel_id: str) -> bool:
213+
from slack_sdk.errors import SlackApiError
214+
200215
try:
201216
self.client.conversations_join(channel=channel_id)
202217
logger.info("Elementary app joined the channel successfully.")
@@ -249,7 +264,8 @@ def _initial_client(self, ssl_context: Optional[ssl.SSLContext]):
249264
# requests.Session() uses the requests default CA bundle (certifi).
250265
return requests.Session()
251266

252-
return WebhookClient(
267+
slack_sdk = import_optional_dependency("slack_sdk", "slack")
268+
return slack_sdk.WebhookClient(
253269
url=self.webhook,
254270
default_headers={"Content-type": "application/json"},
255271
ssl=ssl_context,

elementary/clients/slack/slack_message_builder.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
from enum import Enum
22
from typing import List, Optional, Union
33

4-
from slack_sdk.models.blocks import HeaderBlock, SectionBlock
5-
64
from elementary.clients.slack.schema import SlackBlocksType, SlackMessageSchema
75
from elementary.utils.json_utils import unpack_and_flatten_str_to_list
86
from elementary.utils.pydantic_shim import BaseModel
97

8+
# Slack Block Kit limits (avoid module-level slack_sdk import)
9+
_HEADER_TEXT_MAX_LENGTH = 150
10+
_SECTION_TEXT_MAX_LENGTH = 3000
11+
1012

1113
class OptionSchema(BaseModel):
1214
value: str
@@ -56,11 +58,11 @@ def _add_blocks_as_attachments(self, blocks: SlackBlocksType):
5658

5759
@staticmethod
5860
def get_limited_markdown_msg(section_msg: str) -> str:
59-
if len(section_msg) < SectionBlock.text_max_length:
61+
if len(section_msg) < _SECTION_TEXT_MAX_LENGTH:
6062
return section_msg
6163
return (
6264
section_msg[
63-
: SectionBlock.text_max_length
65+
: _SECTION_TEXT_MAX_LENGTH
6466
- len(SlackMessageBuilder._CONTINUATION_SYMBOL)
6567
- SlackMessageBuilder._LONGEST_MARKDOWN_SUFFIX_LEN
6668
]
@@ -120,8 +122,8 @@ def create_context_block(context_msgs: list) -> dict:
120122

121123
@staticmethod
122124
def create_header_block(msg: str) -> dict:
123-
if len(msg) > HeaderBlock.text_max_length:
124-
final_msg = msg[: HeaderBlock.text_max_length - 3] + "..."
125+
if len(msg) > _HEADER_TEXT_MAX_LENGTH:
126+
final_msg = msg[: _HEADER_TEXT_MAX_LENGTH - 3] + "..."
125127
else:
126128
final_msg = msg
127129

elementary/config/config.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,7 @@
22
from pathlib import Path
33
from typing import Optional
44

5-
import google.auth # type: ignore[import]
65
from dateutil import tz
7-
from google.auth.exceptions import DefaultCredentialsError # type: ignore[import]
86

97
from elementary.exceptions.exceptions import InvalidArgumentsError
108
from elementary.monitor.alerts.grouping_type import GroupingType
@@ -265,9 +263,15 @@ def has_gcloud(self):
265263
if self.google_service_account_path:
266264
return True
267265
try:
268-
google.auth.default()
266+
from elementary.utils.deps import import_optional_dependency
267+
268+
google_auth = import_optional_dependency("google.auth", "gcs")
269+
google_auth.default()
269270
return True
270-
except DefaultCredentialsError:
271+
except ImportError:
272+
return False
273+
except Exception:
274+
# google.auth.exceptions.DefaultCredentialsError or similar
271275
return False
272276

273277
@property

elementary/messages/formats/block_kit.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import json
22
from typing import Any, Callable, List, Optional, Tuple
33

4-
from slack_sdk.models import blocks as slack_blocks
54
from tabulate import tabulate
65

76
from elementary.messages.blocks import (
@@ -105,11 +104,15 @@ def _format_table_cell(self, cell_value: Any, column_count: int) -> str:
105104
return value[: max_cell_length - 2] + ".."
106105
return value
107106

107+
# Slack Block Kit limits (avoid module-level slack_sdk import)
108+
_SECTION_TEXT_MAX_LENGTH = 3000
109+
_HEADER_TEXT_MAX_LENGTH = 150
110+
108111
def _format_markdown_section_text(self, text: str) -> dict:
109-
if len(text) > slack_blocks.SectionBlock.text_max_length:
112+
if len(text) > self._SECTION_TEXT_MAX_LENGTH:
110113
text = (
111114
text[
112-
: slack_blocks.SectionBlock.text_max_length
115+
: self._SECTION_TEXT_MAX_LENGTH
113116
- len("...")
114117
- self._LONGEST_MARKDOWN_SUFFIX_LEN
115118
]
@@ -198,8 +201,8 @@ def _add_lines_block(self, block: LinesBlock) -> None:
198201
self._add_block(self._format_markdown_section("\n".join(formatted_lines)))
199202

200203
def _add_header_block(self, block: HeaderBlock) -> None:
201-
if len(block.text) > slack_blocks.HeaderBlock.text_max_length:
202-
text = block.text[: slack_blocks.HeaderBlock.text_max_length - 3] + "..."
204+
if len(block.text) > self._HEADER_TEXT_MAX_LENGTH:
205+
text = block.text[: self._HEADER_TEXT_MAX_LENGTH - 3] + "..."
203206
else:
204207
text = block.text
205208
self._add_block(

elementary/messages/messaging_integrations/slack_web.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
1+
from __future__ import annotations
2+
13
import json
24
import ssl
35
import time
4-
from typing import Any, Dict, Iterator, Optional
6+
from typing import TYPE_CHECKING, Any, Dict, Iterator, Optional
57

68
from ratelimit import limits, sleep_and_retry
7-
from slack_sdk import WebClient
8-
from slack_sdk.errors import SlackApiError
9-
from slack_sdk.http_retry.builtin_handlers import RateLimitErrorRetryHandler
109
from typing_extensions import TypeAlias
1110

11+
if TYPE_CHECKING:
12+
from slack_sdk import WebClient
13+
from slack_sdk.errors import SlackApiError
14+
1215
from elementary.messages.formats.block_kit import (
1316
FormattedBlockKitMessage,
1417
format_block_kit,
@@ -22,6 +25,7 @@
2225
MessagingIntegrationError,
2326
)
2427
from elementary.tracking.tracking_interface import Tracking
28+
from elementary.utils.deps import import_optional_dependency
2529
from elementary.utils.log import get_logger
2630
from elementary.utils.pydantic_shim import BaseModel
2731

@@ -61,7 +65,10 @@ def from_token(
6165
ssl_context: Optional[ssl.SSLContext] = None,
6266
**kwargs: Any,
6367
) -> "SlackWebMessagingIntegration":
64-
client = WebClient(token=token, ssl=ssl_context)
68+
slack_sdk = import_optional_dependency("slack_sdk", "slack")
69+
from slack_sdk.http_retry.builtin_handlers import RateLimitErrorRetryHandler
70+
71+
client = slack_sdk.WebClient(token=token, ssl=ssl_context)
6572
client.retry_handlers.append(RateLimitErrorRetryHandler(max_retry_count=5))
6673
return cls(client, tracking, **kwargs)
6774

@@ -103,6 +110,8 @@ def _send_message(
103110
thread_ts: Optional[str] = None,
104111
reply_broadcast: bool = False,
105112
) -> MessageSendResult[SlackWebMessageContext]:
113+
from slack_sdk.errors import SlackApiError
114+
106115
try:
107116
response = self.client.chat_postMessage(
108117
channel=destination,
@@ -180,6 +189,8 @@ def _get_channel_id(self, channel_name: str, only_public: bool = False) -> str:
180189
raise MessagingIntegrationError(f"Channel {channel_name} not found")
181190

182191
def _join_channel(self, channel_id: str) -> None:
192+
from slack_sdk.errors import SlackApiError
193+
183194
try:
184195
self.client.conversations_join(channel=channel_id)
185196
except SlackApiError as e:
@@ -190,6 +201,8 @@ def _join_channel(self, channel_id: str) -> None:
190201
@sleep_and_retry
191202
@limits(calls=50, period=ONE_MINUTE)
192203
def get_user_id_from_email(self, email: str) -> Optional[str]:
204+
from slack_sdk.errors import SlackApiError
205+
193206
if email in self._email_to_user_id_cache:
194207
return self._email_to_user_id_cache[email]
195208
try:

elementary/messages/messaging_integrations/slack_webhook.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1+
from __future__ import annotations
2+
13
import ssl
24
from datetime import datetime, timezone
35
from http import HTTPStatus
4-
from typing import Any, Optional
6+
from typing import TYPE_CHECKING, Any, Optional
57

68
from ratelimit import limits, sleep_and_retry
7-
from slack_sdk import WebhookClient
8-
from slack_sdk.http_retry.builtin_handlers import RateLimitErrorRetryHandler
9+
10+
if TYPE_CHECKING:
11+
from slack_sdk import WebhookClient
912

1013
from elementary.messages.formats.block_kit import (
1114
FormattedBlockKitMessage,
@@ -23,6 +26,7 @@
2326
MessagingIntegrationError,
2427
)
2528
from elementary.tracking.tracking_interface import Tracking
29+
from elementary.utils.deps import import_optional_dependency
2630

2731
ONE_SECOND = 1
2832

@@ -43,7 +47,10 @@ def from_url(
4347
tracking: Optional[Tracking] = None,
4448
ssl_context: Optional[ssl.SSLContext] = None,
4549
) -> "SlackWebhookMessagingIntegration":
46-
client = WebhookClient(url, ssl=ssl_context)
50+
slack_sdk = import_optional_dependency("slack_sdk", "slack")
51+
from slack_sdk.http_retry.builtin_handlers import RateLimitErrorRetryHandler
52+
53+
client = slack_sdk.WebhookClient(url, ssl=ssl_context)
4754
client.retry_handlers.append(RateLimitErrorRetryHandler(max_retry_count=5))
4855
return cls(client, tracking)
4956

0 commit comments

Comments
 (0)