sfq 0.0.9__py3-none-any.whl → 0.0.11__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.
sfq/__init__.py CHANGED
@@ -1,14 +1,19 @@
1
+ import base64
1
2
  import http.client
2
3
  import json
3
4
  import logging
4
5
  import os
5
6
  import time
7
+ import warnings
8
+ from queue import Empty, Queue
6
9
  from typing import Any, Dict, Optional
7
10
  from urllib.parse import quote, urlparse
8
11
 
9
12
  TRACE = 5
10
13
  logging.addLevelName(TRACE, "TRACE")
11
14
 
15
+ class ExperimentalWarning(Warning):
16
+ pass
12
17
 
13
18
  def trace(self: logging.Logger, message: str, *args: Any, **kwargs: Any) -> None:
14
19
  """Custom TRACE level logging function with redaction."""
@@ -16,25 +21,33 @@ def trace(self: logging.Logger, message: str, *args: Any, **kwargs: Any) -> None
16
21
  def _redact_sensitive(data: Any) -> Any:
17
22
  """Redacts sensitive keys from a dictionary or query string."""
18
23
  REDACT_VALUE = "*" * 8
24
+ REDACT_KEYS = [
25
+ "access_token",
26
+ "authorization",
27
+ "set-cookie",
28
+ "cookie",
29
+ "refresh_token",
30
+ ]
19
31
  if isinstance(data, dict):
20
32
  return {
21
- k: (
22
- REDACT_VALUE
23
- if k.lower() in ["access_token", "authorization", "refresh_token"]
24
- else v
25
- )
33
+ k: (REDACT_VALUE if k.lower() in REDACT_KEYS else v)
26
34
  for k, v in data.items()
27
35
  }
36
+ elif isinstance(data, (list, tuple)):
37
+ return type(data)(
38
+ (
39
+ (item[0], REDACT_VALUE)
40
+ if isinstance(item, tuple) and item[0].lower() in REDACT_KEYS
41
+ else item
42
+ for item in data
43
+ )
44
+ )
28
45
  elif isinstance(data, str):
29
46
  parts = data.split("&")
30
47
  for i, part in enumerate(parts):
31
48
  if "=" in part:
32
49
  key, value = part.split("=", 1)
33
- if key.lower() in [
34
- "access_token",
35
- "authorization",
36
- "refresh_token",
37
- ]:
50
+ if key.lower() in REDACT_KEYS:
38
51
  parts[i] = f"{key}={REDACT_VALUE}"
39
52
  return "&".join(parts)
40
53
  return data
@@ -70,7 +83,7 @@ class SFAuth:
70
83
  access_token: Optional[str] = None,
71
84
  token_expiration_time: Optional[float] = None,
72
85
  token_lifetime: int = 15 * 60,
73
- user_agent: str = "sfq/0.0.9",
86
+ user_agent: str = "sfq/0.0.11",
74
87
  proxy: str = "auto",
75
88
  ) -> None:
76
89
  """
@@ -84,7 +97,7 @@ class SFAuth:
84
97
  :param access_token: The access token for the current session (default is None).
85
98
  :param token_expiration_time: The expiration time of the access token (default is None).
86
99
  :param token_lifetime: The lifetime of the access token in seconds (default is 15 minutes).
87
- :param user_agent: Custom User-Agent string (default is "sfq/0.0.9").
100
+ :param user_agent: Custom User-Agent string (default is "sfq/0.0.11").
88
101
  :param proxy: The proxy configuration, "auto" to use environment (default is "auto").
89
102
  """
90
103
  self.instance_url = instance_url
@@ -139,7 +152,7 @@ class SFAuth:
139
152
  logger.trace("Direct connection to %s", netloc)
140
153
  return conn
141
154
 
142
- def _post_token_request(self, payload: Dict[str, str]) -> Optional[Dict[str, Any]]:
155
+ def _new_token_request(self, payload: Dict[str, str]) -> Optional[Dict[str, Any]]:
143
156
  """
144
157
  Send a POST request to the Salesforce token endpoint using http.client.
145
158
 
@@ -178,6 +191,7 @@ class SFAuth:
178
191
  logger.exception("Error during token request: %s", err)
179
192
 
180
193
  finally:
194
+ logger.trace("Closing connection.")
181
195
  conn.close()
182
196
 
183
197
  return None
@@ -229,7 +243,7 @@ class SFAuth:
229
243
 
230
244
  logger.trace("Access token expired or missing, refreshing...")
231
245
  payload = self._prepare_payload()
232
- token_data = self._post_token_request(payload)
246
+ token_data = self._new_token_request(payload)
233
247
 
234
248
  if token_data:
235
249
  self.access_token = token_data.get("access_token")
@@ -239,7 +253,10 @@ class SFAuth:
239
253
  self.org_id = token_data.get("id").split("/")[4]
240
254
  self.user_id = token_data.get("id").split("/")[5]
241
255
  logger.trace(
242
- "Authenticated as user %s in org %s", self.user_id, self.org_id
256
+ "Authenticated as user %s for org %s (%s)",
257
+ self.user_id,
258
+ self.org_id,
259
+ token_data.get("instance_url"),
243
260
  )
244
261
  except (IndexError, KeyError):
245
262
  logger.error("Failed to extract org/user IDs from token response.")
@@ -264,16 +281,194 @@ class SFAuth:
264
281
  logger.warning("Token expiration check failed. Treating token as expired.")
265
282
  return True
266
283
 
267
- def tooling_query(self, query: str) -> Optional[Dict[str, Any]]:
284
+ def read_static_resource_name(
285
+ self, resource_name: str, namespace: Optional[str] = None
286
+ ) -> Optional[str]:
268
287
  """
269
- Execute a SOQL query using the Tooling API.
288
+ Read a static resource for a given name from the Salesforce instance.
270
289
 
271
- :param query: The SOQL query string.
290
+ :param resource_name: Name of the static resource to read.
291
+ :param namespace: Namespace of the static resource to read (default is None).
292
+ :return: Static resource content or None on failure.
293
+ """
294
+ _safe_resource_name = quote(resource_name, safe="")
295
+ query = f"SELECT Id FROM StaticResource WHERE Name = '{_safe_resource_name}'"
296
+ if namespace:
297
+ query += f" AND NamespacePrefix = '{namespace}'"
298
+ query += " LIMIT 1"
299
+ _static_resource_id_response = self.query(query)
300
+
301
+ if (
302
+ _static_resource_id_response
303
+ and _static_resource_id_response.get("records")
304
+ and len(_static_resource_id_response["records"]) > 0
305
+ ):
306
+ return self.read_static_resource_id(
307
+ _static_resource_id_response["records"][0].get("Id")
308
+ )
309
+
310
+ logger.error(f"Failed to read static resource with name {_safe_resource_name}.")
311
+ return None
312
+
313
+ def read_static_resource_id(self, resource_id: str) -> Optional[str]:
314
+ """
315
+ Read a static resource for a given ID from the Salesforce instance.
316
+
317
+ :param resource_id: ID of the static resource to read.
318
+ :return: Static resource content or None on failure.
319
+ """
320
+ self._refresh_token_if_needed()
321
+
322
+ if not self.access_token:
323
+ logger.error("No access token available for limits.")
324
+ return None
325
+
326
+ endpoint = f"/services/data/{self.api_version}/sobjects/StaticResource/{resource_id}/Body"
327
+ headers = {
328
+ "Authorization": f"Bearer {self.access_token}",
329
+ "User-Agent": self.user_agent,
330
+ "Accept": "application/json",
331
+ }
332
+
333
+ parsed_url = urlparse(self.instance_url)
334
+ conn = self._create_connection(parsed_url.netloc)
335
+
336
+ try:
337
+ logger.trace("Request endpoint: %s", endpoint)
338
+ logger.trace("Request headers: %s", headers)
339
+ conn.request("GET", endpoint, headers=headers)
340
+ response = conn.getresponse()
341
+ data = response.read().decode("utf-8")
342
+ self._http_resp_header_logic(response)
343
+
344
+ if response.status == 200:
345
+ logger.debug("Get Static Resource Body API request successful.")
346
+ logger.trace("Response body: %s", data)
347
+ return data
348
+
349
+ logger.error(
350
+ "Get Static Resource Body API request failed: %s %s",
351
+ response.status,
352
+ response.reason,
353
+ )
354
+ logger.debug("Response body: %s", data)
355
+
356
+ except Exception as err:
357
+ logger.exception(
358
+ "Error during Get Static Resource Body API request: %s", err
359
+ )
360
+
361
+ finally:
362
+ logger.trace("Closing connection...")
363
+ conn.close()
364
+
365
+ return None
366
+
367
+ def update_static_resource_name(
368
+ self, resource_name: str, data: str, namespace: Optional[str] = None
369
+ ) -> Optional[Dict[str, Any]]:
370
+ """
371
+ Update a static resource for a given name in the Salesforce instance.
372
+
373
+ :param resource_name: Name of the static resource to update.
374
+ :param data: Content to update the static resource with.
375
+ :param namespace: Optional namespace to search for the static resource.
376
+ :return: Static resource content or None on failure.
377
+ """
378
+ safe_resource_name = quote(resource_name, safe="")
379
+ query = f"SELECT Id FROM StaticResource WHERE Name = '{safe_resource_name}'"
380
+ if namespace:
381
+ query += f" AND NamespacePrefix = '{namespace}'"
382
+ query += " LIMIT 1"
383
+
384
+ static_resource_id_response = self.query(query)
385
+
386
+ if (
387
+ static_resource_id_response
388
+ and static_resource_id_response.get("records")
389
+ and len(static_resource_id_response["records"]) > 0
390
+ ):
391
+ return self.update_static_resource_id(
392
+ static_resource_id_response["records"][0].get("Id"), data
393
+ )
394
+
395
+ logger.error(
396
+ f"Failed to update static resource with name {safe_resource_name}."
397
+ )
398
+ return None
399
+
400
+ def update_static_resource_id(
401
+ self, resource_id: str, data: str
402
+ ) -> Optional[Dict[str, Any]]:
403
+ """
404
+ Replace the content of a static resource in the Salesforce instance by ID.
405
+
406
+ :param resource_id: ID of the static resource to update.
407
+ :param data: Content to update the static resource with.
272
408
  :return: Parsed JSON response or None on failure.
273
409
  """
274
- return self.query(query, tooling=True)
410
+ self._refresh_token_if_needed()
411
+
412
+ if not self.access_token:
413
+ logger.error("No access token available for limits.")
414
+ return None
415
+
416
+ payload = {"Body": base64.b64encode(data.encode("utf-8"))}
417
+
418
+ endpoint = (
419
+ f"/services/data/{self.api_version}/sobjects/StaticResource/{resource_id}"
420
+ )
421
+ headers = {
422
+ "Authorization": f"Bearer {self.access_token}",
423
+ "User-Agent": self.user_agent,
424
+ "Content-Type": "application/json",
425
+ "Accept": "application/json",
426
+ }
427
+
428
+ parsed_url = urlparse(self.instance_url)
429
+ conn = self._create_connection(parsed_url.netloc)
430
+
431
+ try:
432
+ logger.trace("Request endpoint: %s", endpoint)
433
+ logger.trace("Request headers: %s", headers)
434
+ logger.trace("Request payload: %s", payload)
435
+ conn.request(
436
+ "PATCH",
437
+ endpoint,
438
+ headers=headers,
439
+ body=json.dumps(payload, default=lambda x: x.decode("utf-8")),
440
+ )
441
+ response = conn.getresponse()
442
+ data = response.read().decode("utf-8")
443
+ self._http_resp_header_logic(response)
444
+
445
+ if response.status == 200:
446
+ logger.debug("Patch Static Resource request successful.")
447
+ logger.trace("Response body: %s", data)
448
+ return json.loads(data)
449
+
450
+ logger.error(
451
+ "Patch Static Resource API request failed: %s %s",
452
+ response.status,
453
+ response.reason,
454
+ )
455
+ logger.debug("Response body: %s", data)
456
+
457
+ except Exception as err:
458
+ logger.exception("Error during patch request: %s", err)
459
+
460
+ finally:
461
+ logger.trace("Closing connection.")
462
+ conn.close()
463
+
464
+ return None
275
465
 
276
466
  def limits(self) -> Optional[Dict[str, Any]]:
467
+ """
468
+ Execute a GET request to the Salesforce Limits API.
469
+
470
+ :return: Parsed JSON response or None on failure.
471
+ """
277
472
  self._refresh_token_if_needed()
278
473
 
279
474
  if not self.access_token:
@@ -284,7 +479,6 @@ class SFAuth:
284
479
  headers = {
285
480
  "Authorization": f"Bearer {self.access_token}",
286
481
  "User-Agent": self.user_agent,
287
- "Content-Type": "application/x-www-form-urlencoded",
288
482
  "Accept": "application/json",
289
483
  }
290
484
 
@@ -304,13 +498,16 @@ class SFAuth:
304
498
  logger.trace("Response body: %s", data)
305
499
  return json.loads(data)
306
500
 
307
- logger.error("Limits API request failed: %s %s", response.status, response.reason)
501
+ logger.error(
502
+ "Limits API request failed: %s %s", response.status, response.reason
503
+ )
308
504
  logger.debug("Response body: %s", data)
309
505
 
310
506
  except Exception as err:
311
507
  logger.exception("Error during limits request: %s", err)
312
508
 
313
509
  finally:
510
+ logger.debug("Closing connection...")
314
511
  conn.close()
315
512
 
316
513
  return None
@@ -338,7 +535,6 @@ class SFAuth:
338
535
  headers = {
339
536
  "Authorization": f"Bearer {self.access_token}",
340
537
  "User-Agent": self.user_agent,
341
- "Content-Type": "application/x-www-form-urlencoded",
342
538
  "Accept": "application/json",
343
539
  }
344
540
 
@@ -393,6 +589,243 @@ class SFAuth:
393
589
  logger.exception("Exception during query: %s", err)
394
590
 
395
591
  finally:
592
+ logger.trace("Closing connection...")
396
593
  conn.close()
397
594
 
398
595
  return None
596
+
597
+ def tooling_query(self, query: str) -> Optional[Dict[str, Any]]:
598
+ """
599
+ Execute a SOQL query using the Tooling API.
600
+
601
+ :param query: The SOQL query string.
602
+ :return: Parsed JSON response or None on failure.
603
+ """
604
+ return self.query(query, tooling=True)
605
+
606
+ def _reconnect_with_backoff(self, attempt: int) -> None:
607
+ wait_time = min(2**attempt, 60)
608
+ logger.warning(
609
+ f"Reconnecting after failure, backoff {wait_time}s (attempt {attempt})"
610
+ )
611
+ time.sleep(wait_time)
612
+ self._refresh_token_if_needed()
613
+
614
+ def _subscribe_topic(
615
+ self,
616
+ topic: str,
617
+ queue_timeout: int = 90,
618
+ max_runtime: Optional[int] = None,
619
+ ):
620
+ """
621
+ Yields events from a subscribed Salesforce CometD topic.
622
+
623
+ :param topic: Topic to subscribe to, e.g. '/event/MyEvent__e'
624
+ :param queue_timeout: Seconds to wait for a message before logging heartbeat
625
+ :param max_runtime: Max total time to listen in seconds (None = unlimited)
626
+ """
627
+ warnings.warn(
628
+ "The _subscribe_topic method is experimental and subject to change in future versions.",
629
+ ExperimentalWarning,
630
+ stacklevel=2,
631
+ )
632
+
633
+ self._refresh_token_if_needed()
634
+ self._msg_count: int = 0
635
+
636
+ if not self.access_token:
637
+ logger.error("No access token available for event stream.")
638
+ return
639
+
640
+ start_time = time.time()
641
+ message_queue = Queue()
642
+ headers = {
643
+ "Authorization": f"Bearer {self.access_token}",
644
+ "Content-Type": "application/json",
645
+ "Accept": "application/json",
646
+ "User-Agent": self.user_agent,
647
+ }
648
+
649
+ parsed_url = urlparse(self.instance_url)
650
+ conn = self._create_connection(parsed_url.netloc)
651
+ _API_VERSION = str(self.api_version).removeprefix("v")
652
+ client_id = str()
653
+
654
+ try:
655
+ logger.trace("Starting handshake with Salesforce CometD server.")
656
+ handshake_payload = json.dumps(
657
+ {
658
+ "id": str(self._msg_count + 1),
659
+ "version": "1.0",
660
+ "minimumVersion": "1.0",
661
+ "channel": "/meta/handshake",
662
+ "supportedConnectionTypes": ["long-polling"],
663
+ "advice": {"timeout": 60000, "interval": 0},
664
+ }
665
+ )
666
+ conn.request(
667
+ "POST",
668
+ f"/cometd/{_API_VERSION}/meta/handshake",
669
+ headers=headers,
670
+ body=handshake_payload,
671
+ )
672
+ response = conn.getresponse()
673
+ self._http_resp_header_logic(response)
674
+
675
+ logger.trace("Received handshake response.")
676
+ for name, value in response.getheaders():
677
+ if name.lower() == "set-cookie" and "BAYEUX_BROWSER=" in value:
678
+ _bayeux_browser_cookie = value.split("BAYEUX_BROWSER=")[1].split(
679
+ ";"
680
+ )[0]
681
+ headers["Cookie"] = f"BAYEUX_BROWSER={_bayeux_browser_cookie}"
682
+ break
683
+
684
+ data = json.loads(response.read().decode("utf-8"))
685
+ if not data or not data[0].get("successful"):
686
+ logger.error("Handshake failed: %s", data)
687
+ return
688
+
689
+ client_id = data[0]["clientId"]
690
+ logger.trace(f"Handshake successful, client ID: {client_id}")
691
+
692
+ logger.trace(f"Subscribing to topic: {topic}")
693
+ subscribe_message = {
694
+ "channel": "/meta/subscribe",
695
+ "clientId": client_id,
696
+ "subscription": topic,
697
+ "id": str(self._msg_count + 1),
698
+ }
699
+ conn.request(
700
+ "POST",
701
+ f"/cometd/{_API_VERSION}/meta/subscribe",
702
+ headers=headers,
703
+ body=json.dumps(subscribe_message),
704
+ )
705
+ response = conn.getresponse()
706
+ self._http_resp_header_logic(response)
707
+
708
+ sub_response = json.loads(response.read().decode("utf-8"))
709
+ if not sub_response or not sub_response[0].get("successful"):
710
+ logger.error("Subscription failed: %s", sub_response)
711
+ return
712
+
713
+ logger.info(f"Successfully subscribed to topic: {topic}")
714
+ logger.trace("Entering event polling loop.")
715
+
716
+ try:
717
+ while True:
718
+ if max_runtime and (time.time() - start_time > max_runtime):
719
+ logger.info(
720
+ f"Disconnecting after max_runtime={max_runtime} seconds"
721
+ )
722
+ break
723
+
724
+ logger.trace("Sending connection message.")
725
+ connect_payload = json.dumps(
726
+ [
727
+ {
728
+ "channel": "/meta/connect",
729
+ "clientId": client_id,
730
+ "connectionType": "long-polling",
731
+ "id": str(self._msg_count + 1),
732
+ }
733
+ ]
734
+ )
735
+
736
+ max_retries = 5
737
+ attempt = 0
738
+
739
+ while attempt < max_retries:
740
+ try:
741
+ conn.request(
742
+ "POST",
743
+ f"/cometd/{_API_VERSION}/meta/connect",
744
+ headers=headers,
745
+ body=connect_payload,
746
+ )
747
+ response = conn.getresponse()
748
+ self._http_resp_header_logic(response)
749
+ self._msg_count += 1
750
+
751
+ events = json.loads(response.read().decode("utf-8"))
752
+ for event in events:
753
+ if event.get("channel") == topic and "data" in event:
754
+ logger.trace(
755
+ f"Event received for topic {topic}, data: {event['data']}"
756
+ )
757
+ message_queue.put(event)
758
+ break
759
+ except (
760
+ http.client.RemoteDisconnected,
761
+ ConnectionResetError,
762
+ TimeoutError,
763
+ http.client.BadStatusLine,
764
+ http.client.CannotSendRequest,
765
+ ConnectionAbortedError,
766
+ ConnectionRefusedError,
767
+ ConnectionError,
768
+ ) as e:
769
+ logger.warning(
770
+ f"Connection error (attempt {attempt + 1}): {e}"
771
+ )
772
+ conn.close()
773
+ conn = self._create_connection(parsed_url.netloc)
774
+ self._reconnect_with_backoff(attempt)
775
+ attempt += 1
776
+ except Exception as e:
777
+ logger.exception(
778
+ f"Connection error (attempt {attempt + 1}): {e}"
779
+ )
780
+ break
781
+ else:
782
+ logger.error("Max retries reached. Exiting event stream.")
783
+ break
784
+
785
+ while True:
786
+ try:
787
+ msg = message_queue.get(timeout=queue_timeout, block=True)
788
+ yield msg
789
+ except Empty:
790
+ logger.debug(
791
+ f"Heartbeat: no message in last {queue_timeout} seconds"
792
+ )
793
+ break
794
+ except KeyboardInterrupt:
795
+ logger.info("Received keyboard interrupt, disconnecting...")
796
+
797
+ except Exception as e:
798
+ logger.exception(f"Polling error: {e}")
799
+
800
+ finally:
801
+ if client_id:
802
+ try:
803
+ logger.trace(
804
+ f"Disconnecting from server with client ID: {client_id}"
805
+ )
806
+ disconnect_payload = json.dumps(
807
+ [
808
+ {
809
+ "channel": "/meta/disconnect",
810
+ "clientId": client_id,
811
+ "id": str(self._msg_count + 1),
812
+ }
813
+ ]
814
+ )
815
+ conn.request(
816
+ "POST",
817
+ f"/cometd/{_API_VERSION}/meta/disconnect",
818
+ headers=headers,
819
+ body=disconnect_payload,
820
+ )
821
+ response = conn.getresponse()
822
+ self._http_resp_header_logic(response)
823
+ _ = response.read()
824
+ logger.trace("Disconnected successfully.")
825
+ except Exception as e:
826
+ logger.warning(f"Exception during disconnect: {e}")
827
+ if conn:
828
+ logger.trace("Closing connection.")
829
+ conn.close()
830
+
831
+ logger.trace("Leaving event polling loop.")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sfq
3
- Version: 0.0.9
3
+ Version: 0.0.11
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
@@ -0,0 +1,5 @@
1
+ sfq/__init__.py,sha256=QkMVcIOaQO7XP29zK9l7uQCTx0NcqIDBNj8V08Llu24,31595
2
+ sfq/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ sfq-0.0.11.dist-info/METADATA,sha256=F1sqI833stEXIk9Z7jtG6q0F8dPuEGMDW8xgFgGxa4M,5067
4
+ sfq-0.0.11.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
5
+ sfq-0.0.11.dist-info/RECORD,,
@@ -1,5 +0,0 @@
1
- sfq/__init__.py,sha256=TUhdE13kmM814DmbYgDHtQogRm461k17YKr7YKzBNho,15101
2
- sfq/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
- sfq-0.0.9.dist-info/METADATA,sha256=DCsSMcv_Nzq2JJinOYzab8Q7MU0jjFQ27lGgMZjUnEI,5066
4
- sfq-0.0.9.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
5
- sfq-0.0.9.dist-info/RECORD,,
File without changes