salespyforce 1.4.0.dev1__py3-none-any.whl → 1.4.0rc0__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.
salespyforce/api.py CHANGED
@@ -4,12 +4,13 @@
4
4
  :Synopsis: Defines the basic functions associated with the Salesforce API
5
5
  :Created By: Jeff Shurtliff
6
6
  :Last Modified: Jeff Shurtliff
7
- :Modified Date: 17 Feb 2023
7
+ :Modified Date: 30 Jan 2026
8
8
  """
9
9
 
10
10
  import requests
11
11
 
12
- from .utils import log_utils
12
+ from . import errors
13
+ from .utils import core_utils, log_utils
13
14
 
14
15
  # Initialize logging
15
16
  logger = log_utils.initialize_logging(__name__)
@@ -19,6 +20,10 @@ def get(sfdc_object, endpoint, params=None, headers=None, timeout=30, show_full_
19
20
  """This method performs a GET request against the Salesforce instance.
20
21
  (`Reference <https://jereze.com/code/authentification-salesforce-rest-api-python/>`_)
21
22
 
23
+ .. version-changed:: 1.4.0
24
+ The full URL for the API call is now constructed prior to making the call.
25
+ The provided URL is also now evaluated to ensure it is a valid Salesforce URL.
26
+
22
27
  :param sfdc_object: The instantiated SalesPyForce object
23
28
  :param endpoint: The API endpoint to query
24
29
  :type endpoint: str
@@ -40,12 +45,13 @@ def get(sfdc_object, endpoint, params=None, headers=None, timeout=30, show_full_
40
45
  default_headers = _get_headers(sfdc_object.access_token)
41
46
  headers = default_headers if not headers else headers
42
47
 
43
- # Make sure the endpoint begins with a slash
44
- endpoint = f'/{endpoint}' if not endpoint.startswith('/') else endpoint
48
+ # Construct the request URL
49
+ url = _construct_full_query_url(endpoint, sfdc_object.instance_url)
45
50
 
46
51
  # Perform the API call
47
- response = requests.get(f'{sfdc_object.instance_url}{endpoint}', headers=headers, params=params, timeout=timeout)
52
+ response = requests.get(url, headers=headers, params=params, timeout=timeout)
48
53
  if response.status_code >= 300:
54
+ # TODO: Functionalize this segment and figure out how to improve on the approach somehow
49
55
  if show_full_error:
50
56
  raise RuntimeError(f'The GET request failed with a {response.status_code} status code.\n'
51
57
  f'{response.text}')
@@ -61,6 +67,10 @@ def api_call_with_payload(sfdc_object, method, endpoint, payload, params=None, h
61
67
  """This method performs a POST call against the Salesforce instance.
62
68
  (`Reference <https://jereze.com/code/authentification-salesforce-rest-api-python/>`_)
63
69
 
70
+ .. version-changed:: 1.4.0
71
+ The full URL for the API call is now constructed prior to making the call.
72
+ The provided URL is also now evaluated to ensure it is a valid Salesforce URL.
73
+
64
74
  :param sfdc_object: The instantiated SalesPyForce object
65
75
  :param method: The API method (``post``, ``put``, or ``patch``)
66
76
  :type method: str
@@ -86,25 +96,23 @@ def api_call_with_payload(sfdc_object, method, endpoint, payload, params=None, h
86
96
  default_headers = _get_headers(sfdc_object.access_token)
87
97
  headers = default_headers if not headers else headers
88
98
 
89
- # Make sure the endpoint begins with a slash
90
- endpoint = f'/{endpoint}' if not endpoint.startswith('/') else endpoint
99
+ # Construct the request URL
100
+ url = _construct_full_query_url(endpoint, sfdc_object.instance_url)
91
101
 
92
102
  # Perform the API call
93
103
  if method.lower() == 'post':
94
- response = requests.post(f'{sfdc_object.instance_url}{endpoint}', json=payload, headers=headers, params=params,
95
- timeout=timeout)
104
+ response = requests.post(url, json=payload, headers=headers, params=params, timeout=timeout)
96
105
  elif method.lower() == 'patch':
97
- response = requests.patch(f'{sfdc_object.instance_url}{endpoint}', json=payload, headers=headers, params=params,
98
- timeout=timeout)
106
+ response = requests.patch(url, json=payload, headers=headers, params=params, timeout=timeout)
99
107
  elif method.lower() == 'put':
100
- response = requests.put(f'{sfdc_object.instance_url}{endpoint}', json=payload, headers=headers, params=params,
101
- timeout=timeout)
108
+ response = requests.put(url, json=payload, headers=headers, params=params, timeout=timeout)
102
109
  else:
103
- raise ValueError('The API call method (POST or PATCH OR PUT) must be defined.')
110
+ raise ValueError('The API call method (POST or PATCH or PUT) must be defined')
104
111
 
105
112
  # Examine the result
106
113
  if response.status_code >= 300:
107
114
  if show_full_error:
115
+ # TODO: Functionalize this segment and figure out how to improve on the approach somehow
108
116
  raise RuntimeError(f'The POST request failed with a {response.status_code} status code.\n'
109
117
  f'{response.text}')
110
118
  else:
@@ -117,6 +125,49 @@ def api_call_with_payload(sfdc_object, method, endpoint, payload, params=None, h
117
125
  return response
118
126
 
119
127
 
128
+ def delete(sfdc_object, endpoint, params=None, headers=None, timeout=30, show_full_error=True, return_json=True):
129
+ """This method performs a DELETE request against the Salesforce instance.
130
+
131
+ .. version-added:: 1.4.0
132
+
133
+ :param sfdc_object: The instantiated SalesPyForce object
134
+ :param endpoint: The API endpoint to query
135
+ :type endpoint: str
136
+ :param params: The query parameters (where applicable)
137
+ :type params: dict, None
138
+ :param headers: Specific API headers to use when performing the API call
139
+ :type headers: dict, None
140
+ :param timeout: The timeout period in seconds (defaults to ``30``)
141
+ :type timeout: int, str, None
142
+ :param show_full_error: Determines if the full error message should be displayed (defaults to ``True``)
143
+ :type show_full_error: bool
144
+ :param return_json: Determines if the response should be returned in JSON format (defaults to ``True``)
145
+ :returns: The API response in JSON format or as a ``requests`` object
146
+ """
147
+ # Define the parameters as an empty dictionary if none are provided
148
+ params = {} if params is None else params
149
+
150
+ # Define the headers
151
+ default_headers = _get_headers(sfdc_object.access_token)
152
+ headers = default_headers if not headers else headers
153
+
154
+ # Construct the request URL
155
+ url = _construct_full_query_url(endpoint, sfdc_object.instance_url)
156
+
157
+ # Perform the API call
158
+ response = requests.delete(url, headers=headers, params=params, timeout=timeout)
159
+ if response.status_code >= 300:
160
+ if show_full_error:
161
+ # TODO: Functionalize this segment and figure out how to improve on the approach somehow
162
+ raise RuntimeError(f'The DELETE request failed with a {response.status_code} status code.\n'
163
+ f'{response.text}')
164
+ else:
165
+ raise RuntimeError(f'The DELETE request failed with a {response.status_code} status code.')
166
+ if return_json:
167
+ response = response.json()
168
+ return response
169
+
170
+
120
171
  def _get_headers(_access_token, _header_type='default'):
121
172
  """This function returns the appropriate HTTP headers to use for different types of API calls."""
122
173
  headers = {
@@ -127,3 +178,36 @@ def _get_headers(_access_token, _header_type='default'):
127
178
  if _header_type == 'articles':
128
179
  headers['accept-language'] = 'en-US'
129
180
  return headers
181
+
182
+
183
+ def _construct_full_query_url(_endpoint: str, _instance_url: str) -> str:
184
+ """This function constructs the URL to use in an API call to the Salesforce REST API.
185
+
186
+ .. version-added:: 1.4.0
187
+
188
+ :param _endpoint: The endpoint provided when calling an API call method or function
189
+ :type _endpoint: str
190
+ :param _instance_url: The Salesforce instance URL defined when the core object was instantiated
191
+ :type _instance_url: str
192
+ :returns: The fully qualified URL
193
+ :raises: :py:exc:`TypeError`,
194
+ :py:exc:`errors.exceptions.InvalidURLError`
195
+ """
196
+ # Raise an exception if the endpoint is not a string
197
+ if not isinstance(_endpoint, str):
198
+ exc_msg = 'The provided URL must be a string and a valid Salesforce URL'
199
+ logger.critical(exc_msg)
200
+ raise TypeError(exc_msg)
201
+
202
+ # Construct the URL as needed by prepending the instance URL
203
+ if _endpoint.startswith('https://'):
204
+ # Only permit valid Salesforce URLs
205
+ if not core_utils.is_valid_salesforce_url(_endpoint):
206
+ raise errors.exceptions.InvalidURLError(url=_endpoint)
207
+ _url = _endpoint
208
+ else:
209
+ _endpoint = f'/{_endpoint}' if not _endpoint.startswith('/') else _endpoint
210
+ _url = f'{_instance_url}{_endpoint}'
211
+
212
+ # Return the constructed URL
213
+ return _url
salespyforce/core.py CHANGED
@@ -6,7 +6,7 @@
6
6
  :Example: ``sfdc = Salesforce(helper=helper_file_path)``
7
7
  :Created By: Jeff Shurtliff
8
8
  :Last Modified: Jeff Shurtliff
9
- :Modified Date: 17 Nov 2025
9
+ :Modified Date: 03 Feb 2026
10
10
  """
11
11
 
12
12
  import re
@@ -21,6 +21,7 @@ from .utils.helper import get_helper_settings
21
21
 
22
22
  # Define constants
23
23
  FALLBACK_SFDC_API_VERSION = '65.0' # Used if querying the org for the version fails
24
+ VALID_ACCESS_CONTROL_FIELDS = {'HasReadAccess', 'HasEditAccess', 'HasDeleteAccess'}
24
25
 
25
26
  # Initialize logging
26
27
  logger = log_utils.initialize_logging(__name__)
@@ -60,7 +61,8 @@ class Salesforce(object):
60
61
  :param helper: The file path of a helper file
61
62
  :type helper: str, None
62
63
  :returns: The instantiated object
63
- :raises: :py:exc:`TypeError`
64
+ :raises: :py:exc:`TypeError`,
65
+ :py:exc:`RuntimeError`
64
66
  """
65
67
  # Define the default settings
66
68
  self._helper_settings = {}
@@ -94,8 +96,9 @@ class Salesforce(object):
94
96
  # Get the connection information used to connect to the instance
95
97
  self.connection_info = connection_info if connection_info is not None else self._get_empty_connection_info()
96
98
 
97
- # Define the base URL value
98
- self.base_url = self.connection_info.get('base_url')
99
+ # Define the base URL and Org ID
100
+ self.base_url = self.connection_info.get('base_url', '')
101
+ self.org_id = self.connection_info.get('org_id', '')
99
102
 
100
103
  # Define the connection response data variables
101
104
  auth_response = self.connect()
@@ -106,6 +109,9 @@ class Salesforce(object):
106
109
  # Define the version with explicitly provided version or by querying the Salesforce org
107
110
  self.version = f'v{version}' if version else f'v{self.get_latest_api_version()}'
108
111
 
112
+ # Retrieve info about current user
113
+ self.current_user_info = self.retrieve_current_user_info(on_init=True, raise_exc_on_error=False)
114
+
109
115
  # Import inner object classes so their methods can be called from the primary object
110
116
  self.chatter = self._import_chatter_class()
111
117
  self.knowledge = self._import_knowledge_class()
@@ -142,11 +148,40 @@ class Salesforce(object):
142
148
  """This method returns the appropriate HTTP headers to use for different types of API calls."""
143
149
  return api._get_headers(_access_token=self.access_token, _header_type=_header_type)
144
150
 
151
+ def _get_cached_user_info(self, _field: str, _retrieve_if_missing: bool = False):
152
+ """This method attempts to retrieve a value for a given field in the cached ``userinfo`` data and
153
+ optionally queries the API as needed to retrieve the data when not found.
154
+
155
+ .. version-added:: 1.4.0
156
+
157
+ :param _field: The name of the field for which the value is needed
158
+ :type _field: str
159
+ :param _retrieve_if_missing: Will query the Salesforce REST API for the data when missing if True
160
+ (``False`` by default)
161
+ :type _retrieve_if_missing: bool
162
+ :returns: The field value when found (or retrieved), or a None value if the field value could not be obtained
163
+ :raises: :py:exc:`errors.exceptions.APIRequestError`
164
+ """
165
+ _field_value = None
166
+ _not_present_msg = f"The '{_field}' field is not present in the current user info data"
167
+ if self.current_user_info and _field in self.current_user_info:
168
+ _field_value = self.current_user_info[_field]
169
+ else:
170
+ logger.warning(_not_present_msg)
171
+ if _retrieve_if_missing:
172
+ self.current_user_info = self.retrieve_current_user_info(raise_exc_on_error=True)
173
+ if self.current_user_info and _field in self.current_user_info:
174
+ _field_value = self.current_user_info[_field]
175
+ else:
176
+ logger.error(f'{_not_present_msg} even after refreshing cached current user info')
177
+ return _field_value
178
+
145
179
  def connect(self):
146
180
  """This method connects to the Salesforce instance to obtain the access token.
147
181
  (`Reference <https://jereze.com/code/authentification-salesforce-rest-api-python/>`_)
148
182
 
149
183
  :returns: The API call response with the authorization information
184
+ :raises: :py:exc:`RuntimeError`
150
185
  """
151
186
  params = {
152
187
  'grant_type': 'password',
@@ -160,6 +195,51 @@ class Salesforce(object):
160
195
  raise RuntimeError(f'Failed to connect to the Salesforce instance.\n{response.text}')
161
196
  return response.json()
162
197
 
198
+ def retrieve_current_user_info(self, all_data=False, raise_exc_on_error=False, on_init=False) -> dict:
199
+ """This method retrieves the ``userinfo`` data for the current/running user.
200
+
201
+ .. version-added:: 1.4.0
202
+
203
+ :param all_data: Returns all ``userinfo`` data from the API when True instead of only the relevant fields/values
204
+ (``False`` by default)
205
+ :type all_data: bool
206
+ :param raise_exc_on_error: Raises an exception if the API retrieval attempt fails when True (``False`` by default)
207
+ :type raise_exc_on_error: bool
208
+ :param on_init: Indicates if the method is being called during the core object instantiation (``False`` by default)
209
+ :type on_init: bool
210
+ :returns: The user info data within a dictionary
211
+ :raises: :py:exc:`RuntimeError`,
212
+ :py:exc:`errors.exceptions.APIRequestError`
213
+ """
214
+ user_info = {'user_id': '', 'nickname': '', 'name': '', 'email': '', 'user_type': '',
215
+ 'language': '', 'locale': '', 'utcOffset': '', 'is_salesforce_integration_user': None}
216
+ bool_fields = ['is_salesforce_integration_user']
217
+ endpoint = '/services/oauth2/userinfo'
218
+ base_error_msg = 'Failed to retrieve current user info'
219
+ msg_init_segment = 'on core object instantiation'
220
+ if on_init:
221
+ base_error_msg = f'{base_error_msg} {msg_init_segment}'
222
+ try:
223
+ response = self.get(endpoint)
224
+ if isinstance(response, dict) and all_data:
225
+ user_info = response
226
+ elif isinstance(response, dict):
227
+ for field in user_info.keys():
228
+ if field in response:
229
+ default_val = None if field in bool_fields else ''
230
+ user_info[field] = response.get(field, default_val)
231
+ else:
232
+ logger.error(f'{base_error_msg} with a usable format')
233
+ except Exception as exc:
234
+ exc_type = errors.handlers.get_exception_type(exc)
235
+ exc_msg = f'{base_error_msg} due to {exc_type} exception: {exc}'
236
+ logger.error(exc_msg)
237
+ if raise_exc_on_error:
238
+ raise errors.exceptions.APIRequestError(f'{exc_type}: {exc}')
239
+
240
+ # Return the populated user info
241
+ return user_info
242
+
163
243
  def get(self, endpoint, params=None, headers=None, timeout=30, show_full_error=True, return_json=True):
164
244
  """This method performs a GET request against the Salesforce instance.
165
245
  (`Reference <https://jereze.com/code/authentification-salesforce-rest-api-python/>`_)
@@ -176,6 +256,7 @@ class Salesforce(object):
176
256
  :type show_full_error: bool
177
257
  :param return_json: Determines if the response should be returned in JSON format (defaults to ``True``)
178
258
  :returns: The API response in JSON format or as a ``requests`` object
259
+ :raises: :py:exc:`RuntimeError`
179
260
  """
180
261
  return api.get(self, endpoint=endpoint, params=params, headers=headers, timeout=timeout,
181
262
  show_full_error=show_full_error, return_json=return_json)
@@ -201,6 +282,7 @@ class Salesforce(object):
201
282
  :type show_full_error: bool
202
283
  :param return_json: Determines if the response should be returned in JSON format (defaults to ``True``)
203
284
  :returns: The API response in JSON format or as a ``requests`` object
285
+ :raises: :py:exc:`RuntimeError`
204
286
  """
205
287
  return api.api_call_with_payload(self, method=method, endpoint=endpoint, payload=payload, params=params,
206
288
  headers=headers, timeout=timeout, show_full_error=show_full_error,
@@ -224,6 +306,7 @@ class Salesforce(object):
224
306
  :type show_full_error: bool
225
307
  :param return_json: Determines if the response should be returned in JSON format (defaults to ``True``)
226
308
  :returns: The API response in JSON format or as a ``requests`` object
309
+ :raises: :py:exc:`RuntimeError`
227
310
  """
228
311
  return api.api_call_with_payload(self, 'post', endpoint=endpoint, payload=payload, params=params,
229
312
  headers=headers, timeout=timeout, show_full_error=show_full_error,
@@ -247,6 +330,7 @@ class Salesforce(object):
247
330
  :type show_full_error: bool
248
331
  :param return_json: Determines if the response should be returned in JSON format (defaults to ``True``)
249
332
  :returns: The API response in JSON format or as a ``requests`` object
333
+ :raises: :py:exc:`RuntimeError`
250
334
  """
251
335
  return api.api_call_with_payload(self, 'patch', endpoint=endpoint, payload=payload, params=params,
252
336
  headers=headers, timeout=timeout, show_full_error=show_full_error,
@@ -270,16 +354,41 @@ class Salesforce(object):
270
354
  :type show_full_error: bool
271
355
  :param return_json: Determines if the response should be returned in JSON format (defaults to ``True``)
272
356
  :returns: The API response in JSON format or as a ``requests`` object
357
+ :raises: :py:exc:`RuntimeError`
273
358
  """
274
359
  return api.api_call_with_payload(self, 'put', endpoint=endpoint, payload=payload, params=params,
275
360
  headers=headers, timeout=timeout, show_full_error=show_full_error,
276
361
  return_json=return_json)
277
362
 
363
+ def delete(self, endpoint, params=None, headers=None, timeout=30, show_full_error=True, return_json=True):
364
+ """This method performs a DELETE request against the Salesforce instance.
365
+ (`Reference <https://jereze.com/code/authentification-salesforce-rest-api-python/>`_)
366
+
367
+ .. version-added:: 1.4.0
368
+
369
+ :param endpoint: The API endpoint to query
370
+ :type endpoint: str
371
+ :param params: The query parameters (where applicable)
372
+ :type params: dict, None
373
+ :param headers: Specific API headers to use when performing the API call
374
+ :type headers: dict, None
375
+ :param timeout: The timeout period in seconds (defaults to ``30``)
376
+ :type timeout: int, str, None
377
+ :param show_full_error: Determines if the full error message should be displayed (defaults to ``True``)
378
+ :type show_full_error: bool
379
+ :param return_json: Determines if the response should be returned in JSON format (defaults to ``True``)
380
+ :returns: The API response in JSON format or as a ``requests`` object
381
+ :raises: :py:exc:`RuntimeError`
382
+ """
383
+ return api.delete(self, endpoint=endpoint, params=params, headers=headers, timeout=timeout,
384
+ show_full_error=show_full_error, return_json=return_json)
385
+
278
386
  def get_api_versions(self) -> list:
279
387
  """This method returns the API versions for the Salesforce releases.
280
388
  (`Reference <https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/dome_versions.htm>`_)
281
389
 
282
390
  :returns: A list containing the API metadata from the ``/services/data`` endpoint.
391
+ :raises: :py:exc:`RuntimeError`
283
392
  """
284
393
  return self.get('/services/data')
285
394
 
@@ -305,13 +414,19 @@ class Salesforce(object):
305
414
  def get_org_limits(self):
306
415
  """This method returns a list of all org limits.
307
416
 
308
- .. versionadded:: 1.1.0
417
+ .. version-added:: 1.1.0
418
+
419
+ :returns: The Salesforce org governor limits data
420
+ :raises: :py:exc:`RuntimeError`
309
421
  """
310
422
  return self.get(f'/services/data/{self.version}/limits')
311
423
 
312
424
  def get_all_sobjects(self):
313
425
  """This method returns a list of all Salesforce objects. (i.e. sObjects)
314
426
  (`Reference <https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/dome_describeGlobal.htm>`_)
427
+
428
+ :returns: The list of all Salesforce objects
429
+ :raises: :py:exc:`RuntimeError`
315
430
  """
316
431
  return self.get(f'/services/data/{self.version}/sobjects')
317
432
 
@@ -325,6 +440,7 @@ class Salesforce(object):
325
440
  :param describe: Determines if the full (i.e. ``describe``) data should be returned (defaults to ``False``)
326
441
  :type describe: bool
327
442
  :returns: The Salesforce object data
443
+ :raises: :py:exc:`RuntimeError`
328
444
  """
329
445
  uri = f'/services/data/{self.version}/sobjects/{object_name}'
330
446
  uri = f'{uri}/describe' if describe else uri
@@ -337,15 +453,32 @@ class Salesforce(object):
337
453
  :param object_name: The name of the Salesforce object
338
454
  :type object_name: str
339
455
  :returns: The Salesforce object data
456
+ :raises: :py:exc:`RuntimeError`
340
457
  """
341
458
  return self.get_sobject(object_name, describe=True)
342
459
 
343
460
  def get_rest_resources(self):
344
461
  """This method returns a list of all available REST resources.
345
462
  (`Reference <https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/dome_discoveryresource.htm>`_)
463
+
464
+ :returns: The list of all available REST resources for the Salesforce org
465
+ :raises: :py:exc:`RuntimeError`
346
466
  """
347
467
  return self.get(f'/services/data/{self.version}')
348
468
 
469
+ @staticmethod
470
+ def get_18_char_id(record_id: str) -> str:
471
+ """This method converts a 15-character Salesforce record ID to its 18-character case-insensitive form.
472
+
473
+ .. version-added:: 1.4.0
474
+
475
+ :param record_id: The Salesforce record ID to convert (or return unchanged if already 18 characters)
476
+ :type record_id: str
477
+ :returns: The 18-character Salesforce record ID
478
+ :raises: :py:exc:`ValueError`
479
+ """
480
+ return core_utils.get_18_char_id(record_id=record_id)
481
+
349
482
  def soql_query(self, query, replace_quotes=True, next_records_url=False):
350
483
  """This method performs a SOQL query and returns the results in JSON format.
351
484
  (`Reference 1 <https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/dome_query.htm>`_,
@@ -358,6 +491,7 @@ class Salesforce(object):
358
491
  :param next_records_url: Indicates that the ``query`` parameter is a ``nextRecordsUrl`` value.
359
492
  :type next_records_url: bool
360
493
  :returns: The result of the SOQL query
494
+ :raises: :py:exc:`RuntimeError`
361
495
  """
362
496
  if next_records_url:
363
497
  query = re.sub(r'^.*/', '', query) if '/' in query else query
@@ -372,16 +506,215 @@ class Salesforce(object):
372
506
  """This method performs a SOSL query to search for a given string.
373
507
  (`Reference <https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_search.htm>`_)
374
508
 
375
- .. versionadded:: 1.1.0
509
+ .. version-added:: 1.1.0
376
510
 
377
511
  :param string_to_search: The string for which to search
378
512
  :type string_to_search: str
379
513
  :returns: The SOSL response data in JSON format
514
+ :raises: :py:exc:`RuntimeError`
380
515
  """
381
516
  query = 'FIND {' + string_to_search + '}'
382
517
  query = core_utils.url_encode(query)
383
518
  return self.get(f'/services/data/{self.version}/search/?q={query}')
384
519
 
520
+ def check_user_record_access(self, record_id: str, user_id=None) -> dict:
521
+ """This method checks the Read, Edit, and Delete access for a given record and user.
522
+
523
+ .. version-added:: 1.4.0
524
+
525
+ :param record_id: The ``Id`` value of the record against which to check the user access
526
+ :type record_id: str
527
+ :param user_id: The ``Id`` of the user to evaluate (or the current user's ID if not explicitly defined)
528
+ :type user_id: str, None
529
+ :returns: Dictionary with Boolean values for ``HasReadAccess``, ``HasEditAccess``, and ``HasDeleteAccess``
530
+ :raises: :py:exc:`RuntimeError`,
531
+ :py:exc:`errors.exceptions.APIRequestError`
532
+ """
533
+ record_access = {'HasReadAccess': None, 'HasEditAccess': None, 'HasDeleteAccess': None}
534
+
535
+ # Use the current/running user's ID if an ID wasn't explicitly provided
536
+ if not user_id:
537
+ user_id = self._get_cached_user_info(_field='user_id', _retrieve_if_missing=True)
538
+ if user_id:
539
+ logger.debug(f'Using the User Id {user_id} for the running user as an Id was not specified')
540
+
541
+ # Raise an exception if the User ID is still undefined
542
+ if not user_id:
543
+ error_msg = f'The user access for record Id {record_id} cannot be checked as the User Id is undefined'
544
+ logger.error(error_msg)
545
+ raise errors.exceptions.MissingRequiredDataError(error_msg)
546
+
547
+ # Perform SOQL query for the access data
548
+ query = f"""
549
+ SELECT RecordId, HasReadAccess, HasEditAccess, HasDeleteAccess
550
+ FROM UserRecordAccess
551
+ WHERE UserId = '{user_id}' AND RecordId = '{record_id}'
552
+ """
553
+ response = self.soql_query(query=query)
554
+
555
+ # Parse the response to extract the relevant field values
556
+ if 'records' in response and response['records']:
557
+ response = response['records'][0]
558
+ for field in record_access.keys():
559
+ record_access[field] = response.get(field, None)
560
+
561
+ # Return the record access data
562
+ return record_access
563
+
564
+ @staticmethod
565
+ def _eval_user_record_access(_field: str, _record_id: str,
566
+ _record_access_data: dict, _raise_exc_on_failure: bool = True) -> bool:
567
+ """This private method checks for an access level given the field and the record access data.
568
+
569
+ .. version-added:: 1.4.0
570
+
571
+ :param _field: The access level field to evaluate (``HasReadAccess``, ``HasEditAccess``, ``HasDeleteAccess``)
572
+ :type _field: str
573
+ :param _record_id: The ID value for the record whose access is being checked
574
+ :type _record_id: str
575
+ :param _record_access_data: The user record access data that has already been retrieved
576
+ :type _record_access_data: dict
577
+ :param _raise_exc_on_failure: Raises an exception rather than returning a ``None`` value (``True`` by default)
578
+ :type _raise_exc_on_failure: bool
579
+ :returns: Boolean value indicating the access level for the given field
580
+ :raises: :py:exc:`errors.exceptions.InvalidFieldError`
581
+ """
582
+ # Raise an exception if a valid access control field is not provided
583
+ if _field not in VALID_ACCESS_CONTROL_FIELDS:
584
+ _error_msg = f"The field '{_field}' is not a valid record access level field"
585
+ raise errors.exceptions.InvalidFieldError(_error_msg)
586
+
587
+ # Identify the access level value if possible (API retrievals should be handled in a parent method)
588
+ _has_access = _record_access_data.get(_field) if _field in _record_access_data else None
589
+ if _has_access is None:
590
+ _error_msg = f"The value for the '{_field}' is undefined for the given Record Id '{_record_id}'"
591
+ logger.error(_error_msg)
592
+ if _raise_exc_on_failure:
593
+ raise errors.exceptions.MissingRequiredDataError(_error_msg)
594
+
595
+ # Return the identified access level value
596
+ return _has_access
597
+
598
+ def can_access_record(self, access_type, record_id, user_id=None, record_access_data=None, raise_exc_on_failure=True):
599
+ """This method evaluates if a user can access a specific record given the access type.
600
+
601
+ .. version-added:: 1.4.0
602
+
603
+ :param access_type: The type of access to evaluate (``read``, ``edit``, or ``delete``)
604
+ :type access_type: str
605
+ :param record_id: The ID of the record
606
+ :type record_id: str
607
+ :param user_id: The ID of the user to evaluate (defaults to the current/running user if not defined)
608
+ :type user_id: str, None
609
+ :param record_access_data: The user record access data that has already been retrieved (optional)
610
+ :type record_access_data: dict, None
611
+ :param raise_exc_on_failure: Raises an exception rather than returning a ``None`` value (``True`` by default)
612
+ :type raise_exc_on_failure: bool
613
+ :returns: Boolean value indicating the access level for the given field
614
+ :raises: :py:exc:`errors.exceptions.InvalidFieldError`
615
+ """
616
+ # Define the initial value for the result
617
+ can_access = None
618
+
619
+ # Identify the correct field to query based on access type
620
+ access_type_field_mapping = {
621
+ 'read': 'HasReadAccess',
622
+ 'edit': 'HasEditAccess',
623
+ 'delete': 'HasDeleteAccess',
624
+ }
625
+ if access_type.lower() not in access_type_field_mapping:
626
+ error_msg = f"The access_type '{access_type}' is invalid (must use 'read', 'edit', or 'delete')"
627
+ logger.error(error_msg)
628
+ if raise_exc_on_failure:
629
+ raise errors.exceptions.InvalidParameterError(error_msg)
630
+ else:
631
+ # Retrieve the field name to check
632
+ read_access_field = access_type_field_mapping.get(access_type.lower())
633
+
634
+ # Check to see if record access data was provided and validate that it is a dictionary
635
+ if record_access_data and not isinstance(record_access_data, dict):
636
+ error_msg = f"The record_access_data provided is Type {type(read_access_field)} but must be a dict"
637
+ logger.error(error_msg)
638
+ if raise_exc_on_failure:
639
+ raise errors.exceptions.DataMismatchError(error_msg)
640
+
641
+ # Perform the API all to check the record access for the user if data not provided
642
+ if not record_access_data:
643
+ record_access_data = self.check_user_record_access(record_id=record_id, user_id=user_id)
644
+
645
+ # Return the access level value
646
+ can_access = self._eval_user_record_access(_field=read_access_field, _record_id=record_id,
647
+ _record_access_data=record_access_data,
648
+ _raise_exc_on_failure=raise_exc_on_failure)
649
+
650
+ # Emit a warning if the value is None rather than a boolean
651
+ if can_access is None:
652
+ warn_msg = 'The record access check could not be completed and the function will return a None value'
653
+ errors.handlers.display_warning(warn_msg)
654
+
655
+ # Return the result
656
+ return can_access
657
+
658
+ def can_read_record(self, record_id, user_id=None, record_access_data=None, raise_exc_on_failure=True):
659
+ """This method evaluates if a user has access to read a specific record.
660
+
661
+ .. version-added:: 1.4.0
662
+
663
+ :param record_id: The ID of the record
664
+ :type record_id: str
665
+ :param user_id: The ID of the user to evaluate (defaults to the current/running user if not defined)
666
+ :type user_id: str, None
667
+ :param record_access_data: The user record access data that has already been retrieved (optional)
668
+ :type record_access_data: dict, None
669
+ :param raise_exc_on_failure: Raises an exception rather than returning a ``None`` value (``True`` by default)
670
+ :type raise_exc_on_failure: bool
671
+ :returns: Boolean value indicating the access level for the given field
672
+ :raises: :py:exc:`errors.exceptions.InvalidFieldError`
673
+ """
674
+ return self.can_access_record(access_type='read', record_id=record_id, user_id=user_id,
675
+ record_access_data=record_access_data,
676
+ raise_exc_on_failure=raise_exc_on_failure)
677
+
678
+ def can_edit_record(self, record_id, user_id=None, record_access_data=None, raise_exc_on_failure=True):
679
+ """This method evaluates if a user has access to edit a specific record.
680
+
681
+ .. version-added:: 1.4.0
682
+
683
+ :param record_id: The ID of the record
684
+ :type record_id: str
685
+ :param user_id: The ID of the user to evaluate (defaults to the current/running user if not defined)
686
+ :type user_id: str, None
687
+ :param record_access_data: The user record access data that has already been retrieved (optional)
688
+ :type record_access_data: dict, None
689
+ :param raise_exc_on_failure: Raises an exception rather than returning a ``None`` value (``True`` by default)
690
+ :type raise_exc_on_failure: bool
691
+ :returns: Boolean value indicating the access level for the given field
692
+ :raises: :py:exc:`errors.exceptions.InvalidFieldError`
693
+ """
694
+ return self.can_access_record(access_type='edit', record_id=record_id, user_id=user_id,
695
+ record_access_data=record_access_data,
696
+ raise_exc_on_failure=raise_exc_on_failure)
697
+
698
+ def can_delete_record(self, record_id, user_id=None, record_access_data=None, raise_exc_on_failure=True):
699
+ """This method evaluates if a user has access to delete a specific record.
700
+
701
+ .. version-added:: 1.4.0
702
+
703
+ :param record_id: The ID of the record
704
+ :type record_id: str
705
+ :param user_id: The ID of the user to evaluate (defaults to the current/running user if not defined)
706
+ :type user_id: str, None
707
+ :param record_access_data: The user record access data that has already been retrieved (optional)
708
+ :type record_access_data: dict, None
709
+ :param raise_exc_on_failure: Raises an exception rather than returning a ``None`` value (``True`` by default)
710
+ :type raise_exc_on_failure: bool
711
+ :returns: Boolean value indicating the access level for the given field
712
+ :raises: :py:exc:`errors.exceptions.InvalidFieldError`
713
+ """
714
+ return self.can_access_record(access_type='edit', record_id=record_id, user_id=user_id,
715
+ record_access_data=record_access_data,
716
+ raise_exc_on_failure=raise_exc_on_failure)
717
+
385
718
  def create_sobject_record(self, sobject, payload):
386
719
  """This method creates a new record for a specific sObject.
387
720
  (`Reference <https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/dome_sobject_create.htm>`_)
@@ -391,7 +724,8 @@ class Salesforce(object):
391
724
  :param payload: The JSON payload with the record details
392
725
  :type payload: dict
393
726
  :returns: The API response from the POST request
394
- :raises: :py:exc:`RuntimeError`, :py:exc:`TypeError`
727
+ :raises: :py:exc:`RuntimeError`,
728
+ :py:exc:`TypeError`
395
729
  """
396
730
  # Ensure the payload is in the appropriate format
397
731
  if not isinstance(payload, dict):
@@ -412,7 +746,8 @@ class Salesforce(object):
412
746
  :param payload: The JSON payload with the record details to be updated
413
747
  :type payload: dict
414
748
  :returns: The API response from the PATCH request
415
- :raises: :py:exc:`RuntimeError`, :py:exc:`TypeError`
749
+ :raises: :py:exc:`RuntimeError`,
750
+ :py:exc:`TypeError`
416
751
  """
417
752
  # Ensure the payload is in the appropriate format
418
753
  if not isinstance(payload, dict):
@@ -596,7 +931,8 @@ class Salesforce(object):
596
931
  :param return_uri: Determines if the URI of the article should be returned rather than the ID (``False`` by default)
597
932
  :type return_uri: bool
598
933
  :returns: The Article ID or Article URI, or a blank string if no article is found
599
- :raises: :py:exc:`ValueError`
934
+ :raises: :py:exc:`ValueError`,
935
+ :py:exc:`RuntimeError`
600
936
  """
601
937
  return knowledge_module.get_article_id_from_number(self.sfdc_object, article_number=article_number,
602
938
  sobject=sobject, return_uri=return_uri)
@@ -616,6 +952,7 @@ class Salesforce(object):
616
952
  :param page_num: The starting page number (``1`` by default)
617
953
  :type page_num: int
618
954
  :returns: The list of retrieved knowledge articles
955
+ :raises: :py:exc:`RuntimeError`
619
956
  """
620
957
  return knowledge_module.get_articles_list(self.sfdc_object, query=query, sort=sort, order=order,
621
958
  page_size=page_size, page_num=page_num)
@@ -629,6 +966,7 @@ class Salesforce(object):
629
966
  :param sobject: The Salesforce object to query (``Knowledge__kav`` by default)
630
967
  :type sobject: str, None
631
968
  :returns: The details for the knowledge article
969
+ :raises: :py:exc:`RuntimeError`
632
970
  """
633
971
  return knowledge_module.get_article_details(self.sfdc_object, article_id=article_id, sobject=sobject)
634
972
 
@@ -680,7 +1018,8 @@ class Salesforce(object):
680
1018
  :param sobject: The Salesforce object to query (``Knowledge__kav`` by default)
681
1019
  :type sobject: str, None
682
1020
  :returns: The article URL as a string
683
- :raises: :py:exc:`ValueError`
1021
+ :raises: :py:exc:`ValueError`,
1022
+ :py:exc:`RuntimeError`
684
1023
  """
685
1024
  return knowledge_module.get_article_url(self.sfdc_object, article_id=article_id,
686
1025
  article_number=article_number, sobject=sobject)
@@ -696,7 +1035,9 @@ class Salesforce(object):
696
1035
  :param full_response: Determines if the full API response should be returned instead of the article ID (``False`` by default)
697
1036
  :type full_response: bool
698
1037
  :returns: The API response or the ID of the article draft
699
- :raises: :py:exc:`ValueError`, :py:exc:`TypeError`
1038
+ :raises: :py:exc:`ValueError`,
1039
+ :py:exc:`TypeError`,
1040
+ :py:exc:`RuntimeError`
700
1041
  """
701
1042
  return knowledge_module.create_article(self.sfdc_object, article_data=article_data, sobject=sobject,
702
1043
  full_response=full_response)
@@ -714,7 +1055,9 @@ class Salesforce(object):
714
1055
  :param include_status_code: Determines if the API response status code should be returned (``False`` by default)
715
1056
  :type include_status_code: bool
716
1057
  :returns: A Boolean indicating if the update operation was successful, and optionally the API response status code
717
- :raises: :py:exc:`ValueError`, :py:exc:`TypeError`, :py:exc:`RuntimeError`
1058
+ :raises: :py:exc:`ValueError`,
1059
+ :py:exc:`TypeError`,
1060
+ :py:exc:`RuntimeError`
718
1061
  """
719
1062
  return knowledge_module.update_article(self.sfdc_object, record_id=record_id, article_data=article_data,
720
1063
  sobject=sobject, include_status_code=include_status_code)
@@ -781,7 +1124,9 @@ class Salesforce(object):
781
1124
  :param major_version: Determines if the published article should be a major version (``True`` by default)
782
1125
  :type major_version: bool
783
1126
  :returns: The API response from the POST request
784
- :raises: :py:exc:`RuntimeError`, :py:exc:`TypeError`, :py:exc:`ValueError`
1127
+ :raises: :py:exc:`RuntimeError`,
1128
+ :py:exc:`TypeError`,
1129
+ :py:exc:`ValueError`
785
1130
  """
786
1131
  return knowledge_module.publish_multiple_articles(self.sfdc_object, article_id_list=article_id_list,
787
1132
  major_version=major_version)
@@ -790,7 +1135,7 @@ class Salesforce(object):
790
1135
  """This method assigns a single data category for a knowledge article.
791
1136
  (`Reference <https://itsmemohit.medium.com/quick-win-15-salesforce-knowledge-rest-apis-bb0725b2040e>`_)
792
1137
 
793
- .. versionadded:: 1.2.0
1138
+ .. version-added:: 1.2.0
794
1139
 
795
1140
  :param article_id: The ID of the article to update
796
1141
  :type article_id: str
@@ -809,7 +1154,7 @@ class Salesforce(object):
809
1154
  """This function archives a published knowledge article.
810
1155
  (`Reference <https://developer.salesforce.com/docs/atlas.en-us.knowledge_dev.meta/knowledge_dev/knowledge_REST_archive_master_version.htm>`_)
811
1156
 
812
- .. versionadded:: 1.3.0
1157
+ .. version-added:: 1.3.0
813
1158
 
814
1159
  :param article_id: The ID of the article to archive
815
1160
  :type article_id: str
@@ -818,6 +1163,23 @@ class Salesforce(object):
818
1163
  """
819
1164
  return knowledge_module.archive_article(self.sfdc_object, article_id=article_id)
820
1165
 
1166
+ def delete_article_draft(self, version_id: str, use_knowledge_management_endpoint: bool = True):
1167
+ """This function deletes an unpublished knowledge article draft.
1168
+
1169
+ .. version-added:: 1.4.0
1170
+
1171
+ :param version_id: The 15-character or 18-character ``Id`` (Knowledge Article Version ID) value
1172
+ :type version_id: str
1173
+ :param use_knowledge_management_endpoint: Leverage the ``/knowledgeManagement/articleVersions/masterVersions/``
1174
+ endpoint rather than the ``/sobjects/Knowledge__kav/`` endpoint
1175
+ (``True`` by default)
1176
+ :type use_knowledge_management_endpoint: bool
1177
+ :returns: The API response from the DELETE request
1178
+ :raises: :py:exc:`RuntimeError`
1179
+ """
1180
+ return knowledge_module.delete_article_draft(self.sfdc_object, version_id=version_id,
1181
+ use_knowledge_management_endpoint=use_knowledge_management_endpoint)
1182
+
821
1183
 
822
1184
  def define_connection_info():
823
1185
  """This function prompts the user for the connection information.
@@ -0,0 +1,53 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ :Module: salespyforce.decorators
4
+ :Synopsis: Decorators that can be used to include additional functionality with functions and methods
5
+ :Created By: Jeff Shurtliff
6
+ :Last Modified: Jeff Shurtliff
7
+ :Modified Date: 02 Feb 2026
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import functools
13
+ import warnings
14
+ from typing import Any, Callable, Optional, Type, TypeVar
15
+
16
+ # Define the function Type bound to Callable
17
+ F = TypeVar("F", bound=Callable[..., Any])
18
+
19
+
20
+ def deprecated(
21
+ *,
22
+ since: str,
23
+ replacement: Optional[str] = None,
24
+ removal: Optional[str] = None,
25
+ category: Type[Warning] = DeprecationWarning,
26
+ stacklevel: int = 2,
27
+ ) -> Callable[[F], F]:
28
+ """This decorator marks a callable as deprecated and emits a warning at runtime.
29
+
30
+ .. version-added:: 1.4.0
31
+
32
+ :param since: Version when deprecation started
33
+ :param replacement: Suggested replacement usage (string)
34
+ :param removal: Version when it will be removed (optional)
35
+ :param category: Warning category (default: DeprecationWarning)
36
+ :param stacklevel: Warning stacklevel (default: 2)
37
+ """
38
+ def decorator(func: F) -> F:
39
+ message_parts = [f"{func.__name__} is deprecated since {since}."]
40
+ if replacement:
41
+ message_parts.append(f"Use {replacement} instead.")
42
+ if removal:
43
+ message_parts.append(f"It will be removed in {removal}.")
44
+ message = " ".join(message_parts)
45
+
46
+ @functools.wraps(func)
47
+ def wrapper(*args: Any, **kwargs: Any):
48
+ warnings.warn(message, category=category, stacklevel=stacklevel)
49
+ return func(*args, **kwargs)
50
+
51
+ return wrapper # type: ignore[return-value]
52
+
53
+ return decorator
@@ -4,12 +4,50 @@
4
4
  :Synopsis: Functions that handle various error situations within the namespace
5
5
  :Created By: Jeff Shurtliff
6
6
  :Last Modified: Jeff Shurtliff
7
- :Modified Date: 22 Feb 2023
7
+ :Modified Date: 02 Feb 2026
8
8
  """
9
9
 
10
+ from __future__ import annotations
11
+
10
12
  import sys
13
+ from typing import Final
14
+ import warnings
15
+
16
+ # Define constants
17
+ _DEFAULT_CATEGORY: Final[type[Warning]] = UserWarning
11
18
 
12
19
 
13
- def eprint(*args, **kwargs):
20
+ def eprint(*args, **kwargs) -> None:
14
21
  """This function behaves the same as the ``print()`` function but is leveraged to print errors to ``sys.stderr``."""
15
22
  print(*args, file=sys.stderr, **kwargs)
23
+
24
+
25
+ def get_exception_type(exc) -> str:
26
+ """This function returns the exception type (e.g. ``RuntimeError``, ``TypeError``, etc.) for a given exception.
27
+
28
+ .. version-added:: 1.4.0
29
+
30
+ :returns: The exception type as a string
31
+ """
32
+ return type(exc).__name__
33
+
34
+
35
+ def display_warning(
36
+ message: str,
37
+ *,
38
+ category: type[Warning] = _DEFAULT_CATEGORY,
39
+ stacklevel: int = 2,
40
+ ) -> None:
41
+ """This function emits a warning that points to the caller by default.
42
+
43
+ .. version-added:: 1.4.0
44
+
45
+ :param message: Warning message to emit
46
+ :type message: str
47
+ :param category: Warning category class (default: ``UserWarning``)
48
+ :type category: type[Warning]
49
+ :param stacklevel: How far up the call stack to attribute the warning (``2`` by default - caller of this helper)
50
+ :type stacklevel: int
51
+ :returns: None
52
+ """
53
+ warnings.warn(message, category=category, stacklevel=stacklevel)
salespyforce/knowledge.py CHANGED
@@ -4,7 +4,7 @@
4
4
  :Synopsis: Defines the Knowledge-related functions associated with the Salesforce API
5
5
  :Created By: Jeff Shurtliff
6
6
  :Last Modified: Jeff Shurtliff
7
- :Modified Date: 14 Nov 2023
7
+ :Modified Date: 03 Feb 2026
8
8
  """
9
9
 
10
10
  from . import errors
@@ -20,7 +20,7 @@ def check_for_existing_article(sfdc_object, title, sobject=None, return_id=False
20
20
  (`Reference 1 <https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/dome_query.htm>`_,
21
21
  `Reference 2 <https://developer.salesforce.com/docs/atlas.en-us.knowledge_dev.meta/knowledge_dev/knowledge_development_soql_sosl_intro.htm>`_)
22
22
 
23
- .. versionchanged:: 1.2.2
23
+ .. version-changed:: 1.2.2
24
24
  You can now specify whether archived articles are included in the query results.
25
25
 
26
26
  :param sfdc_object: The instantiated SalesPyForce object
@@ -230,7 +230,7 @@ def get_article_version(sfdc_object, article_id):
230
230
  def get_article_url(sfdc_object, article_id=None, article_number=None, sobject=None):
231
231
  """This function constructs the URL to view a knowledge article in Lightning or Classic.
232
232
 
233
- .. versionchanged:: 1.2.0
233
+ .. version-changed:: 1.2.0
234
234
  Changed when lightning URLs are defined and fixed an issue with extraneous slashes.
235
235
 
236
236
  :param sfdc_object: The instantiated SalesPyForce object
@@ -483,7 +483,7 @@ def assign_data_category(sfdc_object, article_id, category_group_name, category_
483
483
  """This function assigns a single data category for a knowledge article.
484
484
  (`Reference <https://itsmemohit.medium.com/quick-win-15-salesforce-knowledge-rest-apis-bb0725b2040e>`_)
485
485
 
486
- .. versionadded:: 1.2.0
486
+ .. version-added:: 1.2.0
487
487
 
488
488
  :param sfdc_object: The instantiated SalesPyForce object
489
489
  :type sfdc_object: class[salespyforce.Salesforce]
@@ -512,7 +512,7 @@ def archive_article(sfdc_object, article_id):
512
512
  """This function archives a published knowledge article.
513
513
  (`Reference <https://developer.salesforce.com/docs/atlas.en-us.knowledge_dev.meta/knowledge_dev/knowledge_REST_archive_master_version.htm>`_)
514
514
 
515
- .. versionadded:: 1.3.0
515
+ .. version-added:: 1.3.0
516
516
 
517
517
  :param sfdc_object: The instantiated SalesPyForce object
518
518
  :type sfdc_object: class[salespyforce.Salesforce]
@@ -529,3 +529,26 @@ def archive_article(sfdc_object, article_id):
529
529
  # Perform the API call
530
530
  endpoint = f'/services/data/{sfdc_object.version}/knowledgeManagement/articleVersions/masterVersions/{article_id}'
531
531
  return sfdc_object.patch(endpoint, payload)
532
+
533
+
534
+ def delete_article_draft(sfdc_object, version_id: str, use_knowledge_management_endpoint: bool = True):
535
+ """This function deletes an unpublished knowledge article draft.
536
+
537
+ .. version-added:: 1.4.0
538
+
539
+ :param sfdc_object: The instantiated SalesPyForce object
540
+ :type sfdc_object: class[salespyforce.Salesforce]
541
+ :param version_id: The 15-character or 18-character ``Id`` (Knowledge Article Version ID) value
542
+ :type version_id: str
543
+ :param use_knowledge_management_endpoint: Leverage the ``/knowledgeManagement/articleVersions/masterVersions/``
544
+ endpoint rather than the ``/sobjects/Knowledge__kav/`` endpoint
545
+ (``True`` by default)
546
+ :type use_knowledge_management_endpoint: bool
547
+ :returns: The API response from the DELETE request
548
+ :raises: :py:exc:`RuntimeError`
549
+ """
550
+ if use_knowledge_management_endpoint:
551
+ endpoint = f'/services/data/{sfdc_object.version}/knowledgeManagement/articleVersions/masterVersions/{version_id}'
552
+ else:
553
+ endpoint = f'/services/data/{sfdc_object.version}/sobjects/Knowledge__kav/{version_id}'
554
+ return sfdc_object.delete(endpoint)
@@ -6,9 +6,10 @@
6
6
  :Example: ``encoded_string = core_utils.encode_url(decoded_string)``
7
7
  :Created By: Jeff Shurtliff
8
8
  :Last Modified: Jeff Shurtliff
9
- :Modified Date: 29 May 2023
9
+ :Modified Date: 02 Feb 2026
10
10
  """
11
11
 
12
+ import re
12
13
  import random
13
14
  import string
14
15
  import os.path
@@ -19,10 +20,15 @@ import requests
19
20
 
20
21
  from . import log_utils
21
22
  from .. import errors
23
+ from ..decorators import deprecated
22
24
 
23
25
  # Initialize the logger for this module
24
26
  logger = log_utils.initialize_logging(__name__)
25
27
 
28
+ # Define constants
29
+ SALESFORCE_ID_SUFFIX_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ012345'
30
+ VALID_SALESFORCE_URL_PATTERN = r'^https://[a-zA-Z0-9._-]+\.salesforce\.com(/|$)'
31
+
26
32
 
27
33
  def url_encode(raw_string):
28
34
  """This function encodes a string for use in URLs.
@@ -44,9 +50,13 @@ def url_decode(encoded_string):
44
50
  return urllib.parse.unquote_plus(encoded_string)
45
51
 
46
52
 
53
+ @deprecated(since='1.4.0', replacement='salespyforce.errors.handlers.display_warning', removal='2.0.0')
47
54
  def display_warning(warn_msg):
48
55
  """This function displays a :py:exc:`UserWarning` message via the :py:mod:`warnings` module.
49
56
 
57
+ .. deprecated:: 1.4.0
58
+ Use :py:func:`salespyforce.errors.handlers.display_warning` instead.
59
+
50
60
  :param warn_msg: The message to be displayed
51
61
  :type warn_msg: str
52
62
  :returns: None
@@ -60,7 +70,8 @@ def get_file_type(file_path):
60
70
  :param file_path: The full path to the file
61
71
  :type file_path: str
62
72
  :returns: The file type in string format (e.g. ``yaml`` or ``json``)
63
- :raises: :py:exc:`FileNotFoundError`, :py:exc:`khoros.errors.exceptions.UnknownFileTypeError`
73
+ :raises: :py:exc:`FileNotFoundError`,
74
+ :py:exc:`salespyforce.errors.exceptions.UnknownFileTypeError`
64
75
  """
65
76
  file_type = 'unknown'
66
77
  if os.path.isfile(file_path):
@@ -97,6 +108,79 @@ def get_random_string(length=32, prefix_string=""):
97
108
  return f"{prefix_string}{''.join([random.choice(string.ascii_letters + string.digits) for _ in range(length)])}"
98
109
 
99
110
 
111
+ def get_18_char_id(record_id: str) -> str:
112
+ """This function converts a 15-character Salesforce record ID to its 18-character case-insensitive form.
113
+
114
+ .. version-added:: 1.4.0
115
+
116
+ :param record_id: The Salesforce record ID to convert (or return unchanged if already 18 characters)
117
+ :type record_id: str
118
+ :returns: The 18-character Salesforce record ID
119
+ :raises: :py:exc:`ValueError`
120
+ """
121
+ # Ensure the provided record ID is a string
122
+ if not isinstance(record_id, str):
123
+ raise ValueError("Salesforce ID must be a string")
124
+
125
+ # Return the record ID unchanged if it is already 18 characters in length
126
+ if len(record_id) == 18:
127
+ return record_id
128
+
129
+ # Ensure the record ID is a valid 15-character value
130
+ if len(record_id) != 15:
131
+ raise ValueError("Salesforce ID must be 15 or 18 characters long")
132
+
133
+ # Define the checksum suffix (additional 3 characters)
134
+ suffix = ""
135
+ for i in range(0, 15, 5):
136
+ chunk = record_id[i:i + 5]
137
+ bitmask = 0
138
+
139
+ for index, char in enumerate(chunk):
140
+ if "A" <= char <= "Z":
141
+ bitmask |= 1 << index
142
+
143
+ suffix += SALESFORCE_ID_SUFFIX_ALPHABET[bitmask]
144
+
145
+ # Return the 18-character ID value
146
+ return record_id + suffix
147
+
148
+
149
+ def matches_regex_pattern(pattern: str, text: str, full_match: bool = False, must_start_with: bool = False) -> bool:
150
+ """This function compares a text string against a regex pattern and determines whether they match.
151
+
152
+ .. version-added:: 1.4.0
153
+
154
+ :param pattern: The regex pattern that should match
155
+ :type pattern: str
156
+ :param text: The text string to evaluate
157
+ :type text: str
158
+ :param full_match: Determines if the entire string should be validated
159
+ :type full_match: bool
160
+ :param must_start_with: Determines if the pattern must be at the beginning of the string
161
+ :returns: True if the regex pattern matches anywhere in the text string
162
+ :raises: :py:exc:`TypeError`
163
+ """
164
+ if full_match:
165
+ return bool(re.fullmatch(pattern, text))
166
+ elif must_start_with:
167
+ return bool(re.match(pattern, text))
168
+ else:
169
+ return bool(re.search(pattern, text))
170
+
171
+
172
+ def is_valid_salesforce_url(url: str) -> bool:
173
+ """This function evaluates a URL to determine if it is a valid Salesforce URL.
174
+
175
+ .. version-added:: 1.4.0
176
+
177
+ :param url: The URL to evaluate
178
+ :type url: str
179
+ :returns: Boolean value depending on whether the URL meets the criteria
180
+ """
181
+ return True if isinstance(url, str) and matches_regex_pattern(VALID_SALESFORCE_URL_PATTERN, url) else False
182
+
183
+
100
184
  def get_image_ref_id(image_url):
101
185
  """This function parses an image URL to identify the reference ID (refid) value.
102
186
  (`Reference 1 <https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_sobject_rich_text_image_retrieve.htm>`_,
@@ -121,7 +121,7 @@ def _set_logging_level(_logger, _log_level):
121
121
  """This function sets the logging level for a :py:class:`logging.Logger` instance.
122
122
 
123
123
  :param _logger: The :py:class:`logging.Logger` instance
124
- :type _logger: Logger
124
+ :type _logger: class[logging.Logger]
125
125
  :param _log_level: The log level as a string (``debug``, ``info``, ``warning``, ``error`` or ``critical``)
126
126
  :type _log_level: str
127
127
  :returns: The :py:class:`logging.Logger` instance with a logging level set where applicable
@@ -162,7 +162,7 @@ def _add_file_handler(_logger, _log_level, _log_file, _overwrite, _formatter):
162
162
  """This function adds a :py:class:`logging.FileHandler` to the :py:class:`logging.Logger` instance.
163
163
 
164
164
  :param _logger: The :py:class:`logging.Logger` instance
165
- :type _logger: Logger
165
+ :type _logger: class[logging.Logger]
166
166
  :param _log_level: The log level to set for the handler
167
167
  :type _log_level: str
168
168
  :param _log_file: The log file (as a file name or a file path) to which messages should be written
@@ -174,7 +174,7 @@ def _add_file_handler(_logger, _log_level, _log_file, _overwrite, _formatter):
174
174
  :param _overwrite: Determines if messages should be appended to the file (default) or overwrite it
175
175
  :type _overwrite: bool
176
176
  :param _formatter: The :py:class:`logging.Formatter` to apply to messages passed through the handler
177
- :type _formatter: Formatter
177
+ :type _formatter: class[logging.Formatter]
178
178
  :returns: The :py:class:`logging.Logger` instance with the added :py:class:`logging.FileHandler`
179
179
  """
180
180
  # Define the log file to use
@@ -203,11 +203,11 @@ def _add_stream_handler(_logger, _log_level, _formatter):
203
203
  """This function adds a :py:class:`logging.StreamHandler` to the :py:class:`logging.Logger` instance.
204
204
 
205
205
  :param _logger: The :py:class:`logging.Logger` instance
206
- :type _logger: Logger
206
+ :type _logger: class[logging.Logger]
207
207
  :param _log_level: The log level to set for the handler
208
208
  :type _log_level: str
209
209
  :param _formatter: The :py:class:`logging.Formatter` to apply to messages passed through the handler
210
- :type _formatter: Formatter
210
+ :type _formatter: class[logging.Formatter]
211
211
  :returns: The :py:class:`logging.Logger` instance with the added :py:class:`logging.StreamHandler`
212
212
  """
213
213
  _log_level = HANDLER_DEFAULTS.get('console_log_level') if not _log_level else _log_level
@@ -229,11 +229,11 @@ def _add_split_stream_handlers(_logger, _log_level, _formatter):
229
229
  more information on how this filtering is implemented and for credit to the original author.
230
230
 
231
231
  :param _logger: The :py:class:`logging.Logger` instance
232
- :type _logger: Logger
232
+ :type _logger: class[logging.Logger]
233
233
  :param _log_level: The log level provided for the stream handler (i.e. console output)
234
234
  :type _log_level: str
235
235
  :param _formatter: The :py:class:`logging.Formatter` to apply to messages passed through the handlers
236
- :type _formatter: Formatter
236
+ :type _formatter: class[logging.Formatter]
237
237
  :returns: The logger instance with the two handlers added
238
238
  """
239
239
  # Configure and add the STDOUT handler
@@ -5,7 +5,7 @@
5
5
  :Synopsis: This module is used by pytest to test core utility functions
6
6
  :Created By: Jeff Shurtliff
7
7
  :Last Modified: Jeff Shurtliff
8
- :Modified Date: 20 Dec 2025
8
+ :Modified Date: 30 Jan 2026
9
9
  """
10
10
 
11
11
  import os
@@ -115,6 +115,45 @@ def test_get_random_string_returns_expected_length(monkeypatch):
115
115
  assert all(char in alphabet for char in result.replace("pre_", ""))
116
116
 
117
117
 
118
+ def test_converts_15_char_id_to_18_char():
119
+ """This function tests the conversion of a 15-character Salesforce ID into the 18-character equivalent.
120
+
121
+ .. version-added:: 1.4.0
122
+ """
123
+ id_15 = "ka4PO0000002hby"
124
+ id_18 = core_utils.get_18_char_id(id_15)
125
+
126
+ assert len(id_18) == 18
127
+ assert id_18.startswith(id_15)
128
+
129
+
130
+ def test_returns_18_char_id_unchanged():
131
+ """This function tests that an 18-character Salesforce ID is returned unchanged during conversion attempt.
132
+
133
+ .. version-added:: 1.4.0
134
+ """
135
+ id_18 = "ka4PO0000002hbyYAA"
136
+ assert core_utils.get_18_char_id(id_18) == id_18
137
+
138
+
139
+ def test_invalid_id_length_raises_error():
140
+ """This function tests to ensure passing an invalid Salesforce ID length raises an exception.
141
+
142
+ .. version-added:: 1.4.0
143
+ """
144
+ with pytest.raises(ValueError):
145
+ core_utils.get_18_char_id("short")
146
+
147
+
148
+ def test_non_string_id_input_raises_error():
149
+ """This function tests to ensure passing a non-string to the get_18_char_id function raises an exception.
150
+
151
+ .. version-added:: 1.4.0
152
+ """
153
+ with pytest.raises(ValueError):
154
+ core_utils.get_18_char_id(12345)
155
+
156
+
118
157
  def test_get_image_ref_id_parses_query_param():
119
158
  """This function tests get_image_ref_id parsing.
120
159
 
@@ -22,7 +22,7 @@ def _cleanup_logger(logger: logging.Logger) -> None:
22
22
  .. version-added:: 1.4.0
23
23
 
24
24
  :param logger: The logger instance to clean up
25
- :type logger: logging.Logger
25
+ :type logger: class[logging.Logger]
26
26
  :returns: None
27
27
  """
28
28
  for handler in list(logger.handlers):
@@ -51,7 +51,7 @@ def test_initialize_logging_applies_default_level_to_console_handler(caplog: pyt
51
51
  .. version-added:: 1.4.0
52
52
 
53
53
  :param caplog: Pytest fixture capturing log records for assertions
54
- :type caplog: pytest.LogCaptureFixture
54
+ :type caplog: class[pytest.LogCaptureFixture]
55
55
  :returns: None
56
56
  """
57
57
  logger_name = "salespyforce.test.console.default"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: salespyforce
3
- Version: 1.4.0.dev1
3
+ Version: 1.4.0rc0
4
4
  Summary: A Python toolset for performing Salesforce API calls
5
5
  License: MIT License
6
6
 
@@ -63,7 +63,7 @@ A Python toolset for performing Salesforce API calls
63
63
  <td>Latest Beta/RC Release</td>
64
64
  <td>
65
65
  <a href='https://pypi.org/project/salespyforce/#history'>
66
- <img alt="PyPI" src="https://img.shields.io/badge/pypi-1.4.0.dev1-blue">
66
+ <img alt="PyPI" src="https://img.shields.io/badge/pypi-1.4.0.dev2-blue">
67
67
  </a>
68
68
  </td>
69
69
  </tr>
@@ -151,14 +151,6 @@ The package can be installed via pip using the syntax below.
151
151
  pip install salespyforce --upgrade
152
152
  ```
153
153
 
154
- You may also clone the repository and install from source using below.
155
-
156
- ```sh
157
- git clone git://github.com/jeffshurtliff/salespyforce.git
158
- cd salespyforce/
159
- python setup.py install
160
- ```
161
-
162
154
  ## Change Log
163
155
  The change log can be found in the [documentation](https://salespyforce.readthedocs.io/en/latest/changelog.html).
164
156
 
@@ -1,27 +1,28 @@
1
1
  salespyforce/__init__.py,sha256=W2RY2_kojLcXtTHJrto5FtU6q1umVPaU1RZe_QkFgNY,2031
2
- salespyforce/api.py,sha256=JoqYyOo7Psx-l-bEC7iFdFfQVziIkqINcOKOnlpa_jM,5763
2
+ salespyforce/api.py,sha256=NdvPUqJ5HrcaEBaHte414eNOgwIaNweSYxz3E6-zWQE,9403
3
3
  salespyforce/chatter.py,sha256=GI9iXmq2mrbrH7daW8Kc3gt2O04dRthSVocJq4BVwCU,7494
4
- salespyforce/core.py,sha256=OPn5TsY9oCjg_jtEFWCq3yQz4-B7pTeYKsSGOtfo8yI,49148
4
+ salespyforce/core.py,sha256=LW0ImVoN67E78FpmqW9BWHQ7JdNKKFM34Qtjv7oP7WI,68099
5
+ salespyforce/decorators.py,sha256=wSUXM6hXaXwLrD9Gzn0JBj_efHWyJ4n74qg1mel_tcQ,1742
5
6
  salespyforce/errors/__init__.py,sha256=Tnl4lB2ycpK2UmwmoBoSYuWDtovDNA4CxC5Cku2hDYs,320
6
7
  salespyforce/errors/exceptions.py,sha256=6ATDNCPNXnjOzXcaaLZVUy39lsFt2Se5cCvqkd_8faM,17057
7
- salespyforce/errors/handlers.py,sha256=8Hp-WrpYooMlC7cuuowTkOFyBH8OQFfHKmB8XxDPlyk,479
8
- salespyforce/knowledge.py,sha256=YCUbVDr8n23mdUzeuqlNToHIaaD9mzcSZ5Rvr_J8kAw,24770
8
+ salespyforce/errors/handlers.py,sha256=Ykap4LBUTKQSbbAb7OMpWGfwvIx_Rl5aDtgAQ1I9ovk,1556
9
+ salespyforce/knowledge.py,sha256=7TQcPWvHTGUhTUsky9tvOPDCsomaVgahg6gxwMjmdP8,25983
9
10
  salespyforce/utils/__init__.py,sha256=ATE6BsWd2qDxsy1fQpvaoSJimZgfCmNPYpcFEucES6Q,292
10
- salespyforce/utils/core_utils.py,sha256=ESuQVb8L00KoP56_nAKTj3i09u_6BqK0MxJtYV5QbwI,5674
11
+ salespyforce/utils/core_utils.py,sha256=Q-xtBXVkuK50Ps9lLMZuGfFrSM0JxroN_6jcZkyaK6k,8690
11
12
  salespyforce/utils/helper.py,sha256=zwbysqwddWwhp3mfzKJf0aeTTkVqoR35L9dLu6ZVJtU,5661
12
- salespyforce/utils/log_utils.py,sha256=zLQUljJiKU2x8YPG5mo5b5vXAwFLB8DcbrRUOJU14Qw,11831
13
+ salespyforce/utils/log_utils.py,sha256=-7qgSNCUQRstRgz2wXBX6LRWO-GMjIXS5WjxkHGvBHk,11936
13
14
  salespyforce/utils/tests/__init__.py,sha256=Iv7M9gPyKv5f5tR-pxc089cd2nrWn5M0ZZpWqiB2htc,242
14
15
  salespyforce/utils/tests/conftest.py,sha256=zYB5pYNtRHc6cIxY_cBMQHNM52Is6NjLfcCywCD3-Bw,6661
15
16
  salespyforce/utils/tests/resources.py,sha256=-PEqzLMsEQSVj3dz_8VTHXINbSeMvmjX3gZRMTJgoac,4774
16
- salespyforce/utils/tests/test_core_utils.py,sha256=C8oqr4hQkkAEi36lgbtpwMd6HOoaPXgKVwGA51nIf4w,6132
17
+ salespyforce/utils/tests/test_core_utils.py,sha256=8b3qjzr028wVeQAxBvIeajcqnWCsqUhNgqcquznGQKg,7272
17
18
  salespyforce/utils/tests/test_instantiate_object.py,sha256=O_DTljj_QCBZlDiA1KBUmSL2CZfsVU_7K2h6ypEN0D8,2002
18
- salespyforce/utils/tests/test_log_utils.py,sha256=saUmCNuDUXipfW5YWFtxraLNIqHIl3HwcTU4u9JCBkw,2314
19
+ salespyforce/utils/tests/test_log_utils.py,sha256=FxPC21vXRQbFvqmTi_FL1DsGwuH8JHPkl9XytVoe7uY,2328
19
20
  salespyforce/utils/tests/test_sobjects.py,sha256=AeKli_LjSkeaspGxHZoZsM1IafKS2HkJ1wzajSXt6Dc,2120
20
21
  salespyforce/utils/tests/test_soql.py,sha256=SSOAN-4QZNpwUON-cLndb5W6AAs28Ja1u-Zt1WBx0VA,776
21
22
  salespyforce/utils/tests/test_sosl.py,sha256=3Ny1uF0onjcvI_InfMYRdh31rR5MJADTB3DWSm--EjE,868
22
23
  salespyforce/utils/tests/test_version_utils.py,sha256=njRKMiwtCCyUHEAFvfxvavuh03fJ_EupM1TUYmdsZAo,2465
23
24
  salespyforce/utils/version.py,sha256=uEWnq3DHmN9wZVohiRCRE1KzNF1XVhP4oVY4vBBgoVU,1781
24
- salespyforce-1.4.0.dev1.dist-info/LICENSE,sha256=b_Ch0pvcZN6Mr4dQ9Bo3It-J3MihQYcj_-EIdaZHuWw,1071
25
- salespyforce-1.4.0.dev1.dist-info/METADATA,sha256=ZbUy8c8YPOSLSRL3fQvyZ3KdYQtwR1Hjhv_wyR835w4,10582
26
- salespyforce-1.4.0.dev1.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
27
- salespyforce-1.4.0.dev1.dist-info/RECORD,,
25
+ salespyforce-1.4.0rc0.dist-info/LICENSE,sha256=b_Ch0pvcZN6Mr4dQ9Bo3It-J3MihQYcj_-EIdaZHuWw,1071
26
+ salespyforce-1.4.0rc0.dist-info/METADATA,sha256=uLUbEpNW2OnNfbWXak8I2oXfeQxggGHD2WS2Xl619fM,10398
27
+ salespyforce-1.4.0rc0.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
28
+ salespyforce-1.4.0rc0.dist-info/RECORD,,