sfq 0.0.41__tar.gz → 0.0.43__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 (81) hide show
  1. {sfq-0.0.41 → sfq-0.0.43}/PKG-INFO +32 -2
  2. {sfq-0.0.41 → sfq-0.0.43}/README.md +31 -1
  3. {sfq-0.0.41 → sfq-0.0.43}/pyproject.toml +1 -1
  4. {sfq-0.0.41 → sfq-0.0.43}/src/sfq/__init__.py +87 -5
  5. {sfq-0.0.41 → sfq-0.0.43}/src/sfq/http_client.py +1 -1
  6. sfq-0.0.43/src/sfq/platform_events.py +380 -0
  7. {sfq-0.0.41 → sfq-0.0.43}/src/sfq/utils.py +68 -0
  8. sfq-0.0.43/tests/test_SFTokenAuth_e2e.py +116 -0
  9. sfq-0.0.43/tests/test_fuzzing.py +659 -0
  10. sfq-0.0.43/tests/test_platform_events_e2e.py +150 -0
  11. sfq-0.0.43/tests/test_publish_pe_baseline.py +107 -0
  12. {sfq-0.0.41 → sfq-0.0.43}/uv.lock +1 -1
  13. sfq-0.0.41/tests/test_SFTokenAuth_e2e.py +0 -58
  14. {sfq-0.0.41 → sfq-0.0.43}/.github/workflows/publish.yml +0 -0
  15. {sfq-0.0.41 → sfq-0.0.43}/.gitignore +0 -0
  16. {sfq-0.0.41 → sfq-0.0.43}/.python-version +0 -0
  17. {sfq-0.0.41 → sfq-0.0.43}/src/sfq/_cometd.py +0 -0
  18. {sfq-0.0.41 → sfq-0.0.43}/src/sfq/auth.py +0 -0
  19. {sfq-0.0.41 → sfq-0.0.43}/src/sfq/crud.py +0 -0
  20. {sfq-0.0.41 → sfq-0.0.43}/src/sfq/debug_cleanup.py +0 -0
  21. {sfq-0.0.41 → sfq-0.0.43}/src/sfq/exceptions.py +0 -0
  22. {sfq-0.0.41 → sfq-0.0.43}/src/sfq/py.typed +0 -0
  23. {sfq-0.0.41 → sfq-0.0.43}/src/sfq/query.py +0 -0
  24. {sfq-0.0.41 → sfq-0.0.43}/src/sfq/soap.py +0 -0
  25. {sfq-0.0.41 → sfq-0.0.43}/src/sfq/timeout_detector.py +0 -0
  26. {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_complex_nested.html +0 -0
  27. {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_complex_nested_styled.html +0 -0
  28. {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_empty_list.html +0 -0
  29. {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_empty_list_styled.html +0 -0
  30. {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_int_float_bool.html +0 -0
  31. {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_int_float_bool_styled.html +0 -0
  32. {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_list_value.html +0 -0
  33. {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_list_value_styled.html +0 -0
  34. {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_multiple_dicts.html +0 -0
  35. {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_multiple_dicts_styled.html +0 -0
  36. {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_nested_dict.html +0 -0
  37. {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_nested_dict_styled.html +0 -0
  38. {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_none_value.html +0 -0
  39. {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_none_value_styled.html +0 -0
  40. {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_other_types.html +0 -0
  41. {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_other_types_styled.html +0 -0
  42. {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_sample_report.html +0 -0
  43. {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_sample_report_styled.html +0 -0
  44. {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_single_flat_dict.html +0 -0
  45. {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_single_flat_dict_styled.html +0 -0
  46. {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_typecastable_keys_bool.html +0 -0
  47. {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_typecastable_keys_bool_styled.html +0 -0
  48. {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_typecastable_keys_float.html +0 -0
  49. {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_typecastable_keys_float_styled.html +0 -0
  50. {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_typecastable_keys_int.html +0 -0
  51. {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_typecastable_keys_int_styled.html +0 -0
  52. {sfq-0.0.41 → sfq-0.0.43}/tests/test_auth.py +0 -0
  53. {sfq-0.0.41 → sfq-0.0.43}/tests/test_cdelete.py +0 -0
  54. {sfq-0.0.41 → sfq-0.0.43}/tests/test_compatibility.py +0 -0
  55. {sfq-0.0.41 → sfq-0.0.43}/tests/test_cquery.py +0 -0
  56. {sfq-0.0.41 → sfq-0.0.43}/tests/test_create.py +0 -0
  57. {sfq-0.0.41 → sfq-0.0.43}/tests/test_crud.py +0 -0
  58. {sfq-0.0.41 → sfq-0.0.43}/tests/test_crud_e2e.py +0 -0
  59. {sfq-0.0.41 → sfq-0.0.43}/tests/test_cupdate.py +0 -0
  60. {sfq-0.0.41 → sfq-0.0.43}/tests/test_debug_cleanup_e2e.py +0 -0
  61. {sfq-0.0.41 → sfq-0.0.43}/tests/test_debug_cleanup_unit.py +0 -0
  62. {sfq-0.0.41 → sfq-0.0.43}/tests/test_http_client.py +0 -0
  63. {sfq-0.0.41 → sfq-0.0.43}/tests/test_http_client_retry.py +0 -0
  64. {sfq-0.0.41 → sfq-0.0.43}/tests/test_limits_api.py +0 -0
  65. {sfq-0.0.41 → sfq-0.0.43}/tests/test_log_trace_redact.py +0 -0
  66. {sfq-0.0.41 → sfq-0.0.43}/tests/test_open_frontdoor.py +0 -0
  67. {sfq-0.0.41 → sfq-0.0.43}/tests/test_query.py +0 -0
  68. {sfq-0.0.41 → sfq-0.0.43}/tests/test_query_client.py +0 -0
  69. {sfq-0.0.41 → sfq-0.0.43}/tests/test_query_client_timeout_integration.py +0 -0
  70. {sfq-0.0.41 → sfq-0.0.43}/tests/test_query_e2e.py +0 -0
  71. {sfq-0.0.41 → sfq-0.0.43}/tests/test_query_integration.py +0 -0
  72. {sfq-0.0.41 → sfq-0.0.43}/tests/test_records_to_html.py +0 -0
  73. {sfq-0.0.41 → sfq-0.0.43}/tests/test_soap.py +0 -0
  74. {sfq-0.0.41 → sfq-0.0.43}/tests/test_soap_batch_operation.py +0 -0
  75. {sfq-0.0.41 → sfq-0.0.43}/tests/test_static_resources.py +0 -0
  76. {sfq-0.0.41 → sfq-0.0.43}/tests/test_timeout_detector.py +0 -0
  77. {sfq-0.0.41 → sfq-0.0.43}/tests/test_timeout_edge_cases.py +0 -0
  78. {sfq-0.0.41 → sfq-0.0.43}/tests/test_timeout_scenarios_comprehensive.py +0 -0
  79. {sfq-0.0.41 → sfq-0.0.43}/tests/test_timeout_scenarios_summary.py +0 -0
  80. {sfq-0.0.41 → sfq-0.0.43}/tests/test_utils.py +0 -0
  81. {sfq-0.0.41 → sfq-0.0.43}/tests/test_utils_html_table.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sfq
3
- Version: 0.0.41
3
+ Version: 0.0.43
4
4
  Summary: Python wrapper for the Salesforce's Query API.
5
5
  Author-email: David Moruzzi <sfq.pypi@dmoruzi.com>
6
6
  Keywords: salesforce,salesforce query
@@ -25,6 +25,7 @@ For more varied workflows, consider using an alternative like [Simple Salesforce
25
25
  - Simplified query execution for Salesforce instances.
26
26
  - Integration with Salesforce authentication via refresh tokens.
27
27
  - Option to interact with Salesforce Tooling API for more advanced queries.
28
+ - Platform Events support (list available & publish single/batch).
28
29
 
29
30
  ## Installation
30
31
 
@@ -110,6 +111,36 @@ print(sf.get_sobject_prefixes(key_type="name"))
110
111
  >>> {'AIApplication': '0Pp', 'AIApplicationConfig': '6S9', 'AIInsightAction': '9qd', 'AIInsightFeedback': '9bq', 'AIInsightReason': '0T2', 'AIInsightValue': '9qc', ...}
111
112
  ```
112
113
 
114
+ ### Platform Events
115
+
116
+ Platform Events allow publishing and subscribing to real-time events. Requires a custom Platform Event (e.g., 'sfq__e' with fields like 'text__c').
117
+
118
+ ```python
119
+ from sfq import SFAuth
120
+
121
+ sf = SFAuth(
122
+ instance_url="https://example-dev-ed.trailblaze.my.salesforce.com",
123
+ client_id="your-client-id-here",
124
+ client_secret="your-client-secret-here",
125
+ refresh_token="your-refresh-token-here"
126
+ )
127
+
128
+ # List available events
129
+ events = sf.list_events()
130
+ print(events) # e.g., ['sfq__e']
131
+
132
+ # Publish single event
133
+ result = sf.publish('sfq__e', {'text__c': 'Hello Event!'})
134
+ print(result) # {'success': True, 'id': '2Ee...'}
135
+
136
+ # Publish batch
137
+ events_data = [
138
+ {'text__c': 'Batch 1 message'},
139
+ {'text__c': 'Batch 2 message'}
140
+ ]
141
+ batch_result = sf.publish_batch(events_data, 'sfq__e')
142
+ print(batch_result['results']) # List of results
143
+
113
144
  ## How to Obtain Salesforce Tokens
114
145
 
115
146
  To use the `sfq` library, you'll need a **client ID** and **refresh token**. The easiest way to obtain these is by using the Salesforce CLI:
@@ -183,4 +214,3 @@ To use the `sfq` library, you'll need a **client ID** and **refresh token**. The
183
214
  - **Security**: Safeguard your client_id, client_secret, and refresh_token diligently, as they provide access to your Salesforce environment. Avoid sharing or exposing them in unsecured locations.
184
215
  - **Efficient Data Retrieval**: The `query` and `cquery` function automatically handles pagination, simplifying record retrieval across large datasets. It's recommended to use the `LIMIT` clause in queries to control the volume of data returned.
185
216
  - **Advanced Tooling Queries**: Utilize the `tooling_query` function to access the Salesforce Tooling API. This option is designed for performing complex operations, enhancing your data management capabilities.
186
-
@@ -9,6 +9,7 @@ For more varied workflows, consider using an alternative like [Simple Salesforce
9
9
  - Simplified query execution for Salesforce instances.
10
10
  - Integration with Salesforce authentication via refresh tokens.
11
11
  - Option to interact with Salesforce Tooling API for more advanced queries.
12
+ - Platform Events support (list available & publish single/batch).
12
13
 
13
14
  ## Installation
14
15
 
@@ -94,6 +95,36 @@ print(sf.get_sobject_prefixes(key_type="name"))
94
95
  >>> {'AIApplication': '0Pp', 'AIApplicationConfig': '6S9', 'AIInsightAction': '9qd', 'AIInsightFeedback': '9bq', 'AIInsightReason': '0T2', 'AIInsightValue': '9qc', ...}
95
96
  ```
96
97
 
98
+ ### Platform Events
99
+
100
+ Platform Events allow publishing and subscribing to real-time events. Requires a custom Platform Event (e.g., 'sfq__e' with fields like 'text__c').
101
+
102
+ ```python
103
+ from sfq import SFAuth
104
+
105
+ sf = SFAuth(
106
+ instance_url="https://example-dev-ed.trailblaze.my.salesforce.com",
107
+ client_id="your-client-id-here",
108
+ client_secret="your-client-secret-here",
109
+ refresh_token="your-refresh-token-here"
110
+ )
111
+
112
+ # List available events
113
+ events = sf.list_events()
114
+ print(events) # e.g., ['sfq__e']
115
+
116
+ # Publish single event
117
+ result = sf.publish('sfq__e', {'text__c': 'Hello Event!'})
118
+ print(result) # {'success': True, 'id': '2Ee...'}
119
+
120
+ # Publish batch
121
+ events_data = [
122
+ {'text__c': 'Batch 1 message'},
123
+ {'text__c': 'Batch 2 message'}
124
+ ]
125
+ batch_result = sf.publish_batch(events_data, 'sfq__e')
126
+ print(batch_result['results']) # List of results
127
+
97
128
  ## How to Obtain Salesforce Tokens
98
129
 
99
130
  To use the `sfq` library, you'll need a **client ID** and **refresh token**. The easiest way to obtain these is by using the Salesforce CLI:
@@ -167,4 +198,3 @@ To use the `sfq` library, you'll need a **client ID** and **refresh token**. The
167
198
  - **Security**: Safeguard your client_id, client_secret, and refresh_token diligently, as they provide access to your Salesforce environment. Avoid sharing or exposing them in unsecured locations.
168
199
  - **Efficient Data Retrieval**: The `query` and `cquery` function automatically handles pagination, simplifying record retrieval across large datasets. It's recommended to use the `LIMIT` clause in queries to control the volume of data returned.
169
200
  - **Advanced Tooling Queries**: Utilize the `tooling_query` function to access the Salesforce Tooling API. This option is designed for performing complex operations, enhancing your data management capabilities.
170
-
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sfq"
3
- version = "0.0.41"
3
+ version = "0.0.43"
4
4
  description = "Python wrapper for the Salesforce's Query API."
5
5
  readme = "README.md"
6
6
  authors = [{ name = "David Moruzzi", email = "sfq.pypi@dmoruzi.com" }]
@@ -3,13 +3,16 @@
3
3
  """
4
4
 
5
5
  import webbrowser
6
- from typing import Any, Dict, Iterable, List, Literal, Optional
6
+ from typing import Any, Dict, Generator, Iterable, List, Literal, Optional
7
7
  from urllib.parse import quote
8
8
 
9
9
  # Import new modular components
10
10
  from .auth import AuthManager
11
11
  from .crud import CRUDClient
12
12
 
13
+ # Import platform events support
14
+ from .platform_events import PlatformEventsClient
15
+
13
16
  # Re-export all public classes and functions for backward compatibility
14
17
  from .exceptions import (
15
18
  APIError,
@@ -28,6 +31,9 @@ from .soap import SOAPClient
28
31
  from .utils import get_logger, records_to_html_table
29
32
  from .debug_cleanup import DebugCleanup
30
33
 
34
+ # Re-export PlatformEventsClient for direct import
35
+ from .platform_events import PlatformEventsClient
36
+
31
37
  # Define public API for documentation tools
32
38
  __all__ = [
33
39
  "SFAuth",
@@ -43,9 +49,10 @@ __all__ = [
43
49
  "ConfigurationError",
44
50
  # Package metadata
45
51
  "__version__",
52
+ "PlatformEventsClient",
46
53
  ]
47
54
 
48
- __version__ = "0.0.41"
55
+ __version__ = "0.0.43"
49
56
  """
50
57
  ### `__version__`
51
58
 
@@ -63,7 +70,7 @@ class _SFTokenAuth:
63
70
  access_token: str,
64
71
  api_version: str = "v64.0",
65
72
  token_endpoint: str = "/services/oauth2/token",
66
- user_agent: str = "sfq/0.0.41",
73
+ user_agent: str = "sfq/0.0.43",
67
74
  sforce_client: str = "_auto",
68
75
  proxy: str = "_auto",
69
76
  ) -> None:
@@ -107,7 +114,7 @@ class SFAuth:
107
114
  access_token: Optional[str] = None,
108
115
  token_expiration_time: Optional[float] = None,
109
116
  token_lifetime: int = 15 * 60,
110
- user_agent: str = "sfq/0.0.41",
117
+ user_agent: str = "sfq/0.0.43",
111
118
  sforce_client: str = "_auto",
112
119
  proxy: str = "_auto",
113
120
  ) -> None:
@@ -171,8 +178,14 @@ class SFAuth:
171
178
  # Initialize the DebugCleanup
172
179
  self._debug_cleanup = DebugCleanup(sf_auth=self)
173
180
 
181
+ # Initialize the PlatformEventsClient
182
+ self._platform_events_client = PlatformEventsClient(
183
+ http_client=self._http_client,
184
+ api_version=api_version,
185
+ )
186
+
174
187
  # Store version information
175
- self.__version__ = "0.0.41"
188
+ self.__version__ = "0.0.43"
176
189
  """
177
190
  ### `__version__`
178
191
 
@@ -353,6 +366,15 @@ class SFAuth:
353
366
  """
354
367
  return self._auth_manager.org_id
355
368
 
369
+ @property
370
+ def platform_events(self):
371
+ """
372
+ Access to the PlatformEventsClient for advanced usage.
373
+
374
+ :return: The PlatformEventsClient instance.
375
+ """
376
+ return self._platform_events_client
377
+
356
378
  @property
357
379
  def user_id(self) -> Optional[str]:
358
380
  """
@@ -618,3 +640,63 @@ class SFAuth:
618
640
  if "records" in items:
619
641
  items = items["records"]
620
642
  return records_to_html_table(items, headers=headers, styled=styled)
643
+
644
+ def list_events(self) -> Optional[List[str]]:
645
+ """
646
+ List available Platform Events in the Salesforce org.
647
+
648
+ :return: List of event API names (e.g., ['sfq__e']) or None on failure.
649
+ """
650
+ return self._platform_events_client.list_events()
651
+
652
+ def publish(
653
+ self,
654
+ event_name: str,
655
+ event_data: Dict[str, Any],
656
+ ) -> Optional[Dict[str, Any]]:
657
+ """
658
+ Publish a single Platform Event.
659
+
660
+ :param event_name: The API name of the Platform Event (e.g., 'sfq__e').
661
+ :param event_data: Dict of field values for the event (e.g., {'text__c': 'value'}).
662
+ :return: Response dict with 'success', 'id', etc., or None on failure.
663
+ """
664
+ self._refresh_token_if_needed()
665
+ return self._platform_events_client.publish(event_name, event_data)
666
+
667
+ def publish_batch(
668
+ self,
669
+ events: List[Dict[str, Any]],
670
+ event_name: str,
671
+ ) -> Optional[Dict[str, Any]]:
672
+ """
673
+ Publish a batch of Platform Events.
674
+
675
+ :param events: List of event data dicts (each with field values).
676
+ :param event_name: The API name of the Platform Event (e.g., 'sfq__e').
677
+ :return: Dict with 'results': list of individual results, or None on failure.
678
+ """
679
+ self._refresh_token_if_needed()
680
+ return self._platform_events_client.publish_batch(events, event_name)
681
+
682
+ def _subscribe(
683
+ self,
684
+ event_name: str,
685
+ queue_timeout: int = 90,
686
+ max_runtime: Optional[int] = None,
687
+ ) -> Generator[Dict[str, Any], None, None]:
688
+ """
689
+ Subscribe to a Platform Event topic and yield incoming events.
690
+
691
+ Uses the CometD long-polling Streaming API. Topic format:
692
+ '/event/{EventName}'.
693
+
694
+ :param event_name: The Platform Event API name (e.g., 'MyEvent__e')
695
+ :param queue_timeout: Seconds to wait for messages before heartbeat log
696
+ :param max_runtime: Max seconds to listen (None for unlimited)
697
+ :yields: Event dicts with channel and data
698
+ """
699
+ self._refresh_token_if_needed()
700
+ yield from self._platform_events_client.subscribe(
701
+ event_name, queue_timeout=queue_timeout, max_runtime=max_runtime
702
+ )
@@ -29,7 +29,7 @@ class HTTPClient:
29
29
  def __init__(
30
30
  self,
31
31
  auth_manager: AuthManager,
32
- user_agent: str = "sfq/0.0.41",
32
+ user_agent: str = "sfq/0.0.43",
33
33
  sforce_client: str = "_auto",
34
34
  high_api_usage_threshold: int = 80,
35
35
  ) -> None:
@@ -0,0 +1,380 @@
1
+ """
2
+ Platform Events client module for the SFQ library.
3
+
4
+ This module provides operations for Salesforce Platform Events including
5
+ listing available events, publishing events, and subscribing to events
6
+ using the Streaming API.
7
+ """
8
+
9
+ import json
10
+ import http.client
11
+ import time
12
+ import warnings
13
+ from queue import Empty, Queue
14
+ from urllib.parse import urlparse
15
+
16
+ from typing import Any, Dict, Generator, List, Optional
17
+
18
+ from .http_client import HTTPClient
19
+ from .query import QueryClient
20
+ from .utils import get_logger
21
+
22
+ logger = get_logger(__name__)
23
+
24
+
25
+ class PlatformEventsClient:
26
+ """
27
+ Manages Platform Events operations for Salesforce API communication.
28
+
29
+ This class encapsulates listing available Platform Events, publishing
30
+ event records, and subscribing to real-time events via CometD streaming.
31
+ Platform Events must end with the `__e` suffix for custom events.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ http_client: HTTPClient,
37
+ api_version: str,
38
+ ) -> None:
39
+ """
40
+ Initialize the PlatformEventsClient with HTTP client and API version.
41
+
42
+ :param http_client: HTTPClient instance for making requests
43
+ :param api_version: Salesforce API version to use
44
+ """
45
+ self.http_client = http_client
46
+ self.api_version = api_version
47
+ self.query_client = QueryClient(self.http_client, api_version=self.api_version)
48
+
49
+ def list_events(self) -> Optional[List[str]]:
50
+ """
51
+ List all available Platform Events in the Salesforce org.
52
+
53
+ Uses the REST API to get all sObjects and filter those ending with '__e'.
54
+
55
+ :return: List of event names or None on failure
56
+ """
57
+ endpoint = f"/services/data/{self.api_version}/sobjects/"
58
+
59
+ status_code, data = self.http_client.send_authenticated_request(
60
+ method="GET",
61
+ endpoint=endpoint,
62
+ )
63
+
64
+ if status_code != 200 or not data:
65
+ logger.error("Failed to query for sObjects: HTTP %s - %s", status_code, data)
66
+ return None
67
+
68
+ try:
69
+ response = json.loads(data)
70
+ sobjects = response.get("sobjects", [])
71
+ events = [sobj["name"] for sobj in sobjects if sobj["name"].endswith("__e")]
72
+
73
+ logger.debug("Retrieved %d Platform Events: %r", len(events), events)
74
+ return events
75
+ except json.JSONDecodeError as e:
76
+ logger.error("Failed to parse sObjects response: %s", e)
77
+ return None
78
+
79
+ def publish(
80
+ self,
81
+ event_name: str,
82
+ event_data: Dict[str, Any],
83
+ batch_size: int = 200,
84
+ ) -> Optional[Dict[str, Any]]:
85
+ """
86
+ Publish a single or batch of Platform Events via REST API.
87
+
88
+ Posts event data to /sobjects/{EventName}__e. Supports batching
89
+ for multiple events by passing a list in event_data under 'records'.
90
+
91
+ :param event_name: The Platform Event API name (e.g., 'MyEvent__e')
92
+ :param event_data: Dict of field-value pairs or {'records': [list of dicts]}
93
+ :param batch_size: Batch size for composite API (default 200)
94
+ :return: Response dict or None on failure
95
+ """
96
+ if not event_name.endswith("__e"):
97
+ logger.error("Event name must end with '__e': %s", event_name)
98
+ return None
99
+
100
+ sobject = event_name
101
+ records = event_data.get("records", [event_data]) if isinstance(event_data, dict) else [event_data]
102
+
103
+ endpoint = f"/services/data/{self.api_version}/sobjects/{sobject}"
104
+
105
+ # For single event, direct POST
106
+ if len(records) == 1:
107
+ status_code, data = self.http_client.send_authenticated_request(
108
+ method="POST",
109
+ endpoint=endpoint,
110
+ body=json.dumps(records[0]),
111
+ )
112
+ if status_code == 201 and data:
113
+ result = json.loads(data)
114
+ logger.debug("Published event '%s': %r", event_name, result)
115
+ return result
116
+ else:
117
+ logger.error("Publish failed for '%s': HTTP %s - %s", event_name, status_code, data)
118
+ return None
119
+
120
+ # For batch, use composite/tree or loop with threading (simple loop for now)
121
+ results = []
122
+ for record in records:
123
+ status_code, data = self.http_client.send_authenticated_request(
124
+ method="POST",
125
+ endpoint=endpoint,
126
+ body=json.dumps(record),
127
+ )
128
+ if status_code == 201 and data:
129
+ results.append(json.loads(data))
130
+ else:
131
+ logger.warning("Failed to publish record in batch: HTTP %s - %s", status_code, data)
132
+ results.append({"error": data or f"HTTP {status_code}"})
133
+
134
+ logger.debug("Published batch of %d events for '%s'", len(results), event_name)
135
+ return {"results": results}
136
+
137
+ def publish_batch(
138
+ self,
139
+ events: List[Dict[str, Any]],
140
+ event_name: str,
141
+ batch_size: int = 200,
142
+ max_workers: Optional[int] = None,
143
+ ) -> Optional[Dict[str, Any]]:
144
+ """
145
+ Publish multiple Platform Events in batches with optional threading.
146
+
147
+ :param events: List of event data dicts
148
+ :param event_name: The Platform Event API name
149
+ :param batch_size: Records per batch
150
+ :param max_workers: Max threads for concurrent publishes
151
+ :return: Dict of results or None on failure
152
+ """
153
+ if not event_name.endswith("__e"):
154
+ logger.error("Event name must end with '__e': %s", event_name)
155
+ return None
156
+
157
+ # Reuse publish logic but force batch mode
158
+ batch_data = {"records": events}
159
+ return self.publish(event_name, batch_data, batch_size)
160
+
161
+ def subscribe(
162
+ self,
163
+ event_name: str,
164
+ queue_timeout: int = 90,
165
+ max_runtime: Optional[int] = 10,
166
+ ) -> Generator[Dict[str, Any], None, None]:
167
+ """
168
+ Subscribe to a Platform Event topic and yield incoming events.
169
+
170
+ Uses the CometD long-polling Streaming API. Topic format:
171
+ '/event/{EventName}'.
172
+
173
+ :param event_name: The Platform Event API name (e.g., 'MyEvent__e')
174
+ :param queue_timeout: Seconds to wait for messages before heartbeat log
175
+ :param max_runtime: Max seconds to listen (None for unlimited)
176
+ :yields: Event dicts with channel and data
177
+ """
178
+ if not event_name.endswith("__e"):
179
+ logger.error("Event name must end with '__e': %s", event_name)
180
+ return
181
+
182
+ topic = f"/event/{event_name}"
183
+
184
+ # Refresh token
185
+ self.http_client.refresh_token_and_update_auth()
186
+
187
+ if not self.http_client.auth_manager.access_token:
188
+ logger.error("No access token available for event stream.")
189
+ return
190
+
191
+ start_time = time.time()
192
+ message_queue = Queue()
193
+ msg_count = 0
194
+
195
+ instance_url = self.http_client.auth_manager.instance_url
196
+ api_version = self.api_version
197
+ user_agent = self.http_client.user_agent
198
+ sforce_client = self.http_client.sforce_client
199
+
200
+ headers = {
201
+ "Authorization": f"Bearer {self.http_client.auth_manager.access_token}",
202
+ "Content-Type": "application/json",
203
+ "Accept": "application/json",
204
+ "User-Agent": user_agent,
205
+ "Sforce-Call-Options": f"client={sforce_client}",
206
+ }
207
+
208
+ parsed_url = urlparse(instance_url)
209
+ conn = http.client.HTTPSConnection(parsed_url.netloc)
210
+ _API_VERSION = api_version.removeprefix("v")
211
+ client_id = ""
212
+
213
+ try:
214
+ logger.debug("Starting handshake with Salesforce CometD server.")
215
+ handshake_payload = json.dumps(
216
+ {
217
+ "id": str(msg_count + 1),
218
+ "version": "1.0",
219
+ "minimumVersion": "1.0",
220
+ "channel": "/meta/handshake",
221
+ "supportedConnectionTypes": ["long-polling"],
222
+ "advice": {"timeout": 60000, "interval": 0},
223
+ }
224
+ )
225
+ conn.request(
226
+ "POST",
227
+ f"/cometd/{_API_VERSION}/meta/handshake",
228
+ body=handshake_payload,
229
+ headers=headers,
230
+ )
231
+ response = conn.getresponse()
232
+ if response.status != 200:
233
+ logger.error("Handshake failed: HTTP %d", response.status)
234
+ return
235
+ hand_data = json.loads(response.read().decode("utf-8"))
236
+ if not hand_data or not hand_data[0].get("successful"):
237
+ logger.error("Handshake failed: %s", hand_data)
238
+ return
239
+
240
+ client_id = hand_data[0]["clientId"]
241
+ # Extract cookie from handshake response
242
+ for name, value in response.getheaders():
243
+ if name.lower() == "set-cookie" and "BAYEUX_BROWSER=" in value:
244
+ _bayeux_browser_cookie = value.split("BAYEUX_BROWSER=")[1].split(";")[0]
245
+ headers["Cookie"] = f"BAYEUX_BROWSER={_bayeux_browser_cookie}"
246
+ break
247
+
248
+ logger.debug(f"Handshake successful, client ID: {client_id}")
249
+
250
+ logger.debug(f"Subscribing to topic: {topic}")
251
+ subscribe_message = {
252
+ "channel": "/meta/subscribe",
253
+ "clientId": client_id,
254
+ "subscription": topic,
255
+ "id": str(msg_count + 1),
256
+ }
257
+ conn.request(
258
+ "POST",
259
+ f"/cometd/{_API_VERSION}/subscribe",
260
+ body=json.dumps(subscribe_message),
261
+ headers=headers,
262
+ )
263
+ response = conn.getresponse()
264
+ if response.status != 200:
265
+ logger.error("Subscription failed: HTTP %d", response.status)
266
+ return
267
+ sub_response = json.loads(response.read().decode("utf-8"))
268
+ if not sub_response or not sub_response[0].get("successful"):
269
+ logger.error("Subscription failed: %s", sub_response)
270
+ return
271
+
272
+ # Check for cookie in subscribe response headers
273
+ for name, value in response.getheaders():
274
+ if name.lower() == "set-cookie" and "BAYEUX_BROWSER=" in value:
275
+ bayeux = value.split("BAYEUX_BROWSER=")[1].split(";")[0]
276
+ headers["Cookie"] = f"BAYEUX_BROWSER={bayeux}"
277
+ break
278
+
279
+ logger.info(f"Successfully subscribed to topic: {topic}")
280
+
281
+ while True:
282
+ if max_runtime and (time.time() - start_time > max_runtime):
283
+ logger.info(f"Disconnecting after max_runtime={max_runtime} seconds")
284
+ break
285
+
286
+ logger.debug("Sending connection message.")
287
+ connect_payload = json.dumps(
288
+ [
289
+ {
290
+ "channel": "/meta/connect",
291
+ "clientId": client_id,
292
+ "connectionType": "long-polling",
293
+ "id": str(msg_count + 1),
294
+ }
295
+ ]
296
+ )
297
+
298
+ max_retries = 5
299
+ attempt = 0
300
+ while attempt < max_retries:
301
+ try:
302
+ conn.request(
303
+ "POST",
304
+ f"/cometd/{_API_VERSION}/meta/connect",
305
+ body=connect_payload,
306
+ headers=headers,
307
+ )
308
+ response = conn.getresponse()
309
+ if response.status != 200:
310
+ logger.warning(f"Connect failed: HTTP {response.status}")
311
+ attempt += 1
312
+ continue
313
+ msg_count += 1
314
+
315
+ events = json.loads(response.read().decode("utf-8"))
316
+ for event in events:
317
+ if event.get("channel") == topic and "data" in event:
318
+ logger.debug(f"Event received for topic {topic}")
319
+ message_queue.put(event)
320
+ break
321
+ except Exception as e:
322
+ logger.warning(f"Connection error (attempt {attempt + 1}): {e}")
323
+ if conn:
324
+ conn.close()
325
+ conn = http.client.HTTPSConnection(parsed_url.netloc)
326
+ wait_time = min(2 ** attempt, 60)
327
+ time.sleep(wait_time)
328
+ attempt += 1
329
+ else:
330
+ logger.error("Max retries reached. Exiting event stream.")
331
+ break
332
+
333
+ while True:
334
+ try:
335
+ msg = message_queue.get(timeout=queue_timeout)
336
+ yield msg
337
+ except Empty:
338
+ logger.debug(f"Heartbeat: no message in last {queue_timeout} seconds")
339
+ break
340
+ except Exception as err:
341
+ logger.exception("Subscription error for topic '%s': %s", topic, err)
342
+ finally:
343
+ if client_id:
344
+ try:
345
+ logger.debug(f"Disconnecting from server with client ID: {client_id}")
346
+ disconnect_payload = json.dumps(
347
+ [
348
+ {
349
+ "channel": "/meta/disconnect",
350
+ "clientId": client_id,
351
+ "id": str(msg_count + 1),
352
+ }
353
+ ]
354
+ )
355
+ conn.request(
356
+ "POST",
357
+ f"/cometd/{_API_VERSION}/meta/disconnect",
358
+ body=disconnect_payload,
359
+ headers=headers,
360
+ )
361
+ response = conn.getresponse()
362
+ _ = response.read().decode("utf-8")
363
+ except Exception as e:
364
+ logger.warning(f"Exception during disconnect: {e}")
365
+ if conn:
366
+ conn.close()
367
+
368
+
369
+ def get_platform_events_client(
370
+ http_client: HTTPClient,
371
+ api_version: str,
372
+ ) -> PlatformEventsClient:
373
+ """
374
+ Factory function to create a PlatformEventsClient instance.
375
+
376
+ :param http_client: HTTPClient instance
377
+ :param api_version: API version
378
+ :return: New PlatformEventsClient
379
+ """
380
+ return PlatformEventsClient(http_client, api_version)