sfq 0.0.14__py3-none-any.whl → 0.0.16__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
@@ -7,18 +7,13 @@ import time
7
7
  import warnings
8
8
  from collections import OrderedDict
9
9
  from concurrent.futures import ThreadPoolExecutor, as_completed
10
- from queue import Empty, Queue
11
- from typing import Any, Dict, Literal, Optional
10
+ from typing import Any, Dict, Iterable, Literal, Optional, List, Tuple
12
11
  from urllib.parse import quote, urlparse
13
12
 
14
13
  TRACE = 5
15
14
  logging.addLevelName(TRACE, "TRACE")
16
15
 
17
16
 
18
- class ExperimentalWarning(Warning):
19
- pass
20
-
21
-
22
17
  def trace(self: logging.Logger, message: str, *args: Any, **kwargs: Any) -> None:
23
18
  """Custom TRACE level logging function with redaction."""
24
19
 
@@ -31,6 +26,7 @@ def trace(self: logging.Logger, message: str, *args: Any, **kwargs: Any) -> None
31
26
  "set-cookie",
32
27
  "cookie",
33
28
  "refresh_token",
29
+ "client_secret",
34
30
  ]
35
31
  if isinstance(data, dict):
36
32
  return {
@@ -81,15 +77,15 @@ class SFAuth:
81
77
  self,
82
78
  instance_url: str,
83
79
  client_id: str,
84
- refresh_token: str, # client_secret & refresh_token will swap positions 2025-AUG-1
80
+ refresh_token: str, # client_secret & refresh_token will swap positions 2025-AUG-1
85
81
  client_secret: str = "_deprecation_warning", # mandatory after 2025-AUG-1
86
82
  api_version: str = "v63.0",
87
83
  token_endpoint: str = "/services/oauth2/token",
88
84
  access_token: Optional[str] = None,
89
85
  token_expiration_time: Optional[float] = None,
90
86
  token_lifetime: int = 15 * 60,
91
- user_agent: str = "sfq/0.0.14",
92
- sforce_client: str = '_auto',
87
+ user_agent: str = "sfq/0.0.16",
88
+ sforce_client: str = "_auto",
93
89
  proxy: str = "auto",
94
90
  ) -> None:
95
91
  """
@@ -104,7 +100,7 @@ class SFAuth:
104
100
  :param access_token: The access token for the current session (default is None).
105
101
  :param token_expiration_time: The expiration time of the access token (default is None).
106
102
  :param token_lifetime: The lifetime of the access token in seconds (default is 15 minutes).
107
- :param user_agent: Custom User-Agent string (default is "sfq/0.0.14").
103
+ :param user_agent: Custom User-Agent string (default is "sfq/0.0.16").
108
104
  :param sforce_client: Custom Application Identifier (default is user_agent).
109
105
  :param proxy: The proxy configuration, "auto" to use environment (default is "auto").
110
106
  """
@@ -118,12 +114,12 @@ class SFAuth:
118
114
  self.token_expiration_time = token_expiration_time
119
115
  self.token_lifetime = token_lifetime
120
116
  self.user_agent = user_agent
121
- self.sforce_client = sforce_client
117
+ self.sforce_client = quote(str(sforce_client), safe="")
122
118
  self._auto_configure_proxy(proxy)
123
119
  self._high_api_usage_threshold = 80
124
120
 
125
- if sforce_client == '_auto':
126
- self.sforce_client = user_agent
121
+ if sforce_client == "_auto":
122
+ self.sforce_client = quote(str(user_agent), safe="")
127
123
 
128
124
  if self.client_secret == "_deprecation_warning":
129
125
  warnings.warn(
@@ -138,7 +134,6 @@ class SFAuth:
138
134
  )
139
135
 
140
136
  def _format_instance_url(self, instance_url) -> str:
141
- # check if it begins with https://
142
137
  if instance_url.startswith("https://"):
143
138
  return instance_url
144
139
  if instance_url.startswith("http://"):
@@ -205,49 +200,72 @@ class SFAuth:
205
200
  logger.trace("Direct connection to %s", netloc)
206
201
  return conn
207
202
 
208
- def _new_token_request(self, payload: Dict[str, str]) -> Optional[Dict[str, Any]]:
203
+ def _send_request(
204
+ self,
205
+ method: str,
206
+ endpoint: str,
207
+ headers: Dict[str, str],
208
+ body: Optional[str] = None,
209
+ ) -> Tuple[Optional[int], Optional[str]]:
209
210
  """
210
- Send a POST request to the Salesforce token endpoint using http.client.
211
+ Unified request method with built-in logging and error handling.
211
212
 
212
- :param payload: Dictionary of form-encoded OAuth parameters.
213
- :return: Parsed JSON response if successful, otherwise None.
213
+ :param method: HTTP method to use.
214
+ :param endpoint: Target API endpoint.
215
+ :param headers: HTTP headers.
216
+ :param body: Optional request body.
217
+ :param timeout: Optional timeout in seconds.
218
+ :return: Tuple of HTTP status code and response body as a string.
214
219
  """
215
220
  parsed_url = urlparse(self.instance_url)
216
221
  conn = self._create_connection(parsed_url.netloc)
217
- headers = {
218
- "Accept": "application/json",
219
- "Content-Type": "application/x-www-form-urlencoded",
220
- "User-Agent": self.user_agent,
221
- "Sforce-Call-Options": f"client={self.sforce_client}",
222
- }
223
- body = "&".join(f"{key}={quote(str(value))}" for key, value in payload.items())
224
222
 
225
223
  try:
226
- logger.trace("Request endpoint: %s", self.token_endpoint)
227
- logger.trace("Request body: %s", body)
224
+ logger.trace("Request method: %s", method)
225
+ logger.trace("Request endpoint: %s", endpoint)
228
226
  logger.trace("Request headers: %s", headers)
229
- conn.request("POST", self.token_endpoint, body, headers)
227
+ if body:
228
+ logger.trace("Request body: %s", body)
229
+
230
+ conn.request(method, endpoint, body=body, headers=headers)
230
231
  response = conn.getresponse()
231
- data = response.read().decode("utf-8")
232
232
  self._http_resp_header_logic(response)
233
233
 
234
- if response.status == 200:
235
- logger.trace("Token refresh successful.")
236
- logger.trace("Response body: %s", data)
237
- return json.loads(data)
238
-
239
- logger.error(
240
- "Token refresh failed: %s %s", response.status, response.reason
241
- )
242
- logger.debug("Response body: %s", data)
234
+ data = response.read().decode("utf-8")
235
+ logger.trace("Response status: %s", response.status)
236
+ logger.trace("Response body: %s", data)
237
+ return response.status, data
243
238
 
244
239
  except Exception as err:
245
- logger.exception("Error during token request: %s", err)
240
+ logger.exception("HTTP request failed: %s", err)
241
+ return None, None
246
242
 
247
243
  finally:
248
- logger.trace("Closing connection.")
244
+ logger.trace("Closing connection...")
249
245
  conn.close()
250
246
 
247
+ def _new_token_request(self, payload: Dict[str, str]) -> Optional[Dict[str, Any]]:
248
+ """
249
+ Perform a new token request using the provided payload.
250
+
251
+ :param payload: Payload for the token request.
252
+ :return: Parsed JSON response or None on failure.
253
+ """
254
+ headers = self._get_common_headers(recursive_call=True)
255
+ headers["Content-Type"] = "application/x-www-form-urlencoded"
256
+ del headers["Authorization"]
257
+
258
+ body = "&".join(f"{key}={quote(str(value))}" for key, value in payload.items())
259
+ status, data = self._send_request("POST", self.token_endpoint, headers, body)
260
+
261
+ if status == 200:
262
+ logger.trace("Token refresh successful.")
263
+ return json.loads(data)
264
+
265
+ if status:
266
+ logger.error("Token refresh failed: %s", status)
267
+ logger.debug("Response body: %s", data)
268
+
251
269
  return None
252
270
 
253
271
  def _http_resp_header_logic(self, response: http.client.HTTPResponse) -> None:
@@ -320,6 +338,23 @@ class SFAuth:
320
338
  logger.error("Failed to obtain access token.")
321
339
  return None
322
340
 
341
+ def _get_common_headers(self, recursive_call: bool = False) -> Dict[str, str]:
342
+ """
343
+ Generate common headers for API requests.
344
+
345
+ :return: A dictionary of common headers.
346
+ """
347
+ if not recursive_call:
348
+ self._refresh_token_if_needed()
349
+
350
+ return {
351
+ "Authorization": f"Bearer {self.access_token}",
352
+ "User-Agent": self.user_agent,
353
+ "Sforce-Call-Options": f"client={self.sforce_client}",
354
+ "Accept": "application/json",
355
+ "Content-Type": "application/json",
356
+ }
357
+
323
358
  def _is_token_expired(self) -> bool:
324
359
  """
325
360
  Check if the access token has expired.
@@ -369,52 +404,15 @@ class SFAuth:
369
404
  :param resource_id: ID of the static resource to read.
370
405
  :return: Static resource content or None on failure.
371
406
  """
372
- self._refresh_token_if_needed()
373
-
374
- if not self.access_token:
375
- logger.error("No access token available for limits.")
376
- return None
377
-
378
407
  endpoint = f"/services/data/{self.api_version}/sobjects/StaticResource/{resource_id}/Body"
379
- headers = {
380
- "Authorization": f"Bearer {self.access_token}",
381
- "User-Agent": self.user_agent,
382
- "Sforce-Call-Options": f"client={self.sforce_client}",
383
- "Accept": "application/json",
384
- }
408
+ headers = self._get_common_headers()
409
+ status, data = self._send_request("GET", endpoint, headers)
385
410
 
386
- parsed_url = urlparse(self.instance_url)
387
- conn = self._create_connection(parsed_url.netloc)
388
-
389
- try:
390
- logger.trace("Request endpoint: %s", endpoint)
391
- logger.trace("Request headers: %s", headers)
392
- conn.request("GET", endpoint, headers=headers)
393
- response = conn.getresponse()
394
- data = response.read().decode("utf-8")
395
- self._http_resp_header_logic(response)
396
-
397
- if response.status == 200:
398
- logger.debug("Get Static Resource Body API request successful.")
399
- logger.trace("Response body: %s", data)
400
- return data
401
-
402
- logger.error(
403
- "Get Static Resource Body API request failed: %s %s",
404
- response.status,
405
- response.reason,
406
- )
407
- logger.debug("Response body: %s", data)
408
-
409
- except Exception as err:
410
- logger.exception(
411
- "Error during Get Static Resource Body API request: %s", err
412
- )
413
-
414
- finally:
415
- logger.trace("Closing connection...")
416
- conn.close()
411
+ if status == 200:
412
+ logger.debug("Static resource fetched successfully.")
413
+ return data
417
414
 
415
+ logger.error("Failed to fetch static resource: %s", status)
418
416
  return None
419
417
 
420
418
  def update_static_resource_name(
@@ -461,111 +459,48 @@ class SFAuth:
461
459
  :param data: Content to update the static resource with.
462
460
  :return: Parsed JSON response or None on failure.
463
461
  """
464
- self._refresh_token_if_needed()
465
-
466
- if not self.access_token:
467
- logger.error("No access token available for limits.")
468
- return None
469
-
470
- payload = {"Body": base64.b64encode(data.encode("utf-8"))}
462
+ payload = {"Body": base64.b64encode(data.encode("utf-8")).decode("utf-8")}
471
463
 
472
464
  endpoint = (
473
465
  f"/services/data/{self.api_version}/sobjects/StaticResource/{resource_id}"
474
466
  )
475
- headers = {
476
- "Authorization": f"Bearer {self.access_token}",
477
- "User-Agent": self.user_agent,
478
- "Sforce-Call-Options": f"client={self.sforce_client}",
479
- "Content-Type": "application/json",
480
- "Accept": "application/json",
481
- }
482
-
483
- parsed_url = urlparse(self.instance_url)
484
- conn = self._create_connection(parsed_url.netloc)
485
-
486
- try:
487
- logger.trace("Request endpoint: %s", endpoint)
488
- logger.trace("Request headers: %s", headers)
489
- logger.trace("Request payload: %s", payload)
490
- conn.request(
491
- "PATCH",
492
- endpoint,
493
- headers=headers,
494
- body=json.dumps(payload, default=lambda x: x.decode("utf-8")),
495
- )
496
- response = conn.getresponse()
497
- data = response.read().decode("utf-8")
498
- self._http_resp_header_logic(response)
499
-
500
- if response.status == 200:
501
- logger.debug("Patch Static Resource request successful.")
502
- logger.trace("Response body: %s", data)
503
- return json.loads(data)
467
+ headers = self._get_common_headers()
504
468
 
505
- logger.error(
506
- "Patch Static Resource API request failed: %s %s",
507
- response.status,
508
- response.reason,
509
- )
510
- logger.debug("Response body: %s", data)
469
+ status_code, response_data = self._send_request(
470
+ method="PATCH",
471
+ endpoint=endpoint,
472
+ headers=headers,
473
+ body=json.dumps(payload),
474
+ )
511
475
 
512
- except Exception as err:
513
- logger.exception("Error during patch request: %s", err)
476
+ if status_code == 200:
477
+ logger.debug("Patch Static Resource request successful.")
478
+ return json.loads(response_data)
514
479
 
515
- finally:
516
- logger.trace("Closing connection.")
517
- conn.close()
480
+ logger.error(
481
+ "Patch Static Resource API request failed: %s",
482
+ status_code,
483
+ )
484
+ logger.debug("Response body: %s", response_data)
518
485
 
519
486
  return None
520
487
 
521
488
  def limits(self) -> Optional[Dict[str, Any]]:
522
489
  """
523
- Execute a GET request to the Salesforce Limits API.
490
+ Fetch the current limits for the Salesforce instance.
524
491
 
525
492
  :return: Parsed JSON response or None on failure.
526
493
  """
527
- self._refresh_token_if_needed()
528
-
529
- if not self.access_token:
530
- logger.error("No access token available for limits.")
531
- return None
532
-
533
494
  endpoint = f"/services/data/{self.api_version}/limits"
534
- headers = {
535
- "Authorization": f"Bearer {self.access_token}",
536
- "User-Agent": self.user_agent,
537
- "Sforce-Call-Options": f"client={self.sforce_client}",
538
- "Accept": "application/json",
539
- }
540
-
541
- parsed_url = urlparse(self.instance_url)
542
- conn = self._create_connection(parsed_url.netloc)
543
-
544
- try:
545
- logger.trace("Request endpoint: %s", endpoint)
546
- logger.trace("Request headers: %s", headers)
547
- conn.request("GET", endpoint, headers=headers)
548
- response = conn.getresponse()
549
- data = response.read().decode("utf-8")
550
- self._http_resp_header_logic(response)
495
+ headers = self._get_common_headers()
551
496
 
552
- if response.status == 200:
553
- logger.debug("Limits API request successful.")
554
- logger.trace("Response body: %s", data)
555
- return json.loads(data)
556
-
557
- logger.error(
558
- "Limits API request failed: %s %s", response.status, response.reason
559
- )
560
- logger.debug("Response body: %s", data)
561
-
562
- except Exception as err:
563
- logger.exception("Error during limits request: %s", err)
497
+ status, data = self._send_request("GET", endpoint, headers)
564
498
 
565
- finally:
566
- logger.debug("Closing connection...")
567
- conn.close()
499
+ if status == 200:
500
+ logger.debug("Limits fetched successfully.")
501
+ return json.loads(data)
568
502
 
503
+ logger.error("Failed to fetch limits: %s", status)
569
504
  return None
570
505
 
571
506
  def query(self, query: str, tooling: bool = False) -> Optional[Dict[str, Any]]:
@@ -576,39 +511,27 @@ class SFAuth:
576
511
  :param tooling: If True, use the Tooling API endpoint.
577
512
  :return: Parsed JSON response or None on failure.
578
513
  """
579
- self._refresh_token_if_needed()
580
-
581
- if not self.access_token:
582
- logger.error("No access token available for query.")
583
- return None
584
-
585
514
  endpoint = f"/services/data/{self.api_version}/"
586
515
  endpoint += "tooling/query" if tooling else "query"
587
516
  query_string = f"?q={quote(query)}"
588
-
589
517
  endpoint += query_string
518
+ headers = self._get_common_headers()
590
519
 
591
- headers = {
592
- "Authorization": f"Bearer {self.access_token}",
593
- "User-Agent": self.user_agent,
594
- "Sforce-Call-Options": f"client={self.sforce_client}",
595
- "Accept": "application/json",
596
- }
597
-
598
- parsed_url = urlparse(self.instance_url)
599
- conn = self._create_connection(parsed_url.netloc)
520
+ paginated_results = {"totalSize": 0, "done": False, "records": []}
600
521
 
601
522
  try:
602
- paginated_results = {"totalSize": 0, "done": False, "records": []}
603
523
  while True:
604
524
  logger.trace("Request endpoint: %s", endpoint)
605
525
  logger.trace("Request headers: %s", headers)
606
- conn.request("GET", endpoint, headers=headers)
607
- response = conn.getresponse()
608
- data = response.read().decode("utf-8")
609
- self._http_resp_header_logic(response)
526
+ headers = self._get_common_headers() # handle refresh token
527
+
528
+ status_code, data = self._send_request(
529
+ method="GET",
530
+ endpoint=endpoint,
531
+ headers=headers,
532
+ )
610
533
 
611
- if response.status == 200:
534
+ if status_code == 200:
612
535
  current_results = json.loads(data)
613
536
  paginated_results["records"].extend(current_results["records"])
614
537
  query_done = current_results.get("done")
@@ -633,9 +556,8 @@ class SFAuth:
633
556
  else:
634
557
  logger.debug("Query failed: %r", query)
635
558
  logger.error(
636
- "Query failed with HTTP status %s (%s)",
637
- response.status,
638
- response.reason,
559
+ "Query failed with HTTP status %s",
560
+ status_code,
639
561
  )
640
562
  logger.debug("Query response: %s", data)
641
563
  break
@@ -645,10 +567,6 @@ class SFAuth:
645
567
  except Exception as err:
646
568
  logger.exception("Exception during query: %s", err)
647
569
 
648
- finally:
649
- logger.trace("Closing connection...")
650
- conn.close()
651
-
652
570
  return None
653
571
 
654
572
  def tooling_query(self, query: str) -> Optional[Dict[str, Any]]:
@@ -678,33 +596,22 @@ class SFAuth:
678
596
  )
679
597
  return None
680
598
 
681
- self._refresh_token_if_needed()
682
-
683
- if not self.access_token:
684
- logger.error("No access token available for key prefixes.")
685
- return None
686
-
687
599
  endpoint = f"/services/data/{self.api_version}/sobjects/"
688
- headers = {
689
- "Authorization": f"Bearer {self.access_token}",
690
- "User-Agent": self.user_agent,
691
- "Sforce-Call-Options": f"client={self.sforce_client}",
692
- "Accept": "application/json",
693
- }
600
+ headers = self._get_common_headers()
694
601
 
695
- parsed_url = urlparse(self.instance_url)
696
- conn = self._create_connection(parsed_url.netloc)
697
602
  prefixes = {}
698
603
 
699
604
  try:
700
605
  logger.trace("Request endpoint: %s", endpoint)
701
606
  logger.trace("Request headers: %s", headers)
702
- conn.request("GET", endpoint, headers=headers)
703
- response = conn.getresponse()
704
- data = response.read().decode("utf-8")
705
- self._http_resp_header_logic(response)
706
607
 
707
- if response.status == 200:
608
+ status_code, data = self._send_request(
609
+ method="GET",
610
+ endpoint=endpoint,
611
+ headers=headers,
612
+ )
613
+
614
+ if status_code == 200:
708
615
  logger.debug("Key prefixes API request successful.")
709
616
  logger.trace("Response body: %s", data)
710
617
  for sobject in json.loads(data)["sobjects"]:
@@ -722,23 +629,18 @@ class SFAuth:
722
629
  return prefixes
723
630
 
724
631
  logger.error(
725
- "Key prefixes API request failed: %s %s",
726
- response.status,
727
- response.reason,
632
+ "Key prefixes API request failed: %s",
633
+ status_code,
728
634
  )
729
635
  logger.debug("Response body: %s", data)
730
636
 
731
637
  except Exception as err:
732
638
  logger.exception("Exception during key prefixes API request: %s", err)
733
639
 
734
- finally:
735
- logger.trace("Closing connection...")
736
- conn.close()
737
-
738
640
  return None
739
641
 
740
642
  def cquery(
741
- self, query_dict: dict[str, str], max_workers: int = 10
643
+ self, query_dict: dict[str, str], batch_size: int = 25, max_workers: int = None
742
644
  ) -> Optional[Dict[str, Any]]:
743
645
  """
744
646
  Execute multiple SOQL queries using the Composite Batch API with threading to reduce network overhead.
@@ -747,28 +649,17 @@ class SFAuth:
747
649
  Each query (subrequest) is counted as a unique API request against Salesforce governance limits.
748
650
 
749
651
  :param query_dict: A dictionary of SOQL queries with keys as logical names and values as SOQL queries.
750
- :param max_workers: The maximum number of threads to spawn for concurrent execution (default is 10).
652
+ :param batch_size: The number of queries to include in each batch (default is 25).
653
+ :param max_workers: The maximum number of threads to spawn for concurrent execution (default is None).
751
654
  :return: Dict mapping the original keys to their corresponding batch response or None on failure.
752
655
  """
753
656
  if not query_dict:
754
657
  logger.warning("No queries to execute.")
755
658
  return None
756
659
 
757
- self._refresh_token_if_needed()
758
-
759
- if not self.access_token:
760
- logger.error("No access token available for query.")
761
- return None
762
-
763
660
  def _execute_batch(queries_batch):
764
661
  endpoint = f"/services/data/{self.api_version}/composite/batch"
765
- headers = {
766
- "Authorization": f"Bearer {self.access_token}",
767
- "User-Agent": self.user_agent,
768
- "Sforce-Call-Options": f"client={self.sforce_client}",
769
- "Accept": "application/json",
770
- "Content-Type": "application/json",
771
- }
662
+ headers = self._get_common_headers()
772
663
 
773
664
  payload = {
774
665
  "haltOnError": False,
@@ -781,75 +672,65 @@ class SFAuth:
781
672
  ],
782
673
  }
783
674
 
784
- parsed_url = urlparse(self.instance_url)
785
- conn = self._create_connection(parsed_url.netloc)
786
- batch_results = {}
675
+ status_code, data = self._send_request(
676
+ method="POST",
677
+ endpoint=endpoint,
678
+ headers=headers,
679
+ body=json.dumps(payload),
680
+ )
787
681
 
788
- try:
789
- logger.trace("Request endpoint: %s", endpoint)
790
- logger.trace("Request headers: %s", headers)
791
- logger.trace("Request payload: %s", json.dumps(payload, indent=2))
792
-
793
- conn.request("POST", endpoint, json.dumps(payload), headers=headers)
794
- conn.sock.settimeout(60 * 10)
795
- response = conn.getresponse()
796
- data = response.read().decode("utf-8")
797
- self._http_resp_header_logic(response)
798
-
799
- if response.status == 200:
800
- logger.debug("Composite query successful.")
801
- logger.trace("Composite query full response: %s", data)
802
- results = json.loads(data).get("results", [])
803
- for i, result in enumerate(results):
804
- records = []
805
- if "result" in result and "records" in result["result"]:
806
- records.extend(result["result"]["records"])
807
- # Handle pagination
808
- while not result["result"].get("done", True):
809
- next_url = result["result"].get("nextRecordsUrl")
810
- if next_url:
811
- conn.request("GET", next_url, headers=headers)
812
- response = conn.getresponse()
813
- data = response.read().decode("utf-8")
814
- self._http_resp_header_logic(response)
815
- if response.status == 200:
816
- next_results = json.loads(data)
817
- records.extend(next_results.get("records", []))
818
- result["result"]["done"] = next_results.get("done")
819
- else:
820
- logger.error(
821
- "Failed to fetch next records: %s",
822
- response.reason,
823
- )
824
- break
825
- else:
826
- result["result"]["done"] = True
827
- paginated_results = result["result"]
828
- paginated_results["records"] = records
829
- if "nextRecordsUrl" in paginated_results:
830
- del paginated_results["nextRecordsUrl"]
831
- batch_results[keys[i]] = paginated_results
832
- if result.get("statusCode") != 200:
833
- logger.error("Query failed for key %s: %s", keys[i], result)
834
- logger.error(
835
- "Query failed with HTTP status %s (%s)",
836
- result.get("statusCode"),
837
- result.get("statusMessage"),
682
+ batch_results = {}
683
+ if status_code == 200:
684
+ logger.debug("Composite query successful.")
685
+ logger.trace("Composite query full response: %s", data)
686
+ results = json.loads(data).get("results", [])
687
+ for i, result in enumerate(results):
688
+ records = []
689
+ if "result" in result and "records" in result["result"]:
690
+ records.extend(result["result"]["records"])
691
+ # Handle pagination
692
+ while not result["result"].get("done", True):
693
+ headers = self._get_common_headers() # handles token refresh
694
+ next_url = result["result"].get("nextRecordsUrl")
695
+ if next_url:
696
+ status_code, next_data = self._send_request(
697
+ method="GET",
698
+ endpoint=next_url,
699
+ headers=headers,
838
700
  )
839
- logger.trace("Query response: %s", result)
840
- else:
841
- logger.error(
842
- "Composite query failed with HTTP status %s (%s)",
843
- response.status,
844
- response.reason,
845
- )
846
- batch_results[keys[i]] = data
847
- logger.trace("Composite query response: %s", data)
848
- except Exception as err:
849
- logger.exception("Exception during composite query: %s", err)
850
- finally:
851
- logger.trace("Closing connection...")
852
- conn.close()
701
+ if status_code == 200:
702
+ next_results = json.loads(next_data)
703
+ records.extend(next_results.get("records", []))
704
+ result["result"]["done"] = next_results.get("done")
705
+ else:
706
+ logger.error(
707
+ "Failed to fetch next records: %s",
708
+ next_data,
709
+ )
710
+ break
711
+ else:
712
+ result["result"]["done"] = True
713
+ paginated_results = result["result"]
714
+ paginated_results["records"] = records
715
+ if "nextRecordsUrl" in paginated_results:
716
+ del paginated_results["nextRecordsUrl"]
717
+ batch_results[keys[i]] = paginated_results
718
+ if result.get("statusCode") != 200:
719
+ logger.error("Query failed for key %s: %s", keys[i], result)
720
+ logger.error(
721
+ "Query failed with HTTP status %s (%s)",
722
+ result.get("statusCode"),
723
+ result.get("statusMessage"),
724
+ )
725
+ logger.trace("Query response: %s", result)
726
+ else:
727
+ logger.error(
728
+ "Composite query failed with HTTP status %s (%s)",
729
+ status_code,
730
+ data,
731
+ )
732
+ batch_results[keys[i]] = data
733
+ logger.trace("Composite query response: %s", data)
853
734
 
854
735
  return batch_results
855
736
 
@@ -858,8 +739,9 @@ class SFAuth:
858
739
 
859
740
  with ThreadPoolExecutor(max_workers=max_workers) as executor:
860
741
  futures = []
861
- for i in range(0, len(keys), 25):
862
- batch_keys = keys[i : i + 25]
742
+ BATCH_SIZE = 25
743
+ for i in range(0, len(keys), BATCH_SIZE):
744
+ batch_keys = keys[i : i + BATCH_SIZE]
863
745
  batch_queries = [query_dict[key] for key in batch_keys]
864
746
  futures.append(executor.submit(_execute_batch, batch_queries))
865
747
 
@@ -869,229 +751,50 @@ class SFAuth:
869
751
  logger.trace("Composite query results: %s", results_dict)
870
752
  return results_dict
871
753
 
872
- def _reconnect_with_backoff(self, attempt: int) -> None:
873
- wait_time = min(2**attempt, 60)
874
- logger.warning(
875
- f"Reconnecting after failure, backoff {wait_time}s (attempt {attempt})"
876
- )
877
- time.sleep(wait_time)
878
-
879
- def _subscribe_topic(
880
- self,
881
- topic: str,
882
- queue_timeout: int = 90,
883
- max_runtime: Optional[int] = None,
884
- ):
754
+ def cdelete(
755
+ self, ids: Iterable[str], batch_size: int = 200, max_workers: int = None
756
+ ) -> Optional[Dict[str, Any]]:
885
757
  """
886
- Yields events from a subscribed Salesforce CometD topic.
758
+ Execute the Collections Delete API to delete multiple records using multithreading.
887
759
 
888
- :param topic: Topic to subscribe to, e.g. '/event/MyEvent__e'
889
- :param queue_timeout: Seconds to wait for a message before logging heartbeat
890
- :param max_runtime: Max total time to listen in seconds (None = unlimited)
760
+ :param ids: A list of record IDs to delete.
761
+ :param batch_size: The number of records to delete in each batch (default is 200).
762
+ :param max_workers: The maximum number of threads to spawn for concurrent execution (default is None).
763
+ :return: Combined JSON response from all batches or None on complete failure.
891
764
  """
892
- warnings.warn(
893
- "The _subscribe_topic method is experimental and subject to change in future versions.",
894
- ExperimentalWarning,
895
- stacklevel=2,
896
- )
897
-
898
- self._refresh_token_if_needed()
899
- self._msg_count: int = 0
900
-
901
- if not self.access_token:
902
- logger.error("No access token available for event stream.")
903
- return
904
-
905
- start_time = time.time()
906
- message_queue = Queue()
907
- headers = {
908
- "Authorization": f"Bearer {self.access_token}",
909
- "Content-Type": "application/json",
910
- "Accept": "application/json",
911
- "User-Agent": self.user_agent,
912
- "Sforce-Call-Options": f"client={self.sforce_client}",
913
- }
914
-
915
- parsed_url = urlparse(self.instance_url)
916
- conn = self._create_connection(parsed_url.netloc)
917
- _API_VERSION = str(self.api_version).removeprefix("v")
918
- client_id = str()
919
-
920
- try:
921
- logger.trace("Starting handshake with Salesforce CometD server.")
922
- handshake_payload = json.dumps(
923
- {
924
- "id": str(self._msg_count + 1),
925
- "version": "1.0",
926
- "minimumVersion": "1.0",
927
- "channel": "/meta/handshake",
928
- "supportedConnectionTypes": ["long-polling"],
929
- "advice": {"timeout": 60000, "interval": 0},
930
- }
931
- )
932
- conn.request(
933
- "POST",
934
- f"/cometd/{_API_VERSION}/meta/handshake",
935
- headers=headers,
936
- body=handshake_payload,
937
- )
938
- response = conn.getresponse()
939
- self._http_resp_header_logic(response)
765
+ ids = list(ids)
766
+ chunks = [ids[i : i + batch_size] for i in range(0, len(ids), batch_size)]
940
767
 
941
- logger.trace("Received handshake response.")
942
- for name, value in response.getheaders():
943
- if name.lower() == "set-cookie" and "BAYEUX_BROWSER=" in value:
944
- _bayeux_browser_cookie = value.split("BAYEUX_BROWSER=")[1].split(
945
- ";"
946
- )[0]
947
- headers["Cookie"] = f"BAYEUX_BROWSER={_bayeux_browser_cookie}"
948
- break
949
-
950
- data = json.loads(response.read().decode("utf-8"))
951
- if not data or not data[0].get("successful"):
952
- logger.error("Handshake failed: %s", data)
953
- return
768
+ def delete_chunk(chunk: List[str]) -> Optional[Dict[str, Any]]:
769
+ endpoint = f"/services/data/{self.api_version}/composite/sobjects?ids={','.join(chunk)}&allOrNone=false"
770
+ headers = self._get_common_headers()
954
771
 
955
- client_id = data[0]["clientId"]
956
- logger.trace(f"Handshake successful, client ID: {client_id}")
957
-
958
- logger.trace(f"Subscribing to topic: {topic}")
959
- subscribe_message = {
960
- "channel": "/meta/subscribe",
961
- "clientId": client_id,
962
- "subscription": topic,
963
- "id": str(self._msg_count + 1),
964
- }
965
- conn.request(
966
- "POST",
967
- f"/cometd/{_API_VERSION}/meta/subscribe",
772
+ status_code, resp_data = self._send_request(
773
+ method="DELETE",
774
+ endpoint=endpoint,
968
775
  headers=headers,
969
- body=json.dumps(subscribe_message),
970
776
  )
971
- response = conn.getresponse()
972
- self._http_resp_header_logic(response)
973
-
974
- sub_response = json.loads(response.read().decode("utf-8"))
975
- if not sub_response or not sub_response[0].get("successful"):
976
- logger.error("Subscription failed: %s", sub_response)
977
- return
978
-
979
- logger.info(f"Successfully subscribed to topic: {topic}")
980
- logger.trace("Entering event polling loop.")
981
-
982
- try:
983
- while True:
984
- if max_runtime and (time.time() - start_time > max_runtime):
985
- logger.info(
986
- f"Disconnecting after max_runtime={max_runtime} seconds"
987
- )
988
- break
989
-
990
- logger.trace("Sending connection message.")
991
- connect_payload = json.dumps(
992
- [
993
- {
994
- "channel": "/meta/connect",
995
- "clientId": client_id,
996
- "connectionType": "long-polling",
997
- "id": str(self._msg_count + 1),
998
- }
999
- ]
1000
- )
1001
-
1002
- max_retries = 5
1003
- attempt = 0
1004
777
 
1005
- while attempt < max_retries:
1006
- try:
1007
- conn.request(
1008
- "POST",
1009
- f"/cometd/{_API_VERSION}/meta/connect",
1010
- headers=headers,
1011
- body=connect_payload,
1012
- )
1013
- response = conn.getresponse()
1014
- self._http_resp_header_logic(response)
1015
- self._msg_count += 1
1016
-
1017
- events = json.loads(response.read().decode("utf-8"))
1018
- for event in events:
1019
- if event.get("channel") == topic and "data" in event:
1020
- logger.trace(
1021
- f"Event received for topic {topic}, data: {event['data']}"
1022
- )
1023
- message_queue.put(event)
1024
- break
1025
- except (
1026
- http.client.RemoteDisconnected,
1027
- ConnectionResetError,
1028
- TimeoutError,
1029
- http.client.BadStatusLine,
1030
- http.client.CannotSendRequest,
1031
- ConnectionAbortedError,
1032
- ConnectionRefusedError,
1033
- ConnectionError,
1034
- ) as e:
1035
- logger.warning(
1036
- f"Connection error (attempt {attempt + 1}): {e}"
1037
- )
1038
- conn.close()
1039
- conn = self._create_connection(parsed_url.netloc)
1040
- self._reconnect_with_backoff(attempt)
1041
- attempt += 1
1042
- except Exception as e:
1043
- logger.exception(
1044
- f"Connection error (attempt {attempt + 1}): {e}"
1045
- )
1046
- break
1047
- else:
1048
- logger.error("Max retries reached. Exiting event stream.")
1049
- break
1050
-
1051
- while True:
1052
- try:
1053
- msg = message_queue.get(timeout=queue_timeout, block=True)
1054
- yield msg
1055
- except Empty:
1056
- logger.debug(
1057
- f"Heartbeat: no message in last {queue_timeout} seconds"
1058
- )
1059
- break
1060
- except KeyboardInterrupt:
1061
- logger.info("Received keyboard interrupt, disconnecting...")
1062
-
1063
- except Exception as e:
1064
- logger.exception(f"Polling error: {e}")
778
+ if status_code == 200:
779
+ logger.debug("Collections delete API response without errors.")
780
+ return json.loads(resp_data)
781
+ else:
782
+ logger.error("Collections delete API request failed: %s", status_code)
783
+ logger.debug("Response body: %s", resp_data)
784
+ return None
1065
785
 
1066
- finally:
1067
- if client_id:
1068
- try:
1069
- logger.trace(
1070
- f"Disconnecting from server with client ID: {client_id}"
1071
- )
1072
- disconnect_payload = json.dumps(
1073
- [
1074
- {
1075
- "channel": "/meta/disconnect",
1076
- "clientId": client_id,
1077
- "id": str(self._msg_count + 1),
1078
- }
1079
- ]
1080
- )
1081
- conn.request(
1082
- "POST",
1083
- f"/cometd/{_API_VERSION}/meta/disconnect",
1084
- headers=headers,
1085
- body=disconnect_payload,
1086
- )
1087
- response = conn.getresponse()
1088
- self._http_resp_header_logic(response)
1089
- _ = response.read()
1090
- logger.trace("Disconnected successfully.")
1091
- except Exception as e:
1092
- logger.warning(f"Exception during disconnect: {e}")
1093
- if conn:
1094
- logger.trace("Closing connection.")
1095
- conn.close()
1096
-
1097
- logger.trace("Leaving event polling loop.")
786
+ results = []
787
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
788
+ futures = [executor.submit(delete_chunk, chunk) for chunk in chunks]
789
+ for future in as_completed(futures):
790
+ result = future.result()
791
+ if result:
792
+ results.append(result)
793
+
794
+ combined_response = [
795
+ item
796
+ for result in results
797
+ for item in (result if isinstance(result, list) else [result])
798
+ if isinstance(result, (dict, list))
799
+ ]
800
+ return combined_response or None
sfq/_cometd.py ADDED
@@ -0,0 +1,297 @@
1
+ import http.client
2
+ import json
3
+ import logging
4
+ import time
5
+ from typing import Any, Optional
6
+ import warnings
7
+ from queue import Empty, Queue
8
+
9
+ TRACE = 5
10
+ logging.addLevelName(TRACE, "TRACE")
11
+
12
+ class ExperimentalWarning(Warning):
13
+ pass
14
+
15
+
16
+ def trace(self: logging.Logger, message: str, *args: Any, **kwargs: Any) -> None:
17
+ """Custom TRACE level logging function with redaction."""
18
+
19
+ def _redact_sensitive(data: Any) -> Any:
20
+ """Redacts sensitive keys from a dictionary or query string."""
21
+ REDACT_VALUE = "*" * 8
22
+ REDACT_KEYS = [
23
+ "access_token",
24
+ "authorization",
25
+ "set-cookie",
26
+ "cookie",
27
+ "refresh_token",
28
+ ]
29
+ if isinstance(data, dict):
30
+ return {
31
+ k: (REDACT_VALUE if k.lower() in REDACT_KEYS else v)
32
+ for k, v in data.items()
33
+ }
34
+ elif isinstance(data, (list, tuple)):
35
+ return type(data)(
36
+ (
37
+ (item[0], REDACT_VALUE)
38
+ if isinstance(item, tuple) and item[0].lower() in REDACT_KEYS
39
+ else item
40
+ for item in data
41
+ )
42
+ )
43
+ elif isinstance(data, str):
44
+ parts = data.split("&")
45
+ for i, part in enumerate(parts):
46
+ if "=" in part:
47
+ key, value = part.split("=", 1)
48
+ if key.lower() in REDACT_KEYS:
49
+ parts[i] = f"{key}={REDACT_VALUE}"
50
+ return "&".join(parts)
51
+ return data
52
+
53
+ redacted_args = args
54
+ if args:
55
+ first = args[0]
56
+ if isinstance(first, str):
57
+ try:
58
+ loaded = json.loads(first)
59
+ first = loaded
60
+ except (json.JSONDecodeError, TypeError):
61
+ pass
62
+ redacted_first = _redact_sensitive(first)
63
+ redacted_args = (redacted_first,) + args[1:]
64
+
65
+ if self.isEnabledFor(TRACE):
66
+ self._log(TRACE, message, redacted_args, **kwargs)
67
+
68
+
69
+ logging.Logger.trace = trace
70
+ logger = logging.getLogger("sfq")
71
+
72
+ def _reconnect_with_backoff(self, attempt: int) -> None:
73
+ wait_time = min(2**attempt, 60)
74
+ logger.warning(
75
+ f"Reconnecting after failure, backoff {wait_time}s (attempt {attempt})"
76
+ )
77
+ time.sleep(wait_time)
78
+
79
+ def _subscribe_topic(
80
+ self,
81
+ topic: str,
82
+ queue_timeout: int = 90,
83
+ max_runtime: Optional[int] = None,
84
+ ):
85
+ """
86
+ Yields events from a subscribed Salesforce CometD topic.
87
+
88
+ :param topic: Topic to subscribe to, e.g. '/event/MyEvent__e'
89
+ :param queue_timeout: Seconds to wait for a message before logging heartbeat
90
+ :param max_runtime: Max total time to listen in seconds (None = unlimited)
91
+ """
92
+ warnings.warn(
93
+ "The _subscribe_topic method is experimental and subject to change in future versions.",
94
+ ExperimentalWarning,
95
+ stacklevel=2,
96
+ )
97
+
98
+ self._refresh_token_if_needed()
99
+ self._msg_count: int = 0
100
+
101
+ if not self.access_token:
102
+ logger.error("No access token available for event stream.")
103
+ return
104
+
105
+ start_time = time.time()
106
+ message_queue = Queue()
107
+ headers = {
108
+ "Authorization": f"Bearer {self.access_token}",
109
+ "Content-Type": "application/json",
110
+ "Accept": "application/json",
111
+ "User-Agent": self.user_agent,
112
+ "Sforce-Call-Options": f"client={self.sforce_client}",
113
+ }
114
+
115
+ parsed_url = urlparse(self.instance_url)
116
+ conn = self._create_connection(parsed_url.netloc)
117
+ _API_VERSION = str(self.api_version).removeprefix("v")
118
+ client_id = str()
119
+
120
+ try:
121
+ logger.trace("Starting handshake with Salesforce CometD server.")
122
+ handshake_payload = json.dumps(
123
+ {
124
+ "id": str(self._msg_count + 1),
125
+ "version": "1.0",
126
+ "minimumVersion": "1.0",
127
+ "channel": "/meta/handshake",
128
+ "supportedConnectionTypes": ["long-polling"],
129
+ "advice": {"timeout": 60000, "interval": 0},
130
+ }
131
+ )
132
+ conn.request(
133
+ "POST",
134
+ f"/cometd/{_API_VERSION}/meta/handshake",
135
+ headers=headers,
136
+ body=handshake_payload,
137
+ )
138
+ response = conn.getresponse()
139
+ self._http_resp_header_logic(response)
140
+
141
+ logger.trace("Received handshake response.")
142
+ for name, value in response.getheaders():
143
+ if name.lower() == "set-cookie" and "BAYEUX_BROWSER=" in value:
144
+ _bayeux_browser_cookie = value.split("BAYEUX_BROWSER=")[1].split(
145
+ ";"
146
+ )[0]
147
+ headers["Cookie"] = f"BAYEUX_BROWSER={_bayeux_browser_cookie}"
148
+ break
149
+
150
+ data = json.loads(response.read().decode("utf-8"))
151
+ if not data or not data[0].get("successful"):
152
+ logger.error("Handshake failed: %s", data)
153
+ return
154
+
155
+ client_id = data[0]["clientId"]
156
+ logger.trace(f"Handshake successful, client ID: {client_id}")
157
+
158
+ logger.trace(f"Subscribing to topic: {topic}")
159
+ subscribe_message = {
160
+ "channel": "/meta/subscribe",
161
+ "clientId": client_id,
162
+ "subscription": topic,
163
+ "id": str(self._msg_count + 1),
164
+ }
165
+ conn.request(
166
+ "POST",
167
+ f"/cometd/{_API_VERSION}/meta/subscribe",
168
+ headers=headers,
169
+ body=json.dumps(subscribe_message),
170
+ )
171
+ response = conn.getresponse()
172
+ self._http_resp_header_logic(response)
173
+
174
+ sub_response = json.loads(response.read().decode("utf-8"))
175
+ if not sub_response or not sub_response[0].get("successful"):
176
+ logger.error("Subscription failed: %s", sub_response)
177
+ return
178
+
179
+ logger.info(f"Successfully subscribed to topic: {topic}")
180
+ logger.trace("Entering event polling loop.")
181
+
182
+ try:
183
+ while True:
184
+ if max_runtime and (time.time() - start_time > max_runtime):
185
+ logger.info(
186
+ f"Disconnecting after max_runtime={max_runtime} seconds"
187
+ )
188
+ break
189
+
190
+ logger.trace("Sending connection message.")
191
+ connect_payload = json.dumps(
192
+ [
193
+ {
194
+ "channel": "/meta/connect",
195
+ "clientId": client_id,
196
+ "connectionType": "long-polling",
197
+ "id": str(self._msg_count + 1),
198
+ }
199
+ ]
200
+ )
201
+
202
+ max_retries = 5
203
+ attempt = 0
204
+
205
+ while attempt < max_retries:
206
+ try:
207
+ conn.request(
208
+ "POST",
209
+ f"/cometd/{_API_VERSION}/meta/connect",
210
+ headers=headers,
211
+ body=connect_payload,
212
+ )
213
+ response = conn.getresponse()
214
+ self._http_resp_header_logic(response)
215
+ self._msg_count += 1
216
+
217
+ events = json.loads(response.read().decode("utf-8"))
218
+ for event in events:
219
+ if event.get("channel") == topic and "data" in event:
220
+ logger.trace(
221
+ f"Event received for topic {topic}, data: {event['data']}"
222
+ )
223
+ message_queue.put(event)
224
+ break
225
+ except (
226
+ http.client.RemoteDisconnected,
227
+ ConnectionResetError,
228
+ TimeoutError,
229
+ http.client.BadStatusLine,
230
+ http.client.CannotSendRequest,
231
+ ConnectionAbortedError,
232
+ ConnectionRefusedError,
233
+ ConnectionError,
234
+ ) as e:
235
+ logger.warning(
236
+ f"Connection error (attempt {attempt + 1}): {e}"
237
+ )
238
+ conn.close()
239
+ conn = self._create_connection(parsed_url.netloc)
240
+ self._reconnect_with_backoff(attempt)
241
+ attempt += 1
242
+ except Exception as e:
243
+ logger.exception(
244
+ f"Connection error (attempt {attempt + 1}): {e}"
245
+ )
246
+ break
247
+ else:
248
+ logger.error("Max retries reached. Exiting event stream.")
249
+ break
250
+
251
+ while True:
252
+ try:
253
+ msg = message_queue.get(timeout=queue_timeout, block=True)
254
+ yield msg
255
+ except Empty:
256
+ logger.debug(
257
+ f"Heartbeat: no message in last {queue_timeout} seconds"
258
+ )
259
+ break
260
+ except KeyboardInterrupt:
261
+ logger.info("Received keyboard interrupt, disconnecting...")
262
+
263
+ except Exception as e:
264
+ logger.exception(f"Polling error: {e}")
265
+
266
+ finally:
267
+ if client_id:
268
+ try:
269
+ logger.trace(
270
+ f"Disconnecting from server with client ID: {client_id}"
271
+ )
272
+ disconnect_payload = json.dumps(
273
+ [
274
+ {
275
+ "channel": "/meta/disconnect",
276
+ "clientId": client_id,
277
+ "id": str(self._msg_count + 1),
278
+ }
279
+ ]
280
+ )
281
+ conn.request(
282
+ "POST",
283
+ f"/cometd/{_API_VERSION}/meta/disconnect",
284
+ headers=headers,
285
+ body=disconnect_payload,
286
+ )
287
+ response = conn.getresponse()
288
+ self._http_resp_header_logic(response)
289
+ _ = response.read()
290
+ logger.trace("Disconnected successfully.")
291
+ except Exception as e:
292
+ logger.warning(f"Exception during disconnect: {e}")
293
+ if conn:
294
+ logger.trace("Closing connection.")
295
+ conn.close()
296
+
297
+ logger.trace("Leaving event polling loop.")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sfq
3
- Version: 0.0.14
3
+ Version: 0.0.16
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
@@ -90,6 +90,13 @@ for subrequest_identifer, subrequest_response in batched_response.items():
90
90
  >>> "Frozen Users" returned 4082 records
91
91
  ```
92
92
 
93
+ ### Collection Deletions
94
+
95
+ ```python
96
+ response = sf.cdelete(['07La0000000bYgj', '07La0000000bYgk', '07La0000000bYgl'])
97
+ >>> [{'id': '500aj000006wtdZAAQ', 'success': True, 'errors': []}, {'id': '500aj000006wtdaAAA', 'success': True, 'errors': []}, {'id': '500aj000006wtdbAAA', 'success': True, 'errors': []}]
98
+ ```
99
+
93
100
  ### Static Resources
94
101
 
95
102
  ```python
@@ -0,0 +1,6 @@
1
+ sfq/__init__.py,sha256=8OErEOUxTDKOoj4sFyovhBXi0m918s_G1gaHerqTwzE,31967
2
+ sfq/_cometd.py,sha256=XimQEubmJwUmbWe85TxH_cuhGvWVuiHHrVr41tguuiI,10508
3
+ sfq/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ sfq-0.0.16.dist-info/METADATA,sha256=_rW37RIboasxVhI0wJ0GkSK_IYtd7ld5MJtZLqc_Vpg,6908
5
+ sfq-0.0.16.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
6
+ sfq-0.0.16.dist-info/RECORD,,
@@ -1,5 +0,0 @@
1
- sfq/__init__.py,sha256=sH3sDtjaCCzDaQ0Az17JYSIedAJioxN2PNl9QaxYoYc,43705
2
- sfq/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
- sfq-0.0.14.dist-info/METADATA,sha256=dgP3Q2fRZjWcJPcJgExfdtZJ_X9TlCSt7FxMe3zicVU,6598
4
- sfq-0.0.14.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
5
- sfq-0.0.14.dist-info/RECORD,,
File without changes