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.
- {sfq-0.0.41 → sfq-0.0.43}/PKG-INFO +32 -2
- {sfq-0.0.41 → sfq-0.0.43}/README.md +31 -1
- {sfq-0.0.41 → sfq-0.0.43}/pyproject.toml +1 -1
- {sfq-0.0.41 → sfq-0.0.43}/src/sfq/__init__.py +87 -5
- {sfq-0.0.41 → sfq-0.0.43}/src/sfq/http_client.py +1 -1
- sfq-0.0.43/src/sfq/platform_events.py +380 -0
- {sfq-0.0.41 → sfq-0.0.43}/src/sfq/utils.py +68 -0
- sfq-0.0.43/tests/test_SFTokenAuth_e2e.py +116 -0
- sfq-0.0.43/tests/test_fuzzing.py +659 -0
- sfq-0.0.43/tests/test_platform_events_e2e.py +150 -0
- sfq-0.0.43/tests/test_publish_pe_baseline.py +107 -0
- {sfq-0.0.41 → sfq-0.0.43}/uv.lock +1 -1
- sfq-0.0.41/tests/test_SFTokenAuth_e2e.py +0 -58
- {sfq-0.0.41 → sfq-0.0.43}/.github/workflows/publish.yml +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/.gitignore +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/.python-version +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/src/sfq/_cometd.py +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/src/sfq/auth.py +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/src/sfq/crud.py +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/src/sfq/debug_cleanup.py +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/src/sfq/exceptions.py +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/src/sfq/py.typed +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/src/sfq/query.py +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/src/sfq/soap.py +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/src/sfq/timeout_detector.py +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_complex_nested.html +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_complex_nested_styled.html +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_empty_list.html +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_empty_list_styled.html +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_int_float_bool.html +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_int_float_bool_styled.html +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_list_value.html +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_list_value_styled.html +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_multiple_dicts.html +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_multiple_dicts_styled.html +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_nested_dict.html +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_nested_dict_styled.html +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_none_value.html +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_none_value_styled.html +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_other_types.html +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_other_types_styled.html +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_sample_report.html +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_sample_report_styled.html +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_single_flat_dict.html +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_single_flat_dict_styled.html +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_typecastable_keys_bool.html +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_typecastable_keys_bool_styled.html +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_typecastable_keys_float.html +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_typecastable_keys_float_styled.html +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_typecastable_keys_int.html +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/html/test_typecastable_keys_int_styled.html +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/test_auth.py +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/test_cdelete.py +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/test_compatibility.py +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/test_cquery.py +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/test_create.py +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/test_crud.py +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/test_crud_e2e.py +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/test_cupdate.py +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/test_debug_cleanup_e2e.py +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/test_debug_cleanup_unit.py +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/test_http_client.py +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/test_http_client_retry.py +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/test_limits_api.py +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/test_log_trace_redact.py +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/test_open_frontdoor.py +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/test_query.py +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/test_query_client.py +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/test_query_client_timeout_integration.py +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/test_query_e2e.py +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/test_query_integration.py +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/test_records_to_html.py +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/test_soap.py +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/test_soap_batch_operation.py +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/test_static_resources.py +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/test_timeout_detector.py +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/test_timeout_edge_cases.py +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/test_timeout_scenarios_comprehensive.py +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/test_timeout_scenarios_summary.py +0 -0
- {sfq-0.0.41 → sfq-0.0.43}/tests/test_utils.py +0 -0
- {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.
|
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
|
-
|
@@ -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.
|
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.
|
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.
|
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.
|
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
|
+
)
|
@@ -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)
|