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,1016 @@
1
+ import json
2
+ import os
3
+ from logging import getLogger
4
+ from typing import Any, Dict, List, Optional
5
+
6
+ import requests
7
+
8
+ from ergon.task.helpers import run_fn
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 PipefyService:
24
+ def __init__(self, client: PipefyClient):
25
+ logger.info("Initializing PipefyService")
26
+
27
+ self.client = client
28
+ self.endpoint = client.endpoint
29
+ self.timeout_sec = client.timeout_sec
30
+ self.session = requests.Session()
31
+
32
+ self._after_cursor = None
33
+ self._has_next_page = False
34
+
35
+ self.authenticate()
36
+
37
+ # ---------------------------------------------------------
38
+ # AUTHENTICATION
39
+ # ---------------------------------------------------------
40
+
41
+ def get_access_token(self) -> str | None:
42
+ return self._token
43
+
44
+ @run_fn(retry=default_retry, trace_name="PipefyService.authenticate")
45
+ def authenticate(self) -> None:
46
+ logger.info("Authenticating with Pipefy")
47
+ resp = requests.post(
48
+ self.client.oauth_token_url,
49
+ json={
50
+ "grant_type": "client_credentials",
51
+ "client_id": self.client.client_id,
52
+ "client_secret": self.client.client_secret,
53
+ },
54
+ timeout=30,
55
+ )
56
+
57
+ resp.raise_for_status()
58
+ data = resp.json()
59
+ self._token = data.get("access_token")
60
+
61
+ logger.info("Authenticated successfully")
62
+
63
+ self.session.headers.update(
64
+ {
65
+ "Authorization": f"Bearer {self._token}",
66
+ "Content-Type": "application/json",
67
+ }
68
+ )
69
+
70
+ # ---------------------------------------------------------
71
+ # GENERIC GRAPHQL
72
+ # ---------------------------------------------------------
73
+ def _graphql(self, query: str, variables: Dict[str, Any]) -> Dict[str, Any]:
74
+ if not self._token:
75
+ raise ValueError("Authentication required")
76
+
77
+ payload = {"query": query, "variables": variables}
78
+
79
+ def send_request():
80
+ return self.session.post(
81
+ self.endpoint,
82
+ json=payload,
83
+ timeout=self.timeout_sec,
84
+ )
85
+
86
+ resp = send_request()
87
+
88
+ logger.debug(f"GraphQL query response: {resp.status_code}")
89
+
90
+ # Re-auth
91
+ if resp.status_code == 401:
92
+ logger.debug("Unauthorized. Re-authenticating with Pipefy")
93
+ self.authenticate()
94
+ logger.debug("Re-authenticated successfully")
95
+ resp = send_request()
96
+
97
+ resp.raise_for_status()
98
+ data = resp.json()
99
+
100
+ logger.debug("GraphQL query completed successfully")
101
+ data = resp.json()
102
+ return data.get("data", {})
103
+
104
+ @run_fn(retry=default_retry)
105
+ def get_pipe_fields(
106
+ self,
107
+ pipe_id: str,
108
+ response_fields: Optional[str] = None,
109
+ ) -> List[Dict[str, Any]]:
110
+ """
111
+ Retrieve ALL fields from ALL phases of a Pipefy pipe.
112
+
113
+ Args:
114
+ pipe_id: Pipe ID
115
+ response_fields: Optional GraphQL shape override
116
+
117
+ Returns:
118
+ Flat list of fields in the format:
119
+ [
120
+ {
121
+ "phase_id": "123",
122
+ "phase_name": "Formulário Inicial",
123
+ "id": "cpf",
124
+ "label": "CPF"
125
+ },
126
+ ...
127
+ ]
128
+ """
129
+ if not pipe_id:
130
+ raise RuntimeError("pipe_id must be provided")
131
+
132
+ # Default GraphQL response shape
133
+ if response_fields is None:
134
+ response_fields = """
135
+ id
136
+ name
137
+ phases {
138
+ id
139
+ name
140
+ fields {
141
+ id
142
+ label
143
+ }
144
+ }
145
+ """
146
+
147
+ query = f"""
148
+ query GetPipeFields($pipeId: ID!) {{
149
+ pipe(id: $pipeId) {{
150
+ {response_fields}
151
+ }}
152
+ }}
153
+ """
154
+
155
+ data = self._graphql(query, {"pipeId": pipe_id})
156
+
157
+ pipe = data.get("pipe") or {}
158
+ phases = pipe.get("phases") or []
159
+
160
+ # Log useful info
161
+ total_fields = sum(len(p.get("fields", [])) for p in phases)
162
+ logger.info(
163
+ "Retrieved %d phases and %d total fields from pipe %s",
164
+ len(phases),
165
+ total_fields,
166
+ pipe_id,
167
+ )
168
+
169
+ # Flatten structure
170
+ result = []
171
+ for phase in phases:
172
+ for f in phase.get("fields", []):
173
+ result.append(
174
+ {
175
+ "phase_id": phase.get("id"),
176
+ "phase_name": phase.get("name"),
177
+ "id": f.get("id"),
178
+ "label": f.get("label"),
179
+ }
180
+ )
181
+
182
+ return result
183
+
184
+ # -------------------------------------------------------------
185
+ @run_fn(retry=default_retry)
186
+ def get_pipe_start_form_fields(
187
+ self,
188
+ pipe_id: str,
189
+ response_fields: Optional[str] = None,
190
+ ) -> List[Dict[str, Any]]:
191
+ """
192
+ Retrieve the START FORM fields of a Pipefy pipe.
193
+ These are the fields shown to the user when creating a card.
194
+
195
+ Args:
196
+ pipe_id: Pipe ID
197
+ response_fields: Optional GraphQL shape override
198
+
199
+ Returns:
200
+ [
201
+ {
202
+ "phase_id": "<pipe_id>",
203
+ "phase_name": "<pipe_name>",
204
+ "id": "field_id",
205
+ "label": "Field Label"
206
+ },
207
+ ...
208
+ ]
209
+ """
210
+ if not pipe_id:
211
+ raise RuntimeError("pipe_id must be provided")
212
+
213
+ if response_fields is None:
214
+ response_fields = """
215
+ id
216
+ name
217
+ start_form_fields {
218
+ id
219
+ label
220
+ }
221
+ """
222
+
223
+ query = f"""
224
+ query GetStartFormFields($pipeId: ID!) {{
225
+ pipe(id: $pipeId) {{
226
+ {response_fields}
227
+ }}
228
+ }}
229
+ """
230
+
231
+ data = self._graphql(query, {"pipeId": pipe_id})
232
+
233
+ pipe = data.get("pipe") or {}
234
+ fields = pipe.get("start_form_fields") or []
235
+
236
+ result = [
237
+ {
238
+ "phase_id": pipe.get("id"),
239
+ "phase_name": pipe.get("name"),
240
+ "id": f.get("id"),
241
+ "label": f.get("label"),
242
+ }
243
+ for f in fields
244
+ ]
245
+
246
+ logger.info(
247
+ "Retrieved %d start_form_fields from pipe %s (%s)",
248
+ len(fields),
249
+ pipe_id,
250
+ pipe.get("name"),
251
+ )
252
+
253
+ return result
254
+
255
+ # ---------------------------------------------------------
256
+ # CARD QUERIES
257
+ # ---------------------------------------------------------
258
+
259
+ @run_fn(retry=default_retry)
260
+ def get_card_by_id(
261
+ self,
262
+ card_id: str,
263
+ response_fields: Optional[str] = None,
264
+ ) -> Dict:
265
+ if response_fields is None:
266
+ response_fields = """
267
+ id
268
+ title
269
+ fields {
270
+ field { id }
271
+ name
272
+ value
273
+ }
274
+ current_phase { id name }
275
+ updated_at
276
+ """
277
+
278
+ query = f"""
279
+ query GetCard($id: ID!) {{
280
+ card(id: $id) {{
281
+ {response_fields}
282
+ }}
283
+ }}
284
+ """
285
+
286
+ data = self._graphql(query, {"id": card_id})
287
+ return data.get("card", {})
288
+
289
+ # ---------------------------------------------------------
290
+
291
+ @run_fn(retry=default_retry)
292
+ def get_next_card(
293
+ self,
294
+ phase_id: str,
295
+ field_filters: Optional[List[FieldFilter]] = None,
296
+ batch_size: int = 1,
297
+ response_fields: Optional[str] = None,
298
+ ) -> Optional[List[Dict]]:
299
+ if response_fields is None:
300
+ response_fields = """
301
+ id
302
+ title
303
+ fields {
304
+ field { id }
305
+ name
306
+ value
307
+ }
308
+ current_phase { id name }
309
+ updated_at
310
+ """
311
+
312
+ query = f"""
313
+ query GetCardsFromPhase($phaseId: ID!, $after: String, $batch_size: Int!) {{
314
+ phase(id: $phaseId) {{
315
+ id
316
+ name
317
+ cards(first: $batch_size, after: $after) {{
318
+ edges {{
319
+ node {{
320
+ {response_fields}
321
+ }}
322
+ }}
323
+ pageInfo {{
324
+ hasNextPage
325
+ endCursor
326
+ }}
327
+ }}
328
+ }}
329
+ }}
330
+ """
331
+
332
+ data = self._graphql(
333
+ query,
334
+ {"phaseId": phase_id, "after": self._after_cursor, "batch_size": batch_size},
335
+ )
336
+
337
+ phase = data.get("phase", {})
338
+ if not phase:
339
+ return None
340
+
341
+ cards = phase.get("cards", {}).get("edges", [])
342
+ if not cards:
343
+ self._after_cursor = None
344
+ self._has_next_page = False
345
+ return None
346
+
347
+ page_info = phase.get("cards", {}).get("pageInfo", {})
348
+ self._after_cursor = page_info.get("endCursor")
349
+ self._has_next_page = page_info.get("hasNextPage")
350
+
351
+ filtered = []
352
+ for edge in cards:
353
+ card = edge.get("node", {})
354
+ if self.__apply_client_side_filter(card, field_filters):
355
+ filtered.append(card)
356
+
357
+ return filtered or None
358
+
359
+ @run_fn(retry=default_retry)
360
+ def search_cards_by_field(
361
+ self,
362
+ pipe_id: str,
363
+ field_id: str,
364
+ field_value: str,
365
+ response_fields: Optional[str] = None,
366
+ ) -> List[Dict]:
367
+ """
368
+ Find cards in a Pipefy pipe by searching a **single custom field value**.
369
+ Uses Pipefy's `findCards` GraphQL API.
370
+
371
+ Args:
372
+ pipe_id: Target pipe
373
+ field_id: The Pipefy field ID / indexName used for the search
374
+ field_value: Value to match exactly
375
+ response_fields: Optional override for GraphQL returned fields
376
+
377
+ Returns:
378
+ A list of card dicts (flattened): [{...}, {...}]
379
+ """
380
+
381
+ if not pipe_id:
382
+ raise RuntimeError("pipe_id must be provided")
383
+ if not field_id:
384
+ raise RuntimeError("field_id must be provided")
385
+
386
+ # Default GraphQL shape (consistent with other manager methods)
387
+ if response_fields is None:
388
+ response_fields = """
389
+ id
390
+ title
391
+ updated_at
392
+ current_phase { id name }
393
+ fields {
394
+ field { id }
395
+ name
396
+ value
397
+ }
398
+ """
399
+
400
+ query = f"""
401
+ query FindCards($pipeId: ID!, $fieldId: String!, $fieldValue: String!) {{
402
+ findCards(
403
+ pipeId: $pipeId,
404
+ search: {{ fieldId: $fieldId, fieldValue: $fieldValue }}
405
+ ) {{
406
+ edges {{
407
+ node {{
408
+ {response_fields}
409
+ }}
410
+ }}
411
+ }}
412
+ }}
413
+ """
414
+
415
+ variables = {
416
+ "pipeId": str(pipe_id),
417
+ "fieldId": field_id,
418
+ "fieldValue": field_value,
419
+ }
420
+
421
+ data = self._graphql(query, variables)
422
+ edges = ((data.get("findCards") or {}).get("edges")) or []
423
+
424
+ cards = [edge.get("node", {}) for edge in edges]
425
+
426
+ logger.info(
427
+ "Found %d cards in pipe %s where field %s == %s",
428
+ len(cards),
429
+ pipe_id,
430
+ field_id,
431
+ field_value,
432
+ )
433
+
434
+ return cards
435
+
436
+ # ---------------------------------------------------------
437
+ # CREATE CARD
438
+ # ---------------------------------------------------------
439
+
440
+ @run_fn(retry=default_retry)
441
+ def create_card(
442
+ self,
443
+ card: CreateCardInput,
444
+ response_fields: Optional[str] = None,
445
+ ) -> Dict:
446
+ if response_fields is None:
447
+ response_fields = """
448
+ id
449
+ title
450
+ current_phase { id name }
451
+ fields { name value field { id type } }
452
+ created_at
453
+ """
454
+
455
+ mutation = f"""
456
+ mutation CreateCard($input: CreateCardInput!) {{
457
+ createCard(input: $input) {{
458
+ card {{
459
+ {response_fields}
460
+ }}
461
+ }}
462
+ }}
463
+ """
464
+
465
+ variables = {"input": card.model_dump()}
466
+ data = self._graphql(mutation, variables)
467
+
468
+ return (data.get("createCard") or {}).get("card") or {}
469
+
470
+ # ---------------------------------------------------------
471
+ # FIELD HELPERS
472
+ # ---------------------------------------------------------
473
+
474
+ def get_field_value_by_name(self, card: Dict, field_name: str) -> Any:
475
+ for field in card.get("fields", []):
476
+ if field.get("name") == field_name:
477
+ try:
478
+ is_list = json.loads(field.get("value"))
479
+ if isinstance(is_list, list):
480
+ if is_list:
481
+ return is_list
482
+ else:
483
+ return None
484
+ else:
485
+ return field.get("value")
486
+ except Exception:
487
+ return field.get("value")
488
+ return None
489
+
490
+ def get_field_value_by_id(self, card: Dict, field_id: str) -> Any:
491
+ for field in card.get("fields", []):
492
+ if field.get("field", {}).get("id", {}) == field_id:
493
+ try:
494
+ is_list = json.loads(field.get("value"))
495
+ if isinstance(is_list, list):
496
+ if is_list:
497
+ return is_list
498
+ else:
499
+ return None
500
+ else:
501
+ return field.get("value")
502
+ except Exception:
503
+ return field.get("value")
504
+ return None
505
+
506
+ # ---------------------------------------------------------
507
+ # CARD OPERATIONS
508
+ # ---------------------------------------------------------
509
+ @run_fn(retry=default_retry)
510
+ def move_card_to_phase(
511
+ self,
512
+ card_id: str,
513
+ phase_id: str,
514
+ response_fields: str | None = None,
515
+ ):
516
+ # Default fields returned by the mutation
517
+ if not response_fields:
518
+ response_fields = """
519
+ card {
520
+ id
521
+ title
522
+ current_phase {
523
+ id
524
+ name
525
+ }
526
+ }
527
+ """
528
+
529
+ mutation = f"""
530
+ mutation MoveCardToPhase($input: MoveCardToPhaseInput!) {{
531
+ moveCardToPhase(input: $input) {{
532
+ {response_fields}
533
+ }}
534
+ }}
535
+ """
536
+
537
+ variables = {"input": {"card_id": card_id, "destination_phase_id": phase_id}}
538
+
539
+ data = self._graphql(mutation, variables)
540
+ result = data.get("moveCardToPhase")
541
+
542
+ # If result exists, the mutation succeeded
543
+ return result is not None
544
+
545
+ # ---------------------------------------------------------
546
+ @run_fn(retry=default_retry)
547
+ def update_card_fields_by_id(
548
+ self,
549
+ card_id: str,
550
+ fields: Dict[str, Any],
551
+ response_fields: Optional[str] = None,
552
+ ) -> Dict:
553
+ if not response_fields:
554
+ response_fields = """
555
+ ... on Card {
556
+ id
557
+ title
558
+ fields { name value }
559
+ current_phase { id name }
560
+ }
561
+ """
562
+
563
+ # 1. Note the generic 'nodeId' and 'values' in the mutation signature
564
+ mutation = f"""
565
+ mutation UpdateFieldsValues($input: UpdateFieldsValuesInput!) {{
566
+ updateFieldsValues(input: $input) {{
567
+ success
568
+ updatedNode {{
569
+ {response_fields}
570
+ }}
571
+ userErrors {{
572
+ field
573
+ message
574
+ }}
575
+ }}
576
+ }}
577
+ """
578
+
579
+ # 2. Fix the item structure: 'fieldId' and 'value'
580
+ #
581
+ field_attrs = [{"fieldId": fid, "value": value} for fid, value in fields.items()]
582
+
583
+ # 3. Fix the input structure: 'nodeId' and 'values'
584
+ #
585
+ variables = {
586
+ "input": {
587
+ "nodeId": card_id, # CHANGED: card_id -> nodeId
588
+ "values": field_attrs, # CHANGED: fields_attributes -> values
589
+ }
590
+ }
591
+
592
+ data = self._graphql(mutation, variables)
593
+ result = data.get("updateFieldsValues") or {}
594
+
595
+ if result.get("userErrors"):
596
+ raise RuntimeError(f"Pipefy update failed: {result['userErrors']}")
597
+
598
+ return result.get("updatedNode") or {}
599
+
600
+ @run_fn(retry=default_retry)
601
+ def update_card_field_by_id(
602
+ self,
603
+ card_id: str,
604
+ field_id: str,
605
+ new_value: str,
606
+ ) -> bool:
607
+ mutation = """
608
+ mutation UpdateCardField($input: UpdateCardFieldInput!) {
609
+ updateCardField(input: $input) {
610
+ success
611
+ }
612
+ }
613
+ """
614
+
615
+ variables = {
616
+ "input": {
617
+ "card_id": card_id,
618
+ "field_id": field_id,
619
+ "new_value": new_value,
620
+ }
621
+ }
622
+
623
+ data = self._graphql(mutation, variables)
624
+ return (data.get("updateCardField") or {}).get("success", False)
625
+
626
+ # ---------------------------------------------------------
627
+ # FILTERS
628
+ # ---------------------------------------------------------
629
+
630
+ def __apply_client_side_filter(
631
+ self,
632
+ card: Dict,
633
+ field_filters: Optional[List[FieldFilter]],
634
+ ) -> Optional[Dict]:
635
+ if not field_filters:
636
+ return card
637
+
638
+ card_fields = card.get("fields", [])
639
+
640
+ for filter in field_filters:
641
+ value = next(
642
+ (f.get("value") for f in card_fields if f.get("name") == filter.field),
643
+ None,
644
+ )
645
+
646
+ if value is None:
647
+ return None
648
+
649
+ if filter.operator == FieldFilterOperator.EQUAL:
650
+ if value != filter.value:
651
+ return None
652
+
653
+ return card
654
+
655
+ @run_fn(retry=default_retry)
656
+ def presign_url(
657
+ self,
658
+ org_id: str,
659
+ file_path: str,
660
+ response_fields: Optional[str] = None,
661
+ ) -> Dict:
662
+ """
663
+ Request a pre-signed upload URL from Pipefy for uploading attachments.
664
+
665
+ Args:
666
+ org_id: Organization ID
667
+ file_path: Local file path
668
+ response_fields: Optional override for returned GraphQL fields
669
+
670
+ Returns:
671
+ Dict containing the presigned URL metadata returned by Pipefy.
672
+ """
673
+
674
+ if not org_id:
675
+ raise RuntimeError("org_id must be provided")
676
+
677
+ if not file_path:
678
+ raise RuntimeError("file_path must be provided")
679
+
680
+ if response_fields is None:
681
+ # Pipefy only returns 'url' for this mutation, but we support extension
682
+ response_fields = "url"
683
+
684
+ mutation = f"""
685
+ mutation CreatePresignedUrl($input: CreatePresignedUrlInput!) {{
686
+ createPresignedUrl(input: $input) {{
687
+ {response_fields}
688
+ }}
689
+ }}
690
+ """
691
+
692
+ variables = {
693
+ "input": {
694
+ "organizationId": org_id,
695
+ "fileName": os.path.basename(str(file_path)),
696
+ }
697
+ }
698
+
699
+ data = self._graphql(mutation, variables)
700
+ result = data.get("createPresignedUrl") or {}
701
+
702
+ logger.debug(
703
+ "Received presigned URL for '%s' in org '%s': %s",
704
+ os.path.basename(file_path),
705
+ org_id,
706
+ result.get("url"),
707
+ )
708
+
709
+ return result
710
+
711
+ @run_fn(retry=default_retry)
712
+ def upload_file(
713
+ self,
714
+ presigned_url: Dict,
715
+ file_path: str,
716
+ content_type: str = "application/octet-stream",
717
+ ) -> str:
718
+ """
719
+ Upload a local file to the provided Pipefy pre-signed URL.
720
+
721
+ Args:
722
+ presigned_url: Dict returned from create_presigned_url()
723
+ file_path: Path to the local file
724
+ content_type: MIME type for upload (default generic binary)
725
+
726
+ Returns:
727
+ The S3 object key portion of the URL (without query params).
728
+ """
729
+
730
+ url = presigned_url.get("url")
731
+ if not url:
732
+ raise RuntimeError("Invalid presigned_url: missing 'url'")
733
+
734
+ if not file_path:
735
+ raise RuntimeError("file_path must be provided")
736
+
737
+ logger.debug("Uploading file '%s' → %s", file_path, url)
738
+
739
+ with open(file_path, "rb") as f:
740
+ resp = requests.put(
741
+ url,
742
+ data=f,
743
+ headers={"Content-Type": content_type},
744
+ timeout=60,
745
+ )
746
+ resp.raise_for_status()
747
+
748
+ logger.info("Successfully uploaded file '%s'.", file_path)
749
+
750
+ clean_url = url.split("?")[0]
751
+ s3_key = clean_url.split("amazonaws.com/")[-1]
752
+
753
+ logger.debug("Resolved S3 key after upload: %s", s3_key)
754
+
755
+ return s3_key
756
+
757
+ @run_fn(retry=default_retry)
758
+ def get_database_record_by_title(
759
+ self,
760
+ database_id: str,
761
+ title: str,
762
+ limit: int = 100,
763
+ response_fields: Optional[str] = None,
764
+ ) -> List[Dict]:
765
+ """
766
+ Search Pipefy Database Records by title.
767
+
768
+ Args:
769
+ database_id: Pipefy database ID
770
+ title: search text for the record title
771
+ limit: maximum records to return
772
+ response_fields: optional custom GraphQL structure override
773
+
774
+ Returns:
775
+ List of record nodes (flat dictionaries)
776
+ """
777
+
778
+ if response_fields is None:
779
+ response_fields = """
780
+ id
781
+ title
782
+ record_fields {
783
+ name
784
+ value
785
+ field { id }
786
+ }
787
+ """
788
+
789
+ query = f"""
790
+ query GetDatabaseRecords($databaseId: ID!, $first: Int!, $title: String!) {{
791
+ table_records(
792
+ table_id: $databaseId,
793
+ first: $first,
794
+ search: {{ title: $title }}
795
+ ) {{
796
+ edges {{
797
+ node {{
798
+ {response_fields}
799
+ }}
800
+ }}
801
+ }}
802
+ }}
803
+ """
804
+
805
+ variables = {
806
+ "databaseId": str(database_id),
807
+ "first": limit,
808
+ "title": title,
809
+ }
810
+
811
+ try:
812
+ data = self._graphql(query, variables)
813
+
814
+ edges = (data.get("table_records") or {}).get("edges", [])
815
+ records = [edge.get("node", {}) for edge in edges]
816
+
817
+ logger.info(
818
+ "Retrieved %d database records from database %s matching title '%s'",
819
+ len(records),
820
+ database_id,
821
+ title,
822
+ )
823
+
824
+ return records
825
+
826
+ except Exception as e:
827
+ logger.error(
828
+ f"Error fetching database records for database {database_id} and title '{title}': {e}",
829
+ exc_info=True,
830
+ )
831
+ return []
832
+
833
+ @run_fn(retry=default_retry)
834
+ def download_card_attachments(
835
+ self,
836
+ field_id: str,
837
+ card_id: Optional[str] = None,
838
+ card: Optional[dict] = None,
839
+ output_dir: str = "attachments",
840
+ ) -> List[str]:
841
+ """
842
+ Download card attachments from a specific field.
843
+
844
+ Args:
845
+ card_id: The ID of the card.
846
+ field_id: The ID of the attachment field.
847
+ output_dir: Directory to save downloaded files.
848
+
849
+ Returns:
850
+ List[str]: Paths to the downloaded files.
851
+ """
852
+
853
+ if not card and not card_id:
854
+ raise Exception("Either card or card_id must be passed as parameter")
855
+
856
+ if card_id:
857
+ card = self.get_card_by_id(card_id)
858
+
859
+ if not card:
860
+ raise Exception("Could not resolve card")
861
+
862
+ attachments = self.get_field_value_by_id(card, field_id)
863
+
864
+ if not attachments:
865
+ logger.warning("No attachments found for field '%s' in card %s", field_id, card_id)
866
+ return []
867
+
868
+ if isinstance(attachments, str):
869
+ attachments = [attachments]
870
+ elif not isinstance(attachments, list):
871
+ logger.error("Unexpected attachments format: %s", type(attachments))
872
+ return []
873
+
874
+ os.makedirs(output_dir, exist_ok=True)
875
+ saved_files = []
876
+
877
+ for i, url in enumerate(attachments):
878
+ # Clean URL to get filename
879
+ # Example: .../uuid/filename.ext?params
880
+ clean_url = url.split("?")[0]
881
+ original_filename = clean_url.split("/")[-1]
882
+
883
+ # Index the filename to avoid collisions and preserve order
884
+ filename = f"{i}_{original_filename}"
885
+ file_path = os.path.join(output_dir, filename)
886
+
887
+ logger.info("Downloading attachment %d to %s", i, file_path)
888
+
889
+ try:
890
+ response = requests.get(url, timeout=60)
891
+ response.raise_for_status()
892
+
893
+ with open(file_path, "wb") as f:
894
+ f.write(response.content)
895
+
896
+ saved_files.append(file_path)
897
+ except Exception as e:
898
+ logger.error("Failed to download %s: %s", url, e)
899
+
900
+ return saved_files
901
+
902
+ @run_fn(retry=default_retry)
903
+ def _prepare_and_upload_file(self, presigned_url: Dict, file_path: str) -> str:
904
+ url: str = presigned_url["url"]
905
+
906
+ requests.put(
907
+ url,
908
+ data="BINARY_DATA",
909
+ headers={"Content-Type": "application/pdf"},
910
+ ).raise_for_status()
911
+
912
+ logger.debug("Prepared presigned URL")
913
+ logger.debug("Uploading file %s to presigned URL", file_path)
914
+ with open(file_path, "rb") as f:
915
+ resp = requests.put(url, data=f)
916
+ resp.raise_for_status()
917
+ logger.debug("Uploaded file %s to presigned URL", file_path)
918
+
919
+ clean_url = url.split("?")[0]
920
+ return clean_url.split("amazonaws.com/")[-1]
921
+
922
+ @run_fn(retry=default_retry)
923
+ def attach_file_to_card(
924
+ self,
925
+ card_id: str,
926
+ field_id: str,
927
+ file_paths: List[str],
928
+ org_id: str,
929
+ ) -> bool:
930
+ parsed_urls: List[str] = []
931
+ for file_path in file_paths:
932
+ presigned_url = self.presign_url(org_id, file_path)
933
+
934
+ if not presigned_url:
935
+ raise RuntimeError("Failed to get presigned URL from Pipefy")
936
+
937
+ parsed_url = self._prepare_and_upload_file(presigned_url, file_path)
938
+ parsed_urls.append(parsed_url)
939
+
940
+ result = self.update_card_field_by_id(card_id=card_id, field_id=field_id, new_value=parsed_urls)
941
+
942
+ if result:
943
+ logger.info(
944
+ "Updated card %s field %s with uploaded file.",
945
+ card_id,
946
+ field_id,
947
+ )
948
+ else:
949
+ logger.error(
950
+ "Failed to update card %s field %s with uploaded file.",
951
+ card_id,
952
+ field_id,
953
+ )
954
+
955
+ return result
956
+
957
+ @run_fn(retry=default_retry)
958
+ def update_card_labels(
959
+ self,
960
+ card_id: str,
961
+ label_ids: list[str],
962
+ ) -> bool:
963
+ mutation = """
964
+ mutation UpdateCard($input: UpdateCardInput!) {
965
+ updateCard(input: $input) {
966
+ card {
967
+ id
968
+ }
969
+ }
970
+ }
971
+ """
972
+
973
+ variables = {
974
+ "input": {
975
+ "id": card_id,
976
+ "label_ids": label_ids,
977
+ }
978
+ }
979
+
980
+ response = self._graphql(mutation, variables)
981
+
982
+ if not response:
983
+ return False
984
+
985
+ if "errors" in response:
986
+ raise Exception(f"Pipefy error: {response['errors']}")
987
+
988
+ return bool((response.get("updateCard") or {}).get("card"))
989
+
990
+ @run_fn(retry=default_retry)
991
+ def add_label_to_card(
992
+ self,
993
+ card_id: str,
994
+ new_label_id: str,
995
+ ) -> bool:
996
+ query = """
997
+ query GetCardLabels($id: ID!) {
998
+ card(id: $id) {
999
+ labels {
1000
+ id
1001
+ }
1002
+ }
1003
+ }
1004
+ """
1005
+
1006
+ data = self._graphql(query, {"id": card_id})
1007
+
1008
+ if not data or "errors" in data:
1009
+ raise Exception(f"Pipefy error: {data.get('errors')}")
1010
+
1011
+ current_labels = [label["id"] for label in (data.get("card") or {}).get("labels", [])]
1012
+
1013
+ if new_label_id not in current_labels:
1014
+ current_labels.append(new_label_id)
1015
+
1016
+ return self.update_card_labels(card_id, current_labels)