ergon-framework-python 0.1.0__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.
Files changed (82) hide show
  1. ergon/__init__.py +13 -0
  2. ergon/bootstrap/src/__project__/__init__.py +0 -0
  3. ergon/bootstrap/src/__project__/_observability/docker-compose.telemetry.yml +124 -0
  4. ergon/bootstrap/src/__project__/_observability/grafana.yaml +17 -0
  5. ergon/bootstrap/src/__project__/_observability/loki.yaml +48 -0
  6. ergon/bootstrap/src/__project__/_observability/otel-collector-config.yaml +53 -0
  7. ergon/bootstrap/src/__project__/_observability/prometheus.yaml +11 -0
  8. ergon/bootstrap/src/__project__/_observability/tempo.yaml +24 -0
  9. ergon/bootstrap/src/__project__/connectors/__init__.py +0 -0
  10. ergon/bootstrap/src/__project__/main.py +9 -0
  11. ergon/bootstrap/src/__project__/tasks/__init__.py +0 -0
  12. ergon/bootstrap/src/__project__/tasks/constants.py +13 -0
  13. ergon/bootstrap/src/__project__/tasks/example_task/__init__.py +0 -0
  14. ergon/bootstrap/src/__project__/tasks/example_task/config.py +4 -0
  15. ergon/bootstrap/src/__project__/tasks/example_task/exceptions.py +4 -0
  16. ergon/bootstrap/src/__project__/tasks/example_task/helpers.py +4 -0
  17. ergon/bootstrap/src/__project__/tasks/example_task/schemas.py +5 -0
  18. ergon/bootstrap/src/__project__/tasks/example_task/task.py +1 -0
  19. ergon/bootstrap/src/__project__/tasks/exceptions.py +0 -0
  20. ergon/bootstrap/src/__project__/tasks/helpers.py +0 -0
  21. ergon/bootstrap/src/__project__/tasks/schemas.py +0 -0
  22. ergon/bootstrap/src/__project__/tasks/settings.py +5 -0
  23. ergon/cli.py +174 -0
  24. ergon/connector/__init__.py +64 -0
  25. ergon/connector/connector.py +97 -0
  26. ergon/connector/excel/__init__.py +18 -0
  27. ergon/connector/excel/connector.py +175 -0
  28. ergon/connector/excel/models.py +24 -0
  29. ergon/connector/excel/service.py +98 -0
  30. ergon/connector/pipefy/__init__.py +21 -0
  31. ergon/connector/pipefy/async_connector.py +48 -0
  32. ergon/connector/pipefy/async_service.py +907 -0
  33. ergon/connector/pipefy/connector.py +36 -0
  34. ergon/connector/pipefy/models.py +48 -0
  35. ergon/connector/pipefy/service.py +1016 -0
  36. ergon/connector/pipefy/version.py +1 -0
  37. ergon/connector/postgres/__init__.py +11 -0
  38. ergon/connector/postgres/async_connector.py +119 -0
  39. ergon/connector/postgres/async_service.py +116 -0
  40. ergon/connector/postgres/models.py +34 -0
  41. ergon/connector/rabbitmq/__init__.py +25 -0
  42. ergon/connector/rabbitmq/async_connector.py +120 -0
  43. ergon/connector/rabbitmq/async_service.py +417 -0
  44. ergon/connector/rabbitmq/connector.py +54 -0
  45. ergon/connector/rabbitmq/helper.py +14 -0
  46. ergon/connector/rabbitmq/models.py +92 -0
  47. ergon/connector/rabbitmq/service.py +199 -0
  48. ergon/connector/sqs/__init__.py +15 -0
  49. ergon/connector/sqs/async_connector.py +120 -0
  50. ergon/connector/sqs/async_service.py +246 -0
  51. ergon/connector/sqs/connector.py +120 -0
  52. ergon/connector/sqs/models.py +36 -0
  53. ergon/connector/sqs/service.py +219 -0
  54. ergon/connector/transaction.py +14 -0
  55. ergon/py.typed +0 -0
  56. ergon/service/__init__.py +5 -0
  57. ergon/service/service.py +17 -0
  58. ergon/task/__init__.py +13 -0
  59. ergon/task/base.py +222 -0
  60. ergon/task/exceptions.py +217 -0
  61. ergon/task/helpers.py +691 -0
  62. ergon/task/manager.py +85 -0
  63. ergon/task/mixins/__init__.py +13 -0
  64. ergon/task/mixins/consumer.py +858 -0
  65. ergon/task/mixins/metrics.py +457 -0
  66. ergon/task/mixins/producer.py +486 -0
  67. ergon/task/policies.py +229 -0
  68. ergon/task/runner.py +386 -0
  69. ergon/task/utils.py +64 -0
  70. ergon/telemetry/__init__.py +7 -0
  71. ergon/telemetry/_resource.py +13 -0
  72. ergon/telemetry/logging.py +370 -0
  73. ergon/telemetry/metrics.py +101 -0
  74. ergon/telemetry/tracing.py +152 -0
  75. ergon/utils/__init__.py +5 -0
  76. ergon/utils/env.py +26 -0
  77. ergon_framework_python-0.1.0.dist-info/METADATA +449 -0
  78. ergon_framework_python-0.1.0.dist-info/RECORD +82 -0
  79. ergon_framework_python-0.1.0.dist-info/WHEEL +5 -0
  80. ergon_framework_python-0.1.0.dist-info/entry_points.txt +2 -0
  81. ergon_framework_python-0.1.0.dist-info/licenses/LICENSE +21 -0
  82. ergon_framework_python-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,907 @@
1
+ import json
2
+ import os
3
+ from logging import getLogger
4
+ from typing import Any, Dict, List, Optional
5
+
6
+ import httpx
7
+
8
+ from ergon.task.helpers import run_fn_async
9
+ from ergon.task.policies import RetryPolicy
10
+
11
+ from .models import (
12
+ CreateCardInput,
13
+ FieldFilter,
14
+ FieldFilterOperator,
15
+ PipefyClient,
16
+ )
17
+
18
+ logger = getLogger(__name__)
19
+
20
+ default_retry = RetryPolicy(max_attempts=5, backoff=1, backoff_multiplier=2, backoff_cap=10)
21
+
22
+
23
+ class AsyncPipefyService:
24
+ def __init__(self, client: PipefyClient):
25
+ logger.info("Initializing AsyncPipefyService")
26
+
27
+ self.client = client
28
+ self.endpoint = client.endpoint
29
+ self.timeout_sec = client.timeout_sec
30
+
31
+ self._token: str | None = None
32
+ self._http: httpx.AsyncClient | None = None
33
+ self._after_cursor = None
34
+ self._has_next_page = False
35
+
36
+ async def _get_http(self) -> httpx.AsyncClient:
37
+ if self._http is None or self._http.is_closed:
38
+ self._http = httpx.AsyncClient(timeout=self.timeout_sec)
39
+ return self._http
40
+
41
+ async def close(self) -> None:
42
+ if self._http is not None and not self._http.is_closed:
43
+ await self._http.aclose()
44
+ self._http = None
45
+
46
+ # ---------------------------------------------------------
47
+ # AUTHENTICATION
48
+ # ---------------------------------------------------------
49
+
50
+ def get_access_token(self) -> str | None:
51
+ return self._token
52
+
53
+ @run_fn_async(retry=default_retry, trace_name="AsyncPipefyService.authenticate")
54
+ async def authenticate(self) -> None:
55
+ logger.info("Authenticating with Pipefy (async)")
56
+ http = await self._get_http()
57
+ resp = await http.post(
58
+ self.client.oauth_token_url,
59
+ json={
60
+ "grant_type": "client_credentials",
61
+ "client_id": self.client.client_id,
62
+ "client_secret": self.client.client_secret,
63
+ },
64
+ timeout=30,
65
+ )
66
+
67
+ resp.raise_for_status()
68
+ data = resp.json()
69
+ self._token = data.get("access_token")
70
+
71
+ logger.info("Authenticated successfully (async)")
72
+
73
+ # ---------------------------------------------------------
74
+ # GENERIC GRAPHQL
75
+ # ---------------------------------------------------------
76
+ async def _graphql(self, query: str, variables: Dict[str, Any]) -> Dict[str, Any]:
77
+ if not self._token:
78
+ raise ValueError("Authentication required")
79
+
80
+ http = await self._get_http()
81
+ payload = {"query": query, "variables": variables}
82
+ headers = {
83
+ "Authorization": f"Bearer {self._token}",
84
+ "Content-Type": "application/json",
85
+ }
86
+
87
+ resp = await http.post(self.endpoint, json=payload, headers=headers)
88
+
89
+ logger.debug(f"GraphQL query response: {resp.status_code}")
90
+
91
+ if resp.status_code == 401:
92
+ logger.debug("Unauthorized. Re-authenticating with Pipefy (async)")
93
+ await self.authenticate()
94
+ headers["Authorization"] = f"Bearer {self._token}"
95
+ resp = await http.post(self.endpoint, json=payload, headers=headers)
96
+ logger.debug("Re-authenticated successfully (async)")
97
+
98
+ resp.raise_for_status()
99
+ data = resp.json()
100
+
101
+ logger.debug("GraphQL query completed successfully")
102
+ return data.get("data", {})
103
+
104
+ @run_fn_async(retry=default_retry)
105
+ async def get_pipe_fields(
106
+ self,
107
+ pipe_id: str,
108
+ response_fields: Optional[str] = None,
109
+ ) -> List[Dict[str, Any]]:
110
+ if not pipe_id:
111
+ raise RuntimeError("pipe_id must be provided")
112
+
113
+ if response_fields is None:
114
+ response_fields = """
115
+ id
116
+ name
117
+ phases {
118
+ id
119
+ name
120
+ fields {
121
+ id
122
+ label
123
+ }
124
+ }
125
+ """
126
+
127
+ query = f"""
128
+ query GetPipeFields($pipeId: ID!) {{
129
+ pipe(id: $pipeId) {{
130
+ {response_fields}
131
+ }}
132
+ }}
133
+ """
134
+
135
+ data = await self._graphql(query, {"pipeId": pipe_id})
136
+
137
+ pipe = data.get("pipe") or {}
138
+ phases = pipe.get("phases") or []
139
+
140
+ total_fields = sum(len(p.get("fields", [])) for p in phases)
141
+ logger.info(
142
+ "Retrieved %d phases and %d total fields from pipe %s",
143
+ len(phases),
144
+ total_fields,
145
+ pipe_id,
146
+ )
147
+
148
+ result = []
149
+ for phase in phases:
150
+ for f in phase.get("fields", []):
151
+ result.append(
152
+ {
153
+ "phase_id": phase.get("id"),
154
+ "phase_name": phase.get("name"),
155
+ "id": f.get("id"),
156
+ "label": f.get("label"),
157
+ }
158
+ )
159
+
160
+ return result
161
+
162
+ # -------------------------------------------------------------
163
+ @run_fn_async(retry=default_retry)
164
+ async def get_pipe_start_form_fields(
165
+ self,
166
+ pipe_id: str,
167
+ response_fields: Optional[str] = None,
168
+ ) -> List[Dict[str, Any]]:
169
+ if not pipe_id:
170
+ raise RuntimeError("pipe_id must be provided")
171
+
172
+ if response_fields is None:
173
+ response_fields = """
174
+ id
175
+ name
176
+ start_form_fields {
177
+ id
178
+ label
179
+ }
180
+ """
181
+
182
+ query = f"""
183
+ query GetStartFormFields($pipeId: ID!) {{
184
+ pipe(id: $pipeId) {{
185
+ {response_fields}
186
+ }}
187
+ }}
188
+ """
189
+
190
+ data = await self._graphql(query, {"pipeId": pipe_id})
191
+
192
+ pipe = data.get("pipe") or {}
193
+ fields = pipe.get("start_form_fields") or []
194
+
195
+ result = [
196
+ {
197
+ "phase_id": pipe.get("id"),
198
+ "phase_name": pipe.get("name"),
199
+ "id": f.get("id"),
200
+ "label": f.get("label"),
201
+ }
202
+ for f in fields
203
+ ]
204
+
205
+ logger.info(
206
+ "Retrieved %d start_form_fields from pipe %s (%s)",
207
+ len(fields),
208
+ pipe_id,
209
+ pipe.get("name"),
210
+ )
211
+
212
+ return result
213
+
214
+ # ---------------------------------------------------------
215
+ # CARD QUERIES
216
+ # ---------------------------------------------------------
217
+
218
+ @run_fn_async(retry=default_retry)
219
+ async def get_card_by_id(
220
+ self,
221
+ card_id: str,
222
+ response_fields: Optional[str] = None,
223
+ ) -> Dict:
224
+ if response_fields is None:
225
+ response_fields = """
226
+ id
227
+ title
228
+ fields {
229
+ field { id }
230
+ name
231
+ value
232
+ }
233
+ current_phase { id name }
234
+ updated_at
235
+ """
236
+
237
+ query = f"""
238
+ query GetCard($id: ID!) {{
239
+ card(id: $id) {{
240
+ {response_fields}
241
+ }}
242
+ }}
243
+ """
244
+
245
+ data = await self._graphql(query, {"id": card_id})
246
+ return data.get("card", {})
247
+
248
+ # ---------------------------------------------------------
249
+
250
+ @run_fn_async(retry=default_retry)
251
+ async def get_next_card(
252
+ self,
253
+ phase_id: str,
254
+ field_filters: Optional[List[FieldFilter]] = None,
255
+ batch_size: int = 1,
256
+ response_fields: Optional[str] = None,
257
+ ) -> Optional[List[Dict]]:
258
+ if response_fields is None:
259
+ response_fields = """
260
+ id
261
+ title
262
+ fields {
263
+ field { id }
264
+ name
265
+ value
266
+ }
267
+ current_phase { id name }
268
+ updated_at
269
+ """
270
+
271
+ query = f"""
272
+ query GetCardsFromPhase($phaseId: ID!, $after: String, $batch_size: Int!) {{
273
+ phase(id: $phaseId) {{
274
+ id
275
+ name
276
+ cards(first: $batch_size, after: $after) {{
277
+ edges {{
278
+ node {{
279
+ {response_fields}
280
+ }}
281
+ }}
282
+ pageInfo {{
283
+ hasNextPage
284
+ endCursor
285
+ }}
286
+ }}
287
+ }}
288
+ }}
289
+ """
290
+
291
+ data = await self._graphql(
292
+ query,
293
+ {"phaseId": phase_id, "after": self._after_cursor, "batch_size": batch_size},
294
+ )
295
+
296
+ phase = data.get("phase", {})
297
+ if not phase:
298
+ return None
299
+
300
+ cards = phase.get("cards", {}).get("edges", [])
301
+ if not cards:
302
+ self._after_cursor = None
303
+ self._has_next_page = False
304
+ return None
305
+
306
+ page_info = phase.get("cards", {}).get("pageInfo", {})
307
+ self._after_cursor = page_info.get("endCursor")
308
+ self._has_next_page = page_info.get("hasNextPage")
309
+
310
+ filtered = []
311
+ for edge in cards:
312
+ card = edge.get("node", {})
313
+ if self.__apply_client_side_filter(card, field_filters):
314
+ filtered.append(card)
315
+
316
+ return filtered or None
317
+
318
+ @run_fn_async(retry=default_retry)
319
+ async def search_cards_by_field(
320
+ self,
321
+ pipe_id: str,
322
+ field_id: str,
323
+ field_value: str,
324
+ response_fields: Optional[str] = None,
325
+ ) -> List[Dict]:
326
+ if not pipe_id:
327
+ raise RuntimeError("pipe_id must be provided")
328
+ if not field_id:
329
+ raise RuntimeError("field_id must be provided")
330
+
331
+ if response_fields is None:
332
+ response_fields = """
333
+ id
334
+ title
335
+ updated_at
336
+ current_phase { id name }
337
+ fields {
338
+ field { id }
339
+ name
340
+ value
341
+ }
342
+ """
343
+
344
+ query = f"""
345
+ query FindCards($pipeId: ID!, $fieldId: String!, $fieldValue: String!) {{
346
+ findCards(
347
+ pipeId: $pipeId,
348
+ search: {{ fieldId: $fieldId, fieldValue: $fieldValue }}
349
+ ) {{
350
+ edges {{
351
+ node {{
352
+ {response_fields}
353
+ }}
354
+ }}
355
+ }}
356
+ }}
357
+ """
358
+
359
+ variables = {
360
+ "pipeId": str(pipe_id),
361
+ "fieldId": field_id,
362
+ "fieldValue": field_value,
363
+ }
364
+
365
+ data = await self._graphql(query, variables)
366
+ edges = ((data.get("findCards") or {}).get("edges")) or []
367
+
368
+ cards = [edge.get("node", {}) for edge in edges]
369
+
370
+ logger.info(
371
+ "Found %d cards in pipe %s where field %s == %s",
372
+ len(cards),
373
+ pipe_id,
374
+ field_id,
375
+ field_value,
376
+ )
377
+
378
+ return cards
379
+
380
+ # ---------------------------------------------------------
381
+ # CREATE CARD
382
+ # ---------------------------------------------------------
383
+
384
+ @run_fn_async(retry=default_retry)
385
+ async def create_card(
386
+ self,
387
+ card: CreateCardInput,
388
+ response_fields: Optional[str] = None,
389
+ ) -> Dict:
390
+ if response_fields is None:
391
+ response_fields = """
392
+ id
393
+ title
394
+ current_phase { id name }
395
+ fields { name value field { id type } }
396
+ created_at
397
+ """
398
+
399
+ mutation = f"""
400
+ mutation CreateCard($input: CreateCardInput!) {{
401
+ createCard(input: $input) {{
402
+ card {{
403
+ {response_fields}
404
+ }}
405
+ }}
406
+ }}
407
+ """
408
+
409
+ variables = {"input": card.model_dump()}
410
+ data = await self._graphql(mutation, variables)
411
+
412
+ return (data.get("createCard") or {}).get("card") or {}
413
+
414
+ # ---------------------------------------------------------
415
+ # FIELD HELPERS
416
+ # ---------------------------------------------------------
417
+
418
+ def get_field_value_by_name(self, card: Dict, field_name: str) -> Any:
419
+ for field in card.get("fields", []):
420
+ if field.get("name") == field_name:
421
+ try:
422
+ is_list = json.loads(field.get("value"))
423
+ if isinstance(is_list, list):
424
+ if is_list:
425
+ return is_list
426
+ else:
427
+ return None
428
+ else:
429
+ return field.get("value")
430
+ except Exception:
431
+ return field.get("value")
432
+ return None
433
+
434
+ def get_field_value_by_id(self, card: Dict, field_id: str) -> Any:
435
+ for field in card.get("fields", []):
436
+ if field.get("field", {}).get("id", {}) == field_id:
437
+ try:
438
+ is_list = json.loads(field.get("value"))
439
+ if isinstance(is_list, list):
440
+ if is_list:
441
+ return is_list
442
+ else:
443
+ return None
444
+ else:
445
+ return field.get("value")
446
+ except Exception:
447
+ return field.get("value")
448
+ return None
449
+
450
+ # ---------------------------------------------------------
451
+ # CARD OPERATIONS
452
+ # ---------------------------------------------------------
453
+ @run_fn_async(retry=default_retry)
454
+ async def move_card_to_phase(
455
+ self,
456
+ card_id: str,
457
+ phase_id: str,
458
+ response_fields: str | None = None,
459
+ ):
460
+ if not response_fields:
461
+ response_fields = """
462
+ card {
463
+ id
464
+ title
465
+ current_phase {
466
+ id
467
+ name
468
+ }
469
+ }
470
+ """
471
+
472
+ mutation = f"""
473
+ mutation MoveCardToPhase($input: MoveCardToPhaseInput!) {{
474
+ moveCardToPhase(input: $input) {{
475
+ {response_fields}
476
+ }}
477
+ }}
478
+ """
479
+
480
+ variables = {"input": {"card_id": card_id, "destination_phase_id": phase_id}}
481
+
482
+ data = await self._graphql(mutation, variables)
483
+ result = data.get("moveCardToPhase")
484
+
485
+ return result is not None
486
+
487
+ # ---------------------------------------------------------
488
+ @run_fn_async(retry=default_retry)
489
+ async def update_card_fields_by_id(
490
+ self,
491
+ card_id: str,
492
+ fields: Dict[str, Any],
493
+ response_fields: Optional[str] = None,
494
+ ) -> Dict:
495
+ if not response_fields:
496
+ response_fields = """
497
+ ... on Card {
498
+ id
499
+ title
500
+ fields { name value }
501
+ current_phase { id name }
502
+ }
503
+ """
504
+
505
+ mutation = f"""
506
+ mutation UpdateFieldsValues($input: UpdateFieldsValuesInput!) {{
507
+ updateFieldsValues(input: $input) {{
508
+ success
509
+ updatedNode {{
510
+ {response_fields}
511
+ }}
512
+ userErrors {{
513
+ field
514
+ message
515
+ }}
516
+ }}
517
+ }}
518
+ """
519
+
520
+ field_attrs = [{"fieldId": fid, "value": value} for fid, value in fields.items()]
521
+
522
+ variables = {
523
+ "input": {
524
+ "nodeId": card_id,
525
+ "values": field_attrs,
526
+ }
527
+ }
528
+
529
+ data = await self._graphql(mutation, variables)
530
+ result = data.get("updateFieldsValues") or {}
531
+
532
+ if result.get("userErrors"):
533
+ raise RuntimeError(f"Pipefy update failed: {result['userErrors']}")
534
+
535
+ return result.get("updatedNode") or {}
536
+
537
+ @run_fn_async(retry=default_retry)
538
+ async def update_card_field_by_id(
539
+ self,
540
+ card_id: str,
541
+ field_id: str,
542
+ new_value: str,
543
+ ) -> bool:
544
+ mutation = """
545
+ mutation UpdateCardField($input: UpdateCardFieldInput!) {
546
+ updateCardField(input: $input) {
547
+ success
548
+ }
549
+ }
550
+ """
551
+
552
+ variables = {
553
+ "input": {
554
+ "card_id": card_id,
555
+ "field_id": field_id,
556
+ "new_value": new_value,
557
+ }
558
+ }
559
+
560
+ data = await self._graphql(mutation, variables)
561
+ return (data.get("updateCardField") or {}).get("success", False)
562
+
563
+ # ---------------------------------------------------------
564
+ # FILTERS
565
+ # ---------------------------------------------------------
566
+
567
+ def __apply_client_side_filter(
568
+ self,
569
+ card: Dict,
570
+ field_filters: Optional[List[FieldFilter]],
571
+ ) -> Optional[Dict]:
572
+ if not field_filters:
573
+ return card
574
+
575
+ card_fields = card.get("fields", [])
576
+
577
+ for filter in field_filters:
578
+ value = next(
579
+ (f.get("value") for f in card_fields if f.get("name") == filter.field),
580
+ None,
581
+ )
582
+
583
+ if value is None:
584
+ return None
585
+
586
+ if filter.operator == FieldFilterOperator.EQUAL:
587
+ if value != filter.value:
588
+ return None
589
+
590
+ return card
591
+
592
+ @run_fn_async(retry=default_retry)
593
+ async def presign_url(
594
+ self,
595
+ org_id: str,
596
+ file_path: str,
597
+ response_fields: Optional[str] = None,
598
+ ) -> Dict:
599
+ if not org_id:
600
+ raise RuntimeError("org_id must be provided")
601
+
602
+ if not file_path:
603
+ raise RuntimeError("file_path must be provided")
604
+
605
+ if response_fields is None:
606
+ response_fields = "url"
607
+
608
+ mutation = f"""
609
+ mutation CreatePresignedUrl($input: CreatePresignedUrlInput!) {{
610
+ createPresignedUrl(input: $input) {{
611
+ {response_fields}
612
+ }}
613
+ }}
614
+ """
615
+
616
+ variables = {
617
+ "input": {
618
+ "organizationId": org_id,
619
+ "fileName": os.path.basename(str(file_path)),
620
+ }
621
+ }
622
+
623
+ data = await self._graphql(mutation, variables)
624
+ result = data.get("createPresignedUrl") or {}
625
+
626
+ logger.debug(
627
+ "Received presigned URL for '%s' in org '%s': %s",
628
+ os.path.basename(file_path),
629
+ org_id,
630
+ result.get("url"),
631
+ )
632
+
633
+ return result
634
+
635
+ @run_fn_async(retry=default_retry)
636
+ async def upload_file(
637
+ self,
638
+ presigned_url: Dict,
639
+ file_path: str,
640
+ content_type: str = "application/octet-stream",
641
+ ) -> str:
642
+ url = presigned_url.get("url")
643
+ if not url:
644
+ raise RuntimeError("Invalid presigned_url: missing 'url'")
645
+
646
+ if not file_path:
647
+ raise RuntimeError("file_path must be provided")
648
+
649
+ logger.debug("Uploading file '%s' → %s (async)", file_path, url)
650
+
651
+ http = await self._get_http()
652
+ with open(file_path, "rb") as f:
653
+ content = f.read()
654
+
655
+ resp = await http.put(
656
+ url,
657
+ content=content,
658
+ headers={"Content-Type": content_type},
659
+ timeout=60,
660
+ )
661
+ resp.raise_for_status()
662
+
663
+ logger.info("Successfully uploaded file '%s' (async).", file_path)
664
+
665
+ clean_url = url.split("?")[0]
666
+ s3_key = clean_url.split("amazonaws.com/")[-1]
667
+
668
+ logger.debug("Resolved S3 key after upload: %s", s3_key)
669
+
670
+ return s3_key
671
+
672
+ @run_fn_async(retry=default_retry)
673
+ async def get_database_record_by_title(
674
+ self,
675
+ database_id: str,
676
+ title: str,
677
+ limit: int = 100,
678
+ response_fields: Optional[str] = None,
679
+ ) -> List[Dict]:
680
+ if response_fields is None:
681
+ response_fields = """
682
+ id
683
+ title
684
+ record_fields {
685
+ name
686
+ value
687
+ field { id }
688
+ }
689
+ """
690
+
691
+ query = f"""
692
+ query GetDatabaseRecords($databaseId: ID!, $first: Int!, $title: String!) {{
693
+ table_records(
694
+ table_id: $databaseId,
695
+ first: $first,
696
+ search: {{ title: $title }}
697
+ ) {{
698
+ edges {{
699
+ node {{
700
+ {response_fields}
701
+ }}
702
+ }}
703
+ }}
704
+ }}
705
+ """
706
+
707
+ variables = {
708
+ "databaseId": str(database_id),
709
+ "first": limit,
710
+ "title": title,
711
+ }
712
+
713
+ try:
714
+ data = await self._graphql(query, variables)
715
+
716
+ edges = (data.get("table_records") or {}).get("edges", [])
717
+ records = [edge.get("node", {}) for edge in edges]
718
+
719
+ logger.info(
720
+ "Retrieved %d database records from database %s matching title '%s'",
721
+ len(records),
722
+ database_id,
723
+ title,
724
+ )
725
+
726
+ return records
727
+
728
+ except Exception as e:
729
+ logger.error(
730
+ f"Error fetching database records for database {database_id} and title '{title}': {e}",
731
+ exc_info=True,
732
+ )
733
+ return []
734
+
735
+ @run_fn_async(retry=default_retry)
736
+ async def download_card_attachments(
737
+ self,
738
+ field_id: str,
739
+ card_id: Optional[str] = None,
740
+ card: Optional[dict] = None,
741
+ output_dir: str = "attachments",
742
+ ) -> List[str]:
743
+ if not card and not card_id:
744
+ raise Exception("Either card or card_id must be passed as parameter")
745
+
746
+ if card_id:
747
+ card = await self.get_card_by_id(card_id)
748
+
749
+ if not card:
750
+ raise Exception("Could not resolve card")
751
+
752
+ attachments = self.get_field_value_by_id(card, field_id)
753
+
754
+ if not attachments:
755
+ logger.warning("No attachments found for field '%s' in card %s", field_id, card_id)
756
+ return []
757
+
758
+ if isinstance(attachments, str):
759
+ attachments = [attachments]
760
+ elif not isinstance(attachments, list):
761
+ logger.error("Unexpected attachments format: %s", type(attachments))
762
+ return []
763
+
764
+ os.makedirs(output_dir, exist_ok=True)
765
+ saved_files = []
766
+
767
+ http = await self._get_http()
768
+ for i, url in enumerate(attachments):
769
+ clean_url = url.split("?")[0]
770
+ original_filename = clean_url.split("/")[-1]
771
+
772
+ filename = f"{i}_{original_filename}"
773
+ file_path = os.path.join(output_dir, filename)
774
+
775
+ logger.info("Downloading attachment %d to %s (async)", i, file_path)
776
+
777
+ try:
778
+ response = await http.get(url, timeout=60)
779
+ response.raise_for_status()
780
+
781
+ with open(file_path, "wb") as f:
782
+ f.write(response.content)
783
+
784
+ saved_files.append(file_path)
785
+ except Exception as e:
786
+ logger.error("Failed to download %s: %s", url, e)
787
+
788
+ return saved_files
789
+
790
+ @run_fn_async(retry=default_retry)
791
+ async def _prepare_and_upload_file(self, presigned_url: Dict, file_path: str) -> str:
792
+ url: str = presigned_url["url"]
793
+ http = await self._get_http()
794
+
795
+ await http.put(
796
+ url,
797
+ content=b"BINARY_DATA",
798
+ headers={"Content-Type": "application/pdf"},
799
+ )
800
+
801
+ logger.debug("Prepared presigned URL (async)")
802
+ logger.debug("Uploading file %s to presigned URL (async)", file_path)
803
+ with open(file_path, "rb") as f:
804
+ content = f.read()
805
+
806
+ resp = await http.put(url, content=content)
807
+ resp.raise_for_status()
808
+ logger.debug("Uploaded file %s to presigned URL (async)", file_path)
809
+
810
+ clean_url = url.split("?")[0]
811
+ return clean_url.split("amazonaws.com/")[-1]
812
+
813
+ @run_fn_async(retry=default_retry)
814
+ async def attach_file_to_card(
815
+ self,
816
+ card_id: str,
817
+ field_id: str,
818
+ file_paths: List[str],
819
+ org_id: str,
820
+ ) -> bool:
821
+ parsed_urls: List[str] = []
822
+ for file_path in file_paths:
823
+ presigned_url = await self.presign_url(org_id, file_path)
824
+
825
+ if not presigned_url:
826
+ raise RuntimeError("Failed to get presigned URL from Pipefy")
827
+
828
+ parsed_url = await self._prepare_and_upload_file(presigned_url, file_path)
829
+ parsed_urls.append(parsed_url)
830
+
831
+ result = await self.update_card_field_by_id(card_id=card_id, field_id=field_id, new_value=parsed_urls)
832
+
833
+ if result:
834
+ logger.info(
835
+ "Updated card %s field %s with uploaded file (async).",
836
+ card_id,
837
+ field_id,
838
+ )
839
+ else:
840
+ logger.error(
841
+ "Failed to update card %s field %s with uploaded file (async).",
842
+ card_id,
843
+ field_id,
844
+ )
845
+
846
+ return result
847
+
848
+ @run_fn_async(retry=default_retry)
849
+ async def update_card_labels(
850
+ self,
851
+ card_id: str,
852
+ label_ids: list[str],
853
+ ) -> bool:
854
+ mutation = """
855
+ mutation UpdateCard($input: UpdateCardInput!) {
856
+ updateCard(input: $input) {
857
+ card {
858
+ id
859
+ }
860
+ }
861
+ }
862
+ """
863
+
864
+ variables = {
865
+ "input": {
866
+ "id": card_id,
867
+ "label_ids": label_ids,
868
+ }
869
+ }
870
+
871
+ response = await self._graphql(mutation, variables)
872
+
873
+ if not response:
874
+ return False
875
+
876
+ if "errors" in response:
877
+ raise Exception(f"Pipefy error: {response['errors']}")
878
+
879
+ return bool((response.get("updateCard") or {}).get("card"))
880
+
881
+ @run_fn_async(retry=default_retry)
882
+ async def add_label_to_card(
883
+ self,
884
+ card_id: str,
885
+ new_label_id: str,
886
+ ) -> bool:
887
+ query = """
888
+ query GetCardLabels($id: ID!) {
889
+ card(id: $id) {
890
+ labels {
891
+ id
892
+ }
893
+ }
894
+ }
895
+ """
896
+
897
+ data = await self._graphql(query, {"id": card_id})
898
+
899
+ if not data or "errors" in data:
900
+ raise Exception(f"Pipefy error: {data.get('errors')}")
901
+
902
+ current_labels = [label["id"] for label in (data.get("card") or {}).get("labels", [])]
903
+
904
+ if new_label_id not in current_labels:
905
+ current_labels.append(new_label_id)
906
+
907
+ return await self.update_card_labels(card_id, current_labels)