salespyforce 1.4.0.dev2__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: 29 Jan 2026
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:
@@ -143,14 +151,14 @@ def delete(sfdc_object, endpoint, params=None, headers=None, timeout=30, show_fu
143
151
  default_headers = _get_headers(sfdc_object.access_token)
144
152
  headers = default_headers if not headers else headers
145
153
 
146
- # Make sure the endpoint begins with a slash
147
- endpoint = f'/{endpoint}' if not endpoint.startswith('/') else endpoint
154
+ # Construct the request URL
155
+ url = _construct_full_query_url(endpoint, sfdc_object.instance_url)
148
156
 
149
157
  # Perform the API call
150
- response = requests.delete(f'{sfdc_object.instance_url}{endpoint}', headers=headers, params=params,
151
- timeout=timeout)
158
+ response = requests.delete(url, headers=headers, params=params, timeout=timeout)
152
159
  if response.status_code >= 300:
153
160
  if show_full_error:
161
+ # TODO: Functionalize this segment and figure out how to improve on the approach somehow
154
162
  raise RuntimeError(f'The DELETE request failed with a {response.status_code} status code.\n'
155
163
  f'{response.text}')
156
164
  else:
@@ -170,3 +178,36 @@ def _get_headers(_access_token, _header_type='default'):
170
178
  if _header_type == 'articles':
171
179
  headers['accept-language'] = 'en-US'
172
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: 30 Jan 2026
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__)
@@ -95,8 +96,9 @@ class Salesforce(object):
95
96
  # Get the connection information used to connect to the instance
96
97
  self.connection_info = connection_info if connection_info is not None else self._get_empty_connection_info()
97
98
 
98
- # Define the base URL value
99
- 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', '')
100
102
 
101
103
  # Define the connection response data variables
102
104
  auth_response = self.connect()
@@ -107,6 +109,9 @@ class Salesforce(object):
107
109
  # Define the version with explicitly provided version or by querying the Salesforce org
108
110
  self.version = f'v{version}' if version else f'v{self.get_latest_api_version()}'
109
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
+
110
115
  # Import inner object classes so their methods can be called from the primary object
111
116
  self.chatter = self._import_chatter_class()
112
117
  self.knowledge = self._import_knowledge_class()
@@ -143,6 +148,34 @@ class Salesforce(object):
143
148
  """This method returns the appropriate HTTP headers to use for different types of API calls."""
144
149
  return api._get_headers(_access_token=self.access_token, _header_type=_header_type)
145
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
+
146
179
  def connect(self):
147
180
  """This method connects to the Salesforce instance to obtain the access token.
148
181
  (`Reference <https://jereze.com/code/authentification-salesforce-rest-api-python/>`_)
@@ -162,6 +195,51 @@ class Salesforce(object):
162
195
  raise RuntimeError(f'Failed to connect to the Salesforce instance.\n{response.text}')
163
196
  return response.json()
164
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
+
165
243
  def get(self, endpoint, params=None, headers=None, timeout=30, show_full_error=True, return_json=True):
166
244
  """This method performs a GET request against the Salesforce instance.
167
245
  (`Reference <https://jereze.com/code/authentification-salesforce-rest-api-python/>`_)
@@ -439,6 +517,204 @@ class Salesforce(object):
439
517
  query = core_utils.url_encode(query)
440
518
  return self.get(f'/services/data/{self.version}/search/?q={query}')
441
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
+
442
718
  def create_sobject_record(self, sobject, payload):
443
719
  """This method creates a new record for a specific sObject.
444
720
  (`Reference <https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/dome_sobject_create.htm>`_)
@@ -887,17 +1163,22 @@ class Salesforce(object):
887
1163
  """
888
1164
  return knowledge_module.archive_article(self.sfdc_object, article_id=article_id)
889
1165
 
890
- def delete_article_draft(self, version_id):
1166
+ def delete_article_draft(self, version_id: str, use_knowledge_management_endpoint: bool = True):
891
1167
  """This function deletes an unpublished knowledge article draft.
892
1168
 
893
1169
  .. version-added:: 1.4.0
894
1170
 
895
1171
  :param version_id: The 15-character or 18-character ``Id`` (Knowledge Article Version ID) value
896
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
897
1177
  :returns: The API response from the DELETE request
898
1178
  :raises: :py:exc:`RuntimeError`
899
1179
  """
900
- return knowledge_module.delete_article_draft(self.sfdc_object, version_id=version_id)
1180
+ return knowledge_module.delete_article_draft(self.sfdc_object, version_id=version_id,
1181
+ use_knowledge_management_endpoint=use_knowledge_management_endpoint)
901
1182
 
902
1183
 
903
1184
  def define_connection_info():
@@ -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: 30 Jan 2026
7
+ :Modified Date: 03 Feb 2026
8
8
  """
9
9
 
10
10
  from . import errors
@@ -531,7 +531,7 @@ def archive_article(sfdc_object, article_id):
531
531
  return sfdc_object.patch(endpoint, payload)
532
532
 
533
533
 
534
- def delete_article_draft(sfdc_object, version_id):
534
+ def delete_article_draft(sfdc_object, version_id: str, use_knowledge_management_endpoint: bool = True):
535
535
  """This function deletes an unpublished knowledge article draft.
536
536
 
537
537
  .. version-added:: 1.4.0
@@ -540,8 +540,15 @@ def delete_article_draft(sfdc_object, version_id):
540
540
  :type sfdc_object: class[salespyforce.Salesforce]
541
541
  :param version_id: The 15-character or 18-character ``Id`` (Knowledge Article Version ID) value
542
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
543
547
  :returns: The API response from the DELETE request
544
548
  :raises: :py:exc:`RuntimeError`
545
549
  """
546
- endpoint = f'/services/data/{sfdc_object.version}/sobjects/Knowledge__kav/{version_id}'
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}'
547
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: 30 Jan 2026
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,12 +20,14 @@ 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
 
26
28
  # Define constants
27
29
  SALESFORCE_ID_SUFFIX_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ012345'
30
+ VALID_SALESFORCE_URL_PATTERN = r'^https://[a-zA-Z0-9._-]+\.salesforce\.com(/|$)'
28
31
 
29
32
 
30
33
  def url_encode(raw_string):
@@ -47,9 +50,13 @@ def url_decode(encoded_string):
47
50
  return urllib.parse.unquote_plus(encoded_string)
48
51
 
49
52
 
53
+ @deprecated(since='1.4.0', replacement='salespyforce.errors.handlers.display_warning', removal='2.0.0')
50
54
  def display_warning(warn_msg):
51
55
  """This function displays a :py:exc:`UserWarning` message via the :py:mod:`warnings` module.
52
56
 
57
+ .. deprecated:: 1.4.0
58
+ Use :py:func:`salespyforce.errors.handlers.display_warning` instead.
59
+
53
60
  :param warn_msg: The message to be displayed
54
61
  :type warn_msg: str
55
62
  :returns: None
@@ -139,6 +146,41 @@ def get_18_char_id(record_id: str) -> str:
139
146
  return record_id + suffix
140
147
 
141
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
+
142
184
  def get_image_ref_id(image_url):
143
185
  """This function parses an image URL to identify the reference ID (refid) value.
144
186
  (`Reference 1 <https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_sobject_rich_text_image_retrieve.htm>`_,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: salespyforce
3
- Version: 1.4.0.dev2
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>
@@ -1,13 +1,14 @@
1
1
  salespyforce/__init__.py,sha256=W2RY2_kojLcXtTHJrto5FtU6q1umVPaU1RZe_QkFgNY,2031
2
- salespyforce/api.py,sha256=fSMZYH1bpdZGS2sq924f1we0Bkubx8rrTx810Xyl3Ug,7732
2
+ salespyforce/api.py,sha256=NdvPUqJ5HrcaEBaHte414eNOgwIaNweSYxz3E6-zWQE,9403
3
3
  salespyforce/chatter.py,sha256=GI9iXmq2mrbrH7daW8Kc3gt2O04dRthSVocJq4BVwCU,7494
4
- salespyforce/core.py,sha256=x-D8HsjsRSS7ti1XnwSxKFfqysntI8TttgKBqjsKwh4,52661
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=5a1kllrggh83vOmFrR-NLaz2YDpoKQweQ1B4e2AwghA,25407
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=_uVVxHcVwCb-h6m-B7IKY77ItaSsHtGwydZN923yIic,7038
11
+ salespyforce/utils/core_utils.py,sha256=Q-xtBXVkuK50Ps9lLMZuGfFrSM0JxroN_6jcZkyaK6k,8690
11
12
  salespyforce/utils/helper.py,sha256=zwbysqwddWwhp3mfzKJf0aeTTkVqoR35L9dLu6ZVJtU,5661
12
13
  salespyforce/utils/log_utils.py,sha256=-7qgSNCUQRstRgz2wXBX6LRWO-GMjIXS5WjxkHGvBHk,11936
13
14
  salespyforce/utils/tests/__init__.py,sha256=Iv7M9gPyKv5f5tR-pxc089cd2nrWn5M0ZZpWqiB2htc,242
@@ -21,7 +22,7 @@ salespyforce/utils/tests/test_soql.py,sha256=SSOAN-4QZNpwUON-cLndb5W6AAs28Ja1u-Z
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.dev2.dist-info/LICENSE,sha256=b_Ch0pvcZN6Mr4dQ9Bo3It-J3MihQYcj_-EIdaZHuWw,1071
25
- salespyforce-1.4.0.dev2.dist-info/METADATA,sha256=7Loir-UVgxd2NpPpLKFQo-0nnt540mMC2ZSh-0uIyxA,10400
26
- salespyforce-1.4.0.dev2.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
27
- salespyforce-1.4.0.dev2.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,,