bw-essentials-core 0.1.3__tar.gz → 0.1.42__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. {bw_essentials_core-0.1.3 → bw_essentials_core-0.1.42}/PKG-INFO +1 -1
  2. bw_essentials_core-0.1.42/bw_essentials/EventNotification/EventBridgePublisher.py +106 -0
  3. bw_essentials_core-0.1.42/bw_essentials/EventNotification/NotificationType.py +314 -0
  4. {bw_essentials_core-0.1.3 → bw_essentials_core-0.1.42}/bw_essentials/constants/services.py +15 -0
  5. {bw_essentials_core-0.1.3 → bw_essentials_core-0.1.42}/bw_essentials/email_client/email_client.py +31 -18
  6. bw_essentials_core-0.1.42/bw_essentials/services/__init__.py +0 -0
  7. {bw_essentials_core-0.1.3 → bw_essentials_core-0.1.42}/bw_essentials/services/api_client.py +50 -1
  8. {bw_essentials_core-0.1.3 → bw_essentials_core-0.1.42}/bw_essentials/services/broker.py +61 -4
  9. bw_essentials_core-0.1.42/bw_essentials/services/compliance.py +78 -0
  10. bw_essentials_core-0.1.42/bw_essentials/services/job_scheduler.py +62 -0
  11. {bw_essentials_core-0.1.3 → bw_essentials_core-0.1.42}/bw_essentials/services/market_pricer.py +71 -17
  12. {bw_essentials_core-0.1.3 → bw_essentials_core-0.1.42}/bw_essentials/services/master_data.py +49 -0
  13. {bw_essentials_core-0.1.3 → bw_essentials_core-0.1.42}/bw_essentials/services/model_portfolio_reporting.py +30 -1
  14. {bw_essentials_core-0.1.3 → bw_essentials_core-0.1.42}/bw_essentials/services/notification.py +19 -15
  15. bw_essentials_core-0.1.42/bw_essentials/services/payment.py +84 -0
  16. bw_essentials_core-0.1.42/bw_essentials/services/portfolio_catalogue.py +272 -0
  17. bw_essentials_core-0.1.42/bw_essentials/services/portfolio_content.py +93 -0
  18. {bw_essentials_core-0.1.3 → bw_essentials_core-0.1.42}/bw_essentials/services/trade_placement.py +262 -9
  19. {bw_essentials_core-0.1.3 → bw_essentials_core-0.1.42}/bw_essentials/services/user_app.py +87 -5
  20. bw_essentials_core-0.1.42/bw_essentials/services/user_portfolio.py +1148 -0
  21. {bw_essentials_core-0.1.3 → bw_essentials_core-0.1.42}/bw_essentials/services/user_portfolio_reporting.py +37 -1
  22. bw_essentials_core-0.1.42/bw_essentials/services/user_referrals.py +251 -0
  23. {bw_essentials_core-0.1.3 → bw_essentials_core-0.1.42}/bw_essentials_core.egg-info/PKG-INFO +1 -1
  24. {bw_essentials_core-0.1.3 → bw_essentials_core-0.1.42}/bw_essentials_core.egg-info/SOURCES.txt +9 -0
  25. {bw_essentials_core-0.1.3 → bw_essentials_core-0.1.42}/setup.py +1 -1
  26. bw_essentials_core-0.1.3/bw_essentials/services/user_portfolio.py +0 -406
  27. {bw_essentials_core-0.1.3 → bw_essentials_core-0.1.42}/README.md +0 -0
  28. {bw_essentials_core-0.1.3/bw_essentials → bw_essentials_core-0.1.42/bw_essentials/EventNotification}/__init__.py +0 -0
  29. {bw_essentials_core-0.1.3/bw_essentials/constants → bw_essentials_core-0.1.42/bw_essentials}/__init__.py +0 -0
  30. {bw_essentials_core-0.1.3/bw_essentials/data_loch → bw_essentials_core-0.1.42/bw_essentials/constants}/__init__.py +0 -0
  31. {bw_essentials_core-0.1.3/bw_essentials/email_client → bw_essentials_core-0.1.42/bw_essentials/data_loch}/__init__.py +0 -0
  32. {bw_essentials_core-0.1.3 → bw_essentials_core-0.1.42}/bw_essentials/data_loch/data_loch.py +0 -0
  33. {bw_essentials_core-0.1.3/bw_essentials/notifications → bw_essentials_core-0.1.42/bw_essentials/email_client}/__init__.py +0 -0
  34. {bw_essentials_core-0.1.3/bw_essentials/s3_utils → bw_essentials_core-0.1.42/bw_essentials/notifications}/__init__.py +0 -0
  35. {bw_essentials_core-0.1.3 → bw_essentials_core-0.1.42}/bw_essentials/notifications/teams_notification_schemas.py +0 -0
  36. {bw_essentials_core-0.1.3 → bw_essentials_core-0.1.42}/bw_essentials/notifications/teams_notifications.py +0 -0
  37. {bw_essentials_core-0.1.3/bw_essentials/services → bw_essentials_core-0.1.42/bw_essentials/s3_utils}/__init__.py +0 -0
  38. {bw_essentials_core-0.1.3 → bw_essentials_core-0.1.42}/bw_essentials/s3_utils/s3_utils.py +0 -0
  39. {bw_essentials_core-0.1.3 → bw_essentials_core-0.1.42}/bw_essentials_core.egg-info/dependency_links.txt +0 -0
  40. {bw_essentials_core-0.1.3 → bw_essentials_core-0.1.42}/bw_essentials_core.egg-info/requires.txt +0 -0
  41. {bw_essentials_core-0.1.3 → bw_essentials_core-0.1.42}/bw_essentials_core.egg-info/top_level.txt +0 -0
  42. {bw_essentials_core-0.1.3 → bw_essentials_core-0.1.42}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bw-essentials-core
3
- Version: 0.1.3
3
+ Version: 0.1.42
4
4
  Summary: Reusable utilities for S3, email, Data Loch, Microsoft Teams Notifications and more.
5
5
  Author: InvestorAI
6
6
  Author-email: support+tech@investorai.in
@@ -0,0 +1,106 @@
1
+ """
2
+ Event notification module for publishing events to AWS EventBridge.
3
+
4
+ This module provides functionality to send events to AWS EventBridge for
5
+ asynchronous processing and integration with other AWS services.
6
+ """
7
+ import json
8
+ import logging
9
+ import os
10
+ import sys
11
+ from importlib.util import spec_from_file_location, module_from_spec
12
+ from ..notifications.teams_notifications import Notifications as ErrorNotifications
13
+
14
+ import boto3
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class EventBridgePublisher(object):
20
+ """
21
+ Handles publishing events to AWS EventBridge.
22
+
23
+ This class manages AWS credentials from environment configuration and provides
24
+ methods to publish events to an AWS EventBridge event bus for downstream processing.
25
+ """
26
+
27
+ def __init__(self):
28
+ self.region_name = self._get_env_var("AWS_REGION")
29
+ self.aws_access_key_id = self._get_env_var("AWS_ACCESS_KEY_ID")
30
+ self.aws_secret_access_key = self._get_env_var("AWS_SECRET_ACCESS_KEY")
31
+
32
+ def _get_env_var(self, key: str) -> str:
33
+ """
34
+ Fetch a required variable from bw_config.py located in the root directory.
35
+
36
+ Raises:
37
+ FileNotFoundError: If bw_config.py is not found.
38
+ AttributeError: If the requested key is not defined in the config.
39
+
40
+ Returns:
41
+ str: The value of the config variable.
42
+ """
43
+ config_path = os.path.join(os.getcwd(), "bw_config.py")
44
+
45
+ if not os.path.exists(config_path):
46
+ raise FileNotFoundError("`bw_config.py` file not found in the root directory. "
47
+ "Please ensure the config file exists.")
48
+
49
+ spec = spec_from_file_location("bw_config", config_path)
50
+ bw_config = module_from_spec(spec)
51
+ sys.modules["bw_config"] = bw_config
52
+ spec.loader.exec_module(bw_config)
53
+
54
+ if not hasattr(bw_config, key):
55
+ raise AttributeError(f"`{key}` not found in bw_config.py. Please define it in the config.")
56
+
57
+ return getattr(bw_config, key)
58
+
59
+ def send_event_notification(self, event, source='user-event'):
60
+ """
61
+ Publish an event to AWS EventBridge.
62
+
63
+ This method sends an event to the configured AWS EventBridge event bus.
64
+ If an error occurs during publishing, it sends an error notification
65
+ via Teams.
66
+
67
+ Args:
68
+ event (dict): The event payload to publish. Will be JSON-serialized.
69
+ source (str, optional): The event source identifier. Defaults to 'user-event'.
70
+
71
+ Note:
72
+ - Requires AWS_EVENT_BUS_ARN configuration variable
73
+ - Errors are logged and sent as Teams notifications
74
+ - AWS client connection is properly closed after use
75
+ """
76
+ try:
77
+ logger.info(f"In publish event")
78
+ # Create AWS EventBridge client with configured credentials
79
+ client = boto3.client("events",
80
+ region_name=self.region_name,
81
+ aws_access_key_id=self.aws_access_key_id,
82
+ aws_secret_access_key=self.aws_secret_access_key)
83
+ event_bus_arn = self._get_env_var("AWS_EVENT_BUS_ARN")
84
+
85
+ if not event_bus_arn:
86
+ raise ValueError("EventBusName is empty — invalid configuration.")
87
+
88
+ # Publish event to EventBridge
89
+ response = client.put_events(
90
+ Entries=[
91
+ {
92
+ 'Source': source,
93
+ 'DetailType': 'user-preferences',
94
+ 'Detail': json.dumps(event),
95
+ 'EventBusName': event_bus_arn
96
+ }
97
+ ]
98
+ )
99
+ client.close()
100
+ logger.info(f"Event published - {response = }")
101
+ except Exception as e:
102
+ # Notify error via Teams on publishing failure
103
+ error_notifications = ErrorNotifications('BW-essentials_error')
104
+ error_notifications.notify_error(
105
+ message="error via BW essentials while sending a event notification" + str(e),
106
+ summary=f"event publish error {event = }")
@@ -0,0 +1,314 @@
1
+ """
2
+ Notification type definitions for different event types.
3
+
4
+ This module defines structured notification types for various business events
5
+ such as subscriptions, investments, withdrawals, and alerts.
6
+ """
7
+ import logging
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class NotificationType:
13
+ """
14
+ Defines all available notification event types and their structures.
15
+
16
+ This class provides methods to create standardized event payloads for
17
+ different types of user actions and system events. Each method returns
18
+ a dictionary with a consistent schema including version, broker info,
19
+ event name, and event-specific data.
20
+ """
21
+
22
+ def __init__(self):
23
+ self.profit_target_event_name = "profit_target"
24
+ self.stop_loss_alert_event_name = "stop_loss_alert"
25
+
26
+ def available_notifications(self):
27
+ """
28
+ Get a list of all available notification methods.
29
+
30
+ Returns:
31
+ list: Names of all public notification methods in this class.
32
+ """
33
+ return [m for m in dir(self) if
34
+ callable(getattr(self, m)) and m != 'available_notifications' and (not m.startswith('_'))]
35
+
36
+ def subscription_successful(self,
37
+ user_id: str,
38
+ phone_number: str,
39
+ basket_name: str,
40
+ basket_fee: float,
41
+ transaction_amount: float,
42
+ start_date: str,
43
+ expiry_date: str,
44
+ broker_name: str,
45
+ product: str,
46
+ platform: str):
47
+ """
48
+ Create a subscription successful event notification.
49
+
50
+ Args:
51
+ user_id (str): The ID of the user who subscribed.
52
+ phone_number (str): User's phone number.
53
+ basket_name (str): Name of the basket subscribed to.
54
+ basket_fee (float): Fee charged for the basket.
55
+ transaction_amount (float): Total transaction amount.
56
+ start_date (str): Subscription start date.
57
+ expiry_date (str): Subscription expiry date.
58
+ broker_name (str): Name of the broker handling the subscription.
59
+ product (str): Product identifier.
60
+ platform (str): Platform identifier.
61
+
62
+ Returns:
63
+ dict: Structured event payload with subscription details.
64
+ """
65
+ result = {"version": "2",
66
+ "broker": broker_name,
67
+ "event_name": "subscription_successful",
68
+ "user_id": user_id,
69
+ "product": product,
70
+ "platform": platform,
71
+ "data": {
72
+ "phone_number": phone_number,
73
+ "basket_name": basket_name,
74
+ "basket_fee": basket_fee,
75
+ "transaction_amount": transaction_amount,
76
+ "start_date": start_date,
77
+ "expiry_date": expiry_date,
78
+ }
79
+ }
80
+ return result
81
+
82
+ def subscription_cancelled(self,
83
+ user_id: str,
84
+ broker_name: str,
85
+ product: str,
86
+ platform: str
87
+ ):
88
+ """
89
+ Create a subscription cancelled event notification.
90
+
91
+ Args:
92
+ user_id (str): The ID of the user whose subscription was cancelled.
93
+ broker_name (str): Name of the broker handling the subscription.
94
+ product (str): Product identifier.
95
+ platform (str): Platform identifier.
96
+
97
+ Returns:
98
+ dict: Structured event payload indicating subscription cancellation.
99
+ """
100
+ result = {
101
+ "version": "2",
102
+ "broker": broker_name,
103
+ "event_name": "subscription_cancelled",
104
+ "user_id": user_id,
105
+ "product": product,
106
+ "platform": platform,
107
+ "data": {}
108
+ }
109
+ return result
110
+
111
+ def investment_successful(self,
112
+ user_id: str,
113
+ phone_number: str,
114
+ basket_name: str,
115
+ basket_fee: float,
116
+ transaction_amount: float,
117
+ investment_amount: float,
118
+ broker_name: str,
119
+ product: str,
120
+ platform: str
121
+ ):
122
+ """
123
+ Create an investment successful event notification.
124
+
125
+ Args:
126
+ user_id (str): The ID of the user who made the investment.
127
+ phone_number (str): User's phone number.
128
+ basket_name (str): Name of the basket invested in.
129
+ basket_fee (float): Fee charged for the basket.
130
+ transaction_amount (float): Total transaction amount.
131
+ investment_amount (float): The amount invested.
132
+ broker_name (str): Name of the broker handling the investment.
133
+ product (str): Product identifier.
134
+ platform (str): Platform identifier.
135
+
136
+ Returns:
137
+ dict: Structured event payload with investment details.
138
+ """
139
+ result = {
140
+ "version": "2",
141
+ "broker": broker_name,
142
+ "event_name": "investment_successful",
143
+ "user_id": user_id,
144
+ "product": product,
145
+ "platform": platform,
146
+ "data": {
147
+ "phone_number": phone_number,
148
+ "basket_name": basket_name,
149
+ "basket_fee": basket_fee,
150
+ "transaction_amount": transaction_amount,
151
+ "investment_amount": investment_amount,
152
+ }
153
+ }
154
+ return result
155
+
156
+ def withdrawal_successful(self,
157
+ user_id: str,
158
+ phone_number: str,
159
+ basket_name: str,
160
+ sell_trade_value: float,
161
+ broker_name: str,
162
+ product: str,
163
+ platform: str
164
+ ):
165
+ """
166
+ Create a withdrawal successful event notification.
167
+
168
+ Args:
169
+ user_id (str): The ID of the user who made the withdrawal.
170
+ phone_number (str): User's phone number.
171
+ basket_name (str): Name of the basket withdrawn from.
172
+ sell_trade_value (float): The value of the sold trade.
173
+ broker_name (str): Name of the broker handling the withdrawal.
174
+ product (str): Product identifier.
175
+ platform (str): Platform identifier.
176
+
177
+ Returns:
178
+ dict: Structured event payload with withdrawal details.
179
+ """
180
+ result = {
181
+ "version": "2",
182
+ "broker": broker_name,
183
+ "event_name": "withdrawal_successful",
184
+ "user_id": user_id,
185
+ "product": product,
186
+ "platform": platform,
187
+ "data": {
188
+ "sell_trade_value": sell_trade_value,
189
+ "phone_number": phone_number,
190
+ "basket_name": basket_name,
191
+ }
192
+ }
193
+ return result
194
+
195
+ def loss_alert_updated(self,
196
+ user_id: str,
197
+ basket_name: str,
198
+ start_date: str,
199
+ stop_loss_limit: float,
200
+ broker_name: str,
201
+ product: str,
202
+ platform: str
203
+ ):
204
+ """
205
+ Create a loss alert updated event notification.
206
+
207
+ This event is triggered when a stop-loss limit is updated for a basket.
208
+
209
+ Args:
210
+ user_id (str): The ID of the user whose alert was updated.
211
+ basket_name (str): Name of the basket with the loss alert.
212
+ start_date (str): The start date associated with the alert.
213
+ stop_loss_limit (float): The stop-loss limit value.
214
+ broker_name (str): Name of the broker managing the alert.
215
+ product (str): Product identifier.
216
+ platform (str): Platform identifier.
217
+
218
+ Returns:
219
+ dict: Structured event payload with loss alert details.
220
+ """
221
+ result = {
222
+ "version": "2",
223
+ "broker": broker_name,
224
+ "event_name": "loss_alert_updated",
225
+ "user_id": user_id,
226
+ "product": product,
227
+ "platform": platform,
228
+ "data": {
229
+ "start_date": start_date,
230
+ "stop_loss_limit": stop_loss_limit,
231
+ "product": product,
232
+ "basket_name": basket_name
233
+ }
234
+ }
235
+ return result
236
+
237
+ def profit_target(
238
+ self,
239
+ user_id: str,
240
+ triggered_at: str,
241
+ target_percentage: float,
242
+ symbol: str,
243
+ broker_name: str,
244
+ product: str,
245
+ platform: str,
246
+ ) -> dict:
247
+ """
248
+ Build Profit Target notification payload.
249
+ """
250
+ logger.info(
251
+ "ProfitTarget payload build started | user=%s | symbol=%s",
252
+ user_id,
253
+ symbol,
254
+ )
255
+
256
+ result = {
257
+ "version": "2",
258
+ "broker": broker_name,
259
+ "event_name": self.profit_target_event_name,
260
+ "user_id": user_id,
261
+ "product": product,
262
+ "platform": platform,
263
+ "data": {
264
+ "triggered_at": triggered_at,
265
+ "target_percentage": target_percentage,
266
+ "symbol": symbol,
267
+ "product": product,
268
+ },
269
+ }
270
+
271
+ logger.info(
272
+ "ProfitTarget payload build completed | user=%s | symbol=%s | payload=%s",
273
+ user_id,
274
+ symbol,
275
+ result,
276
+ )
277
+
278
+ return result
279
+
280
+ def stop_loss_alert(
281
+ self,
282
+ user_id: str,
283
+ triggered_at: str,
284
+ target_percentage: float,
285
+ symbol: str,
286
+ broker_name: str,
287
+ product: str,
288
+ platform: str,
289
+ ) -> dict:
290
+ """
291
+ Build Stop Loss notification payload.
292
+ """
293
+ logger.info("StopLoss payload build started | user=%s | symbol=%s", user_id, symbol)
294
+ result = {
295
+ "version": "2",
296
+ "broker": broker_name,
297
+ "event_name": self.stop_loss_alert_event_name,
298
+ "user_id": user_id,
299
+ "product": product,
300
+ "platform": platform,
301
+ "data": {
302
+ "triggered_at": triggered_at,
303
+ "target_percentage": target_percentage,
304
+ "symbol": symbol,
305
+ "product": product,
306
+ },
307
+ }
308
+
309
+ logger.info("StopLoss payload build completed | user=%s | symbol=%s | payload=%s", user_id,
310
+ symbol,
311
+ result,
312
+ )
313
+
314
+ return result
@@ -11,6 +11,7 @@ class Services(Enum):
11
11
  MARKET_PRICER = "Market_Pricer"
12
12
  BROKER = 'Broker'
13
13
  USER_PORTFOLIO = 'User_Portfolio'
14
+ FINN_USER_PORTFOLIO = 'Finn_User_Portfolio'
14
15
  TRADE_PLACEMENT = 'Trade_Placement'
15
16
  CONTENT = 'Portfolio_Content'
16
17
  NOTIFICATION = 'Notification'
@@ -18,3 +19,17 @@ class Services(Enum):
18
19
  PAYMENT = 'Payment'
19
20
  MODEL_PORTFOLIO = "Model_Portfolio"
20
21
  PROMETHEUS_USER_APP = "Prometheus_User_App"
22
+ PORTFOLIO_CATALOGUE = "Portfolio_Catalogue"
23
+ PORTFOLIO_CONTENT = "Portfolio_Content"
24
+ JOB_SCHEDULER = "Job_Scheduler"
25
+ COMPLIANCE = "Compliance"
26
+
27
+ class PortfolioStatus(Enum):
28
+ """
29
+ Enum representing Status of Basket.
30
+ """
31
+ ACTIVE = "active"
32
+ INACTIVE = "inactive"
33
+ PAUSED = "paused"
34
+
35
+ ACTIVE_FOR_SUBSCRIBED_USER = (ACTIVE, PAUSED)
@@ -132,7 +132,7 @@ class EmailClient:
132
132
  cc_addresses: Union[str, List[str], None],
133
133
  subject: str,
134
134
  body: str,
135
- attachment_path: str,
135
+ attachment_path: Optional[Union[str, List[str]]],
136
136
  is_html: bool = True,
137
137
  ):
138
138
  """
@@ -143,7 +143,7 @@ class EmailClient:
143
143
  cc_addresses (Union[str, List[str], None]): One or more CC addresses (optional).
144
144
  subject (str): Email subject line.
145
145
  body (str): Email body content.
146
- attachment_path (str): Full path to the file to attach.
146
+ attachment_path (Optional[Union[str, List[str]]]): File path(s) for attachments.
147
147
  is_html (bool): If True, body is interpreted as HTML; otherwise, plain text.
148
148
  """
149
149
  logger.info(
@@ -158,7 +158,7 @@ class EmailClient:
158
158
  cc_addresses: Union[str, List[str], None],
159
159
  subject: str,
160
160
  body: str,
161
- attachment_path: Optional[str],
161
+ attachment_path: Optional[Union[str, List[str]]],
162
162
  is_html: bool,
163
163
  ):
164
164
  """
@@ -169,7 +169,7 @@ class EmailClient:
169
169
  cc_addresses (Union[str, List[str], None]): CC addresses.
170
170
  subject (str): Subject of the email.
171
171
  body (str): Email body content.
172
- attachment_path (Optional[str]): File path for the attachment, if any.
172
+ attachment_path (Optional[Union[str, List[str]]]): File path(s) for attachments.
173
173
  is_html (bool): True if the body is HTML-formatted.
174
174
  """
175
175
  msg = MIMEMultipart()
@@ -180,23 +180,36 @@ class EmailClient:
180
180
 
181
181
  msg.attach(MIMEText(body, 'html' if is_html else 'plain'))
182
182
  logger.debug("Email headers and body constructed")
183
+ attachment_paths = []
183
184
 
184
185
  if attachment_path:
185
- if not os.path.exists(attachment_path):
186
- logger.warning("Attachment file not found: %s", attachment_path)
186
+ if isinstance(attachment_path, list):
187
+ attachment_paths = attachment_path
187
188
  else:
188
- try:
189
- with open(attachment_path, 'rb') as attachment:
190
- part = MIMEBase('application', 'octet-stream')
191
- part.set_payload(attachment.read())
192
- encoders.encode_base64(part)
193
- part.add_header('Content-Disposition', 'attachment',
194
- filename=os.path.basename(attachment_path))
195
- msg.attach(part)
196
- logger.info("Attachment added: %s", os.path.basename(attachment_path))
197
- except Exception as e:
198
- logger.exception("Failed to read attachment file")
199
- raise e
189
+ attachment_paths = [attachment_path]
190
+
191
+ for path in attachment_paths:
192
+ if not path:
193
+ continue
194
+ if not os.path.exists(path):
195
+ logger.warning("Attachment file not found: %s", path)
196
+ continue
197
+
198
+ try:
199
+ with open(path, 'rb') as attachment:
200
+ part = MIMEBase('application', 'octet-stream')
201
+ part.set_payload(attachment.read())
202
+ encoders.encode_base64(part)
203
+ part.add_header(
204
+ 'Content-Disposition',
205
+ 'attachment',
206
+ filename=os.path.basename(path)
207
+ )
208
+ msg.attach(part)
209
+ logger.info("Attachment added: %s", os.path.basename(path))
210
+ except Exception as e:
211
+ logger.exception("Failed to read attachment file: %s", path)
212
+ raise e
200
213
 
201
214
  try:
202
215
  with smtplib.SMTP(self.smtp_host, self.smtp_port) as server:
@@ -15,6 +15,7 @@ import logging
15
15
  import os
16
16
  import sys
17
17
  import time
18
+ import httpx
18
19
  from importlib.util import spec_from_file_location, module_from_spec
19
20
  from typing import Optional, Dict, Any
20
21
 
@@ -80,6 +81,19 @@ class ApiClient:
80
81
  env_key = f"{service_name.upper()}_BASE_URL"
81
82
  return self._get_env_var(env_key)
82
83
 
84
+ def get_api_key(self, service_name: str) -> str:
85
+ """
86
+ Resolve the service API Key for a given service name using environment variables.
87
+
88
+ Args:
89
+ service_name (str): The logical name of the service.
90
+
91
+ Returns:
92
+ str: The resolved api key from the environment.
93
+ """
94
+ env_key = f"{service_name.upper()}_API_KEY"
95
+ return self._get_env_var(env_key)
96
+
83
97
  def set_tenant_id(self, tenant_id: str) -> None:
84
98
  """
85
99
  Set the tenant ID in the request headers.
@@ -88,7 +102,7 @@ class ApiClient:
88
102
  tenant_id (str): The tenant identifier to include in the headers.
89
103
  """
90
104
  logger.info(f"Setting tenant ID: {tenant_id}")
91
- self._update_headers({"tenant_id": tenant_id})
105
+ self._update_headers({"X-Tenant-ID": tenant_id})
92
106
  logger.info(f"Updated headers: {self.headers}")
93
107
 
94
108
  def set_headers(self, headers: dict) -> None:
@@ -182,6 +196,41 @@ class ApiClient:
182
196
  """
183
197
  return self._request("get", url, endpoint, params=params)
184
198
 
199
+ async def _async_get(self, url: str, endpoint: str, headers: dict | None = None, params: dict | None = None):
200
+ """
201
+ Async GET request, aligned with the sync _request() style.
202
+ """
203
+ headers = self._update_headers(headers or {})
204
+ params = params or {}
205
+ formatted_url = f"{url.rstrip('/')}/{endpoint.lstrip('/')}"
206
+
207
+ logger.info(f"GET {formatted_url} | Headers: {headers} | Params: {params}")
208
+
209
+ start = time.time()
210
+ try:
211
+ async with httpx.AsyncClient() as client:
212
+ response = await client.get(formatted_url, headers=headers, params=params)
213
+
214
+ elapsed_time_ms = (time.time() - start) * 1000
215
+
216
+ # parse JSON safely
217
+ try:
218
+ json_data = response.json()
219
+ except ValueError:
220
+ logger.error(f"Non-JSON response from {formatted_url}")
221
+ json_data = None
222
+
223
+ self._log_response("GET", formatted_url, response.status_code, elapsed_time_ms, json_data)
224
+
225
+ response.raise_for_status()
226
+ return json_data
227
+
228
+ except Exception as exc:
229
+ elapsed_time_ms = (time.time() - start) * 1000
230
+ logger.error(f"GET {formatted_url} failed after {elapsed_time_ms:.2f}ms")
231
+ logger.exception(exc)
232
+ raise
233
+
185
234
  def _post(
186
235
  self,
187
236
  url: str,