ergon-framework-python 0.2.3__py3-none-any.whl → 0.2.4__py3-none-any.whl

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.
@@ -0,0 +1,410 @@
1
+ import base64
2
+ import logging
3
+ from pathlib import Path
4
+ from typing import Any, Dict, List, Optional
5
+ from urllib.parse import quote
6
+
7
+ import requests
8
+
9
+ from ..transaction import Transaction
10
+ from .models import (
11
+ OutlookEmailAddress,
12
+ OutlookFlagStatus,
13
+ OutlookGraphClient,
14
+ OutlookMessageQuery,
15
+ OutlookProducerConfig,
16
+ OutlookSendMessagePayload,
17
+ OutlookWellKnownFolder,
18
+ )
19
+ from .utils import message_to_transaction, normalize_send_payload
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ _OUTLOOK_IMPORT_ERROR = "Install with: pip install ergon-framework-python[outlook]"
24
+
25
+
26
+ def _graph_error_detail(response: requests.Response) -> str:
27
+ try:
28
+ payload = response.json()
29
+ except ValueError:
30
+ return (response.text or response.reason or "").strip()
31
+ error = payload.get("error") if isinstance(payload, dict) else None
32
+ if isinstance(error, dict):
33
+ message = error.get("message") or error.get("code")
34
+ if message:
35
+ return str(message)
36
+ return (response.reason or "").strip()
37
+
38
+
39
+ def _permission_hint(method: str, path: str) -> str:
40
+ target = f"{method} {path}".casefold()
41
+ if any(token in target for token in ("/sendmail", "/reply", "/replyall", "/forward")):
42
+ return "This operation requires the Mail.Send application permission."
43
+ if method.upper() in {"PATCH", "DELETE"} or "/move" in target:
44
+ return "This operation requires the Mail.ReadWrite application permission."
45
+ return "This operation requires the Mail.Read application permission."
46
+
47
+
48
+ def _raise_graph_error(method: str, path: str, response: requests.Response) -> None:
49
+ if response.status_code == 403:
50
+ detail = _graph_error_detail(response)
51
+ suffix = f" Graph: {detail}" if detail else ""
52
+ raise requests.HTTPError(
53
+ f"403 Forbidden from Microsoft Graph. {_permission_hint(method, path)}{suffix}",
54
+ response=response,
55
+ )
56
+ response.raise_for_status()
57
+
58
+
59
+ def _get_msal_app_class():
60
+ try:
61
+ from msal import ConfidentialClientApplication
62
+ except ImportError as exc:
63
+ raise ImportError(_OUTLOOK_IMPORT_ERROR) from exc
64
+ return ConfidentialClientApplication
65
+
66
+
67
+ class OutlookGraphService:
68
+ """Low-level Microsoft Graph mail service using application credentials."""
69
+
70
+ def __init__(
71
+ self,
72
+ client: OutlookGraphClient,
73
+ session: Optional[requests.Session] = None,
74
+ ) -> None:
75
+ self.client = client
76
+ self._session = session or requests.Session()
77
+ self._msal_app: Any = None
78
+ self._access_token: Optional[str] = None
79
+ self._next_link: Optional[str] = None
80
+ self._exhausted = False
81
+
82
+ @property
83
+ def user_email(self) -> str:
84
+ return self.client.user_email
85
+
86
+ def close(self) -> None:
87
+ self._session.close()
88
+
89
+ def reset_pagination(self) -> None:
90
+ self._next_link = None
91
+ self._exhausted = False
92
+
93
+ def _authenticate(self, *, force: bool = False) -> str:
94
+ if self._access_token and not force:
95
+ return self._access_token
96
+ if self._msal_app is None or force:
97
+ app_class = _get_msal_app_class()
98
+ self._msal_app = app_class(
99
+ client_id=self.client.client_id,
100
+ client_credential=self.client.client_secret,
101
+ authority=self.client.authority,
102
+ )
103
+ token_response = self._msal_app.acquire_token_for_client(scopes=self.client.scopes)
104
+ access_token = token_response.get("access_token")
105
+ if not isinstance(access_token, str) or not access_token:
106
+ description = token_response.get("error_description") or token_response.get("error") or "unknown error"
107
+ raise RuntimeError(f"Microsoft Graph authentication failed: {description}")
108
+ self._access_token = access_token
109
+ return access_token
110
+
111
+ def _headers(self, *, eventual_consistency: bool = False) -> Dict[str, str]:
112
+ headers = {
113
+ "Authorization": f"Bearer {self._authenticate()}",
114
+ "Accept": "application/json",
115
+ "Content-Type": "application/json",
116
+ }
117
+ if eventual_consistency:
118
+ headers["ConsistencyLevel"] = "eventual"
119
+ return headers
120
+
121
+ def _url(self, path: str) -> str:
122
+ if path.startswith("http://") or path.startswith("https://"):
123
+ base = self.client.graph_base_url.rstrip("/")
124
+ if path != base and not path.startswith(f"{base}/"):
125
+ raise ValueError("Microsoft Graph pagination URL is outside graph_base_url")
126
+ return path
127
+ return f"{self.client.graph_base_url.rstrip('/')}/{path.lstrip('/')}"
128
+
129
+ def _request(
130
+ self,
131
+ method: str,
132
+ path: str,
133
+ params: Optional[Dict[str, Any]] = None,
134
+ json: Optional[Dict[str, Any]] = None,
135
+ eventual_consistency: bool = False,
136
+ ) -> requests.Response:
137
+ response = self._session.request(
138
+ method,
139
+ self._url(path),
140
+ headers=self._headers(eventual_consistency=eventual_consistency),
141
+ params=params,
142
+ json=json,
143
+ timeout=self.client.timeout_sec,
144
+ )
145
+ if response.status_code == 401:
146
+ self._authenticate(force=True)
147
+ response = self._session.request(
148
+ method,
149
+ self._url(path),
150
+ headers=self._headers(eventual_consistency=eventual_consistency),
151
+ params=params,
152
+ json=json,
153
+ timeout=self.client.timeout_sec,
154
+ )
155
+ if response.status_code >= 400:
156
+ _raise_graph_error(method, path, response)
157
+ return response
158
+
159
+ def _user_path(self) -> str:
160
+ return f"users/{quote(self.user_email, safe='')}"
161
+
162
+ @staticmethod
163
+ def _response_data(response: requests.Response) -> Dict[str, Any]:
164
+ if response.status_code == 204 or not response.content:
165
+ return {}
166
+ data = response.json()
167
+ return data if isinstance(data, dict) else {}
168
+
169
+ def list_messages(
170
+ self,
171
+ query: OutlookMessageQuery,
172
+ limit: int,
173
+ next_link: Optional[str] = None,
174
+ use_internal_pagination: bool = True,
175
+ ) -> Dict[str, Any]:
176
+ if use_internal_pagination and self._exhausted:
177
+ return {"data": [], "next_link": None}
178
+
179
+ cursor = next_link if next_link is not None else (self._next_link if use_internal_pagination else None)
180
+ if cursor:
181
+ response = self._request("GET", cursor, eventual_consistency=query.requires_eventual_consistency)
182
+ else:
183
+ folder = quote(query.folder_id, safe="")
184
+ response = self._request(
185
+ "GET",
186
+ f"{self._user_path()}/mailFolders/{folder}/messages",
187
+ params=query.to_query_params(top=limit),
188
+ eventual_consistency=query.requires_eventual_consistency,
189
+ )
190
+ body = self._response_data(response)
191
+ messages = body.get("value") or []
192
+ if not isinstance(messages, list):
193
+ messages = []
194
+ cursor = body.get("@odata.nextLink")
195
+ cursor = cursor if isinstance(cursor, str) and cursor else None
196
+ if use_internal_pagination:
197
+ self._next_link = cursor
198
+ self._exhausted = cursor is None
199
+ return {"data": messages, "next_link": cursor}
200
+
201
+ def find_message(self, message_id: str, *, select: Optional[List[str]] = None) -> Dict[str, Any]:
202
+ params = {"$select": ",".join(select)} if select else None
203
+ response = self._request(
204
+ "GET",
205
+ f"{self._user_path()}/messages/{quote(message_id, safe='')}",
206
+ params=params,
207
+ )
208
+ return self._response_data(response)
209
+
210
+ def list_attachments(self, message_id: str, *, download_content: bool = False) -> List[Dict[str, Any]]:
211
+ path: Optional[str] = f"{self._user_path()}/messages/{quote(message_id, safe='')}/attachments"
212
+ attachments: List[Dict[str, Any]] = []
213
+ while path:
214
+ response = self._request("GET", path)
215
+ body = self._response_data(response)
216
+ for item in body.get("value") or []:
217
+ if not isinstance(item, dict):
218
+ continue
219
+ if item.get("@odata.type") != "#microsoft.graph.fileAttachment":
220
+ continue
221
+ attachment = {
222
+ "id": item.get("id"),
223
+ "name": item.get("name"),
224
+ "content_type": item.get("contentType"),
225
+ "size": item.get("size"),
226
+ "is_inline": item.get("isInline", False),
227
+ }
228
+ if download_content and item.get("contentBytes"):
229
+ attachment["content"] = base64.b64decode(item["contentBytes"])
230
+ attachments.append(attachment)
231
+ next_link = body.get("@odata.nextLink")
232
+ path = next_link if isinstance(next_link, str) and next_link else None
233
+ return attachments
234
+
235
+ def save_attachment(
236
+ self,
237
+ attachment: Dict[str, Any],
238
+ destination: str | Path,
239
+ overwrite: bool = False,
240
+ ) -> Path:
241
+ name = Path(str(attachment.get("name") or "")).name
242
+ content = attachment.get("content")
243
+ if not name or not isinstance(content, bytes):
244
+ raise ValueError("Attachment requires a safe name and downloaded byte content")
245
+
246
+ directory = Path(destination)
247
+ directory.mkdir(parents=True, exist_ok=True)
248
+ path = directory / name
249
+ if path.exists() and not overwrite:
250
+ stem, suffix = path.stem, path.suffix
251
+ counter = 1
252
+ while path.exists():
253
+ path = directory / f"{stem}_{counter}{suffix}"
254
+ counter += 1
255
+ path.write_bytes(content)
256
+ return path
257
+
258
+ def fetch_items(
259
+ self,
260
+ query: OutlookMessageQuery,
261
+ limit: int,
262
+ download_attachments: bool = False,
263
+ ) -> List[Transaction]:
264
+ result = self.list_messages(query, limit)
265
+ transactions: List[Transaction] = []
266
+ for message in result["data"]:
267
+ attachments: List[Dict[str, Any]] = []
268
+ if message.get("hasAttachments"):
269
+ attachments = self.list_attachments(
270
+ str(message.get("id", "")),
271
+ download_content=download_attachments,
272
+ )
273
+ transactions.append(message_to_transaction(message, self.user_email, attachments))
274
+ return transactions
275
+
276
+ def find_message_transaction(
277
+ self,
278
+ message_id: str,
279
+ select: Optional[List[str]] = None,
280
+ download_attachments: bool = False,
281
+ ) -> Transaction:
282
+ message = self.find_message(message_id, select=select)
283
+ attachments: List[Dict[str, Any]] = []
284
+ if message.get("hasAttachments"):
285
+ attachments = self.list_attachments(message_id, download_content=download_attachments)
286
+ return message_to_transaction(message, self.user_email, attachments)
287
+
288
+ def get_messages_count(self, query: OutlookMessageQuery, *, max_pages: Optional[int] = None) -> int:
289
+ count = 0
290
+ pages = 0
291
+ next_link: Optional[str] = None
292
+ while True:
293
+ result = self.list_messages(
294
+ query,
295
+ limit=1000,
296
+ next_link=next_link,
297
+ use_internal_pagination=False,
298
+ )
299
+ batch = result["data"]
300
+ count += len(batch)
301
+ pages += 1
302
+ next_link = result["next_link"]
303
+ if not batch or not next_link:
304
+ break
305
+ if max_pages is not None and pages >= max_pages:
306
+ break
307
+ return count
308
+
309
+ def send_message(
310
+ self,
311
+ payload: OutlookSendMessagePayload,
312
+ producer_config: Optional[OutlookProducerConfig] = None,
313
+ ) -> None:
314
+ config = producer_config or OutlookProducerConfig()
315
+ self._request(
316
+ "POST",
317
+ f"{self._user_path()}/sendMail",
318
+ json={
319
+ "message": normalize_send_payload(payload),
320
+ "saveToSentItems": config.save_to_sent_items,
321
+ },
322
+ )
323
+
324
+ def update_message(self, message_id: str, request_body: Dict[str, Any]) -> Dict[str, Any]:
325
+ response = self._request(
326
+ "PATCH",
327
+ f"{self._user_path()}/messages/{quote(message_id, safe='')}",
328
+ json=request_body,
329
+ )
330
+ return self._response_data(response)
331
+
332
+ def mark_as_read(self, message_id: str) -> Dict[str, Any]:
333
+ return self.update_message(message_id, {"isRead": True})
334
+
335
+ def mark_as_unread(self, message_id: str) -> Dict[str, Any]:
336
+ return self.update_message(message_id, {"isRead": False})
337
+
338
+ def set_flag(
339
+ self,
340
+ message_id: str,
341
+ status: OutlookFlagStatus | str = OutlookFlagStatus.FLAGGED,
342
+ ) -> Dict[str, Any]:
343
+ flag_status = OutlookFlagStatus(status)
344
+ return self.update_message(message_id, {"flag": {"flagStatus": flag_status.value}})
345
+
346
+ def set_categories(self, message_id: str, categories: List[str]) -> Dict[str, Any]:
347
+ normalized = list(dict.fromkeys(category.strip() for category in categories if category.strip()))
348
+ return self.update_message(message_id, {"categories": normalized})
349
+
350
+ def move_message(self, message_id: str, folder_id: OutlookWellKnownFolder | str) -> Dict[str, Any]:
351
+ destination = folder_id.value if isinstance(folder_id, OutlookWellKnownFolder) else folder_id
352
+ response = self._request(
353
+ "POST",
354
+ f"{self._user_path()}/messages/{quote(message_id, safe='')}/move",
355
+ json={"destinationId": destination},
356
+ )
357
+ return self._response_data(response)
358
+
359
+ def delete_message(self, message_id: str) -> None:
360
+ self._request(
361
+ "DELETE",
362
+ f"{self._user_path()}/messages/{quote(message_id, safe='')}",
363
+ )
364
+
365
+ def reply(self, message_id: str, *, comment: str = "") -> None:
366
+ self._request(
367
+ "POST",
368
+ f"{self._user_path()}/messages/{quote(message_id, safe='')}/reply",
369
+ json={"comment": comment},
370
+ )
371
+
372
+ def reply_all(self, message_id: str, *, comment: str = "") -> None:
373
+ self._request(
374
+ "POST",
375
+ f"{self._user_path()}/messages/{quote(message_id, safe='')}/replyAll",
376
+ json={"comment": comment},
377
+ )
378
+
379
+ def forward(
380
+ self,
381
+ message_id: str,
382
+ to: List[OutlookEmailAddress],
383
+ comment: str = "",
384
+ ) -> None:
385
+ self._request(
386
+ "POST",
387
+ f"{self._user_path()}/messages/{quote(message_id, safe='')}/forward",
388
+ json={
389
+ "comment": comment,
390
+ "toRecipients": [address.to_graph() for address in to],
391
+ },
392
+ )
393
+
394
+ def list_mail_folders(self, *, include_hidden: bool = False) -> List[Dict[str, Any]]:
395
+ response = self._request(
396
+ "GET",
397
+ f"{self._user_path()}/mailFolders",
398
+ params={"includeHiddenFolders": str(include_hidden).lower()},
399
+ )
400
+ body = self._response_data(response)
401
+ folders = body.get("value") or []
402
+ return folders if isinstance(folders, list) else []
403
+
404
+ def get_mail_folder(self, folder_id: OutlookWellKnownFolder | str) -> Dict[str, Any]:
405
+ value = folder_id.value if isinstance(folder_id, OutlookWellKnownFolder) else folder_id
406
+ response = self._request(
407
+ "GET",
408
+ f"{self._user_path()}/mailFolders/{quote(value, safe='')}",
409
+ )
410
+ return self._response_data(response)
@@ -0,0 +1,118 @@
1
+ from typing import Any, Dict, Optional
2
+
3
+ from ..transaction import Transaction
4
+ from .models import (
5
+ OutlookAckActionConfig,
6
+ OutlookConsumerConfig,
7
+ OutlookMessageQuery,
8
+ OutlookNackActionConfig,
9
+ OutlookSendMessageInput,
10
+ OutlookSendMessagePayload,
11
+ )
12
+
13
+
14
+ def merge_query(
15
+ base: Optional[OutlookConsumerConfig],
16
+ overrides: Optional[OutlookMessageQuery] = None,
17
+ **kwargs: Any,
18
+ ) -> OutlookMessageQuery:
19
+ query_fields = set(OutlookMessageQuery.model_fields)
20
+ merged: Dict[str, Any] = {}
21
+ if base is not None:
22
+ merged.update({key: value for key, value in base.model_dump(exclude_none=True).items() if key in query_fields})
23
+ if overrides is not None:
24
+ dumped = {
25
+ key: value
26
+ for key, value in overrides.model_dump(exclude_none=True, exclude_unset=True).items()
27
+ if key in query_fields
28
+ }
29
+ if "search" in dumped:
30
+ merged.pop("filter", None)
31
+ if "filter" in dumped:
32
+ merged.pop("search", None)
33
+ merged.update(dumped)
34
+ for key, value in kwargs.items():
35
+ if key not in query_fields or value is None:
36
+ continue
37
+ if key == "search":
38
+ merged.pop("filter", None)
39
+ if key == "filter":
40
+ merged.pop("search", None)
41
+ merged[key] = value
42
+ return OutlookMessageQuery.model_validate(merged)
43
+
44
+
45
+ def _graph_email(value: Any) -> Optional[str]:
46
+ if not isinstance(value, dict):
47
+ return None
48
+ address = value.get("emailAddress")
49
+ if not isinstance(address, dict):
50
+ return None
51
+ email = address.get("address")
52
+ return email if isinstance(email, str) and email else None
53
+
54
+
55
+ def _graph_emails(values: Any) -> list[str]:
56
+ if not isinstance(values, list):
57
+ return []
58
+ emails: list[str] = []
59
+ for item in values:
60
+ email = _graph_email(item)
61
+ if email:
62
+ emails.append(email)
63
+ return emails
64
+
65
+
66
+ def message_to_transaction(
67
+ message: Dict[str, Any],
68
+ user_email: str,
69
+ attachments: Optional[list[Dict[str, Any]]] = None,
70
+ ) -> Transaction:
71
+ attachment_items = attachments or message.get("attachments") or []
72
+ raw_body = message.get("body")
73
+ body = raw_body if isinstance(raw_body, dict) else {}
74
+ return Transaction(
75
+ id=str(message.get("id", "")),
76
+ payload=message,
77
+ metadata={
78
+ "user_email": user_email,
79
+ "subject": message.get("subject"),
80
+ "from_email": _graph_email(message.get("from")),
81
+ "to_emails": _graph_emails(message.get("toRecipients")),
82
+ "received_at": message.get("receivedDateTime"),
83
+ "body_preview": message.get("bodyPreview"),
84
+ "body": body.get("content"),
85
+ "body_type": body.get("contentType"),
86
+ "conversation_id": message.get("conversationId"),
87
+ "internet_message_id": message.get("internetMessageId"),
88
+ "parent_folder_id": message.get("parentFolderId"),
89
+ "has_attachment": bool(message.get("hasAttachments") or attachment_items),
90
+ "attachments": attachment_items,
91
+ "is_read": message.get("isRead"),
92
+ },
93
+ )
94
+
95
+
96
+ def normalize_send_payload(payload: OutlookSendMessagePayload) -> Dict[str, Any]:
97
+ if isinstance(payload, OutlookSendMessageInput):
98
+ return payload.to_graph_message()
99
+ if isinstance(payload, dict):
100
+ if "message" in payload and isinstance(payload["message"], dict):
101
+ return dict(payload["message"])
102
+ return dict(payload)
103
+ raise TypeError(f"Unsupported Outlook dispatch payload type: {type(payload)}")
104
+
105
+
106
+ def build_ack_patch(config: OutlookAckActionConfig) -> Dict[str, Any]:
107
+ return {"isRead": True} if config.mark_as_read else {}
108
+
109
+
110
+ def build_nack_patch(config: OutlookNackActionConfig) -> Dict[str, Any]:
111
+ patch: Dict[str, Any] = {}
112
+ if config.mark_as_unread:
113
+ patch["isRead"] = False
114
+ if config.categories:
115
+ patch["categories"] = list(
116
+ dict.fromkeys(category.strip() for category in config.categories if category.strip())
117
+ )
118
+ return patch
@@ -103,7 +103,7 @@ class ConsumerMixin(ABC):
103
103
  # -----------------------
104
104
  # 1) PROCESS STEP
105
105
  # -----------------------
106
- logger.info(f"Transaction {transaction.id} processing started")
106
+ logger.debug("Transaction %s processing started", transaction.id)
107
107
  process_ok, process_result = self._handle_process(transaction, policy.process.retry)
108
108
 
109
109
  # -----------------------
@@ -195,7 +195,7 @@ class ConsumerMixin(ABC):
195
195
  # PROCESS HANDLER
196
196
  # =====================================================================
197
197
  def _handle_process(self, transaction, retry: policies.RetryPolicy):
198
- logger.info(f"Transaction {transaction.id} process handler started")
198
+ logger.debug("Transaction %s process handler started", transaction.id)
199
199
  stage_start = time.perf_counter()
200
200
  success, result = helpers.run_fn(
201
201
  fn=lambda: self.process_transaction(transaction),
@@ -210,8 +210,10 @@ class ConsumerMixin(ABC):
210
210
  duration=time.perf_counter() - stage_start,
211
211
  outcome="ok" if success else "error",
212
212
  )
213
- logger.info(
214
- f"Transaction {transaction.id} process handler completed with status: {'success' if success else 'error'}"
213
+ logger.debug(
214
+ "Transaction %s process handler completed with status: %s",
215
+ transaction.id,
216
+ "success" if success else "error",
215
217
  )
216
218
  return success, result
217
219
 
@@ -219,7 +221,7 @@ class ConsumerMixin(ABC):
219
221
  # SUCCESS HANDLER
220
222
  # =====================================================================
221
223
  def _handle_success(self, transaction, result, retry: policies.RetryPolicy):
222
- logger.info(f"Transaction {transaction.id} success handler started")
224
+ logger.debug("Transaction %s success handler started", transaction.id)
223
225
  stage_start = time.perf_counter()
224
226
  success, handler_result = helpers.run_fn(
225
227
  fn=lambda: self.handle_process_success(transaction, result),
@@ -234,8 +236,10 @@ class ConsumerMixin(ABC):
234
236
  duration=time.perf_counter() - stage_start,
235
237
  outcome="ok" if success else "error",
236
238
  )
237
- logger.info(
238
- f"Transaction {transaction.id} success handler completed with status: {'success' if success else 'error'}"
239
+ logger.debug(
240
+ "Transaction %s success handler completed with status: %s",
241
+ transaction.id,
242
+ "success" if success else "error",
239
243
  )
240
244
  return success, handler_result
241
245
 
@@ -243,7 +247,7 @@ class ConsumerMixin(ABC):
243
247
  # EXCEPTION HANDLER
244
248
  # =====================================================================
245
249
  def _handle_exception(self, transaction, exc, retry: policies.RetryPolicy):
246
- logger.error(f"Transaction {transaction.id} exception handler started")
250
+ logger.debug("Transaction %s exception handler started", transaction.id)
247
251
  stage_start = time.perf_counter()
248
252
  success, result = helpers.run_fn(
249
253
  fn=lambda: self.handle_process_exception(transaction, exc),
@@ -258,8 +262,10 @@ class ConsumerMixin(ABC):
258
262
  duration=time.perf_counter() - stage_start,
259
263
  outcome="ok" if success else "error",
260
264
  )
261
- logger.info(
262
- f"Transaction {transaction.id} exception handler completed with status: {'success' if success else 'error'}"
265
+ logger.debug(
266
+ "Transaction %s exception handler completed with status: %s",
267
+ transaction.id,
268
+ "success" if success else "error",
263
269
  )
264
270
  return success, result
265
271
 
@@ -337,7 +343,7 @@ class ConsumerMixin(ABC):
337
343
  # -------------------------
338
344
  # FETCH
339
345
  # -------------------------
340
- logger.info(f"Fetching transactions batch with fetch policy: {policy.fetch.model_dump_json(indent=2)}")
346
+ logger.debug(f"Fetching transactions batch with fetch policy: {policy.fetch.model_dump_json(indent=2)}")
341
347
  success, result = self._handle_fetch(conn, policy.fetch)
342
348
  if not success:
343
349
  logger.error(f"Fetch failed → {result}")
@@ -645,7 +651,7 @@ class AsyncConsumerMixin(ABC):
645
651
  # -----------------------
646
652
  # 1) PROCESS STEP
647
653
  # -----------------------
648
- logger.info(f"Transaction {transaction.id} processing started")
654
+ logger.debug("Transaction %s processing started", transaction.id)
649
655
  process_ok, process_result = await self._handle_process(transaction, policy.process.retry)
650
656
 
651
657
  # -----------------------
@@ -735,7 +741,7 @@ class AsyncConsumerMixin(ABC):
735
741
  # PROCESS HANDLER WITH RETRIES
736
742
  # =====================================================================
737
743
  async def _handle_process(self, transaction, retry: policies.RetryPolicy):
738
- logger.info(f"Transaction {transaction.id} process handler started")
744
+ logger.debug("Transaction %s process handler started", transaction.id)
739
745
  stage_start = time.perf_counter()
740
746
  success, result = await helpers.run_fn_async(
741
747
  fn=lambda: self.process_transaction(transaction),
@@ -750,8 +756,10 @@ class AsyncConsumerMixin(ABC):
750
756
  duration=time.perf_counter() - stage_start,
751
757
  outcome="ok" if success else "error",
752
758
  )
753
- logger.info(
754
- f"Transaction {transaction.id} process handler completed with status: {'success' if success else 'error'}"
759
+ logger.debug(
760
+ "Transaction %s process handler completed with status: %s",
761
+ transaction.id,
762
+ "success" if success else "error",
755
763
  )
756
764
  return success, result
757
765
 
@@ -759,7 +767,7 @@ class AsyncConsumerMixin(ABC):
759
767
  # SUCCESS HANDLER
760
768
  # =====================================================================
761
769
  async def _handle_success(self, transaction, result, retry: policies.RetryPolicy):
762
- logger.info(f"Transaction {transaction.id} success handler started")
770
+ logger.debug("Transaction %s success handler started", transaction.id)
763
771
  stage_start = time.perf_counter()
764
772
  success, handler_result = await helpers.run_fn_async(
765
773
  fn=lambda: self.handle_process_success(transaction, result),
@@ -774,8 +782,10 @@ class AsyncConsumerMixin(ABC):
774
782
  duration=time.perf_counter() - stage_start,
775
783
  outcome="ok" if success else "error",
776
784
  )
777
- logger.info(
778
- f"Transaction {transaction.id} success handler completed with status: {'success' if success else 'error'}"
785
+ logger.debug(
786
+ "Transaction %s success handler completed with status: %s",
787
+ transaction.id,
788
+ "success" if success else "error",
779
789
  )
780
790
  return success, handler_result
781
791
 
@@ -783,7 +793,7 @@ class AsyncConsumerMixin(ABC):
783
793
  # EXCEPTION HANDLER
784
794
  # =====================================================================
785
795
  async def _handle_exception(self, transaction, exc, retry: policies.RetryPolicy):
786
- logger.error(f"Transaction {transaction.id} exception handler started")
796
+ logger.debug("Transaction %s exception handler started", transaction.id)
787
797
  stage_start = time.perf_counter()
788
798
  success, result = await helpers.run_fn_async(
789
799
  fn=lambda: self.handle_process_exception(transaction, exc),
@@ -798,8 +808,10 @@ class AsyncConsumerMixin(ABC):
798
808
  duration=time.perf_counter() - stage_start,
799
809
  outcome="ok" if success else "error",
800
810
  )
801
- logger.info(
802
- f"Transaction {transaction.id} exception handler completed with status: {'success' if success else 'error'}"
811
+ logger.debug(
812
+ "Transaction %s exception handler completed with status: %s",
813
+ transaction.id,
814
+ "success" if success else "error",
803
815
  )
804
816
  return success, result
805
817