├── .gitignore ├── .pylintrc ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── requirements-test.txt ├── requirements.txt ├── salesforce_api ├── __init__.py ├── client.py ├── config.py ├── const │ ├── __init__.py │ ├── misc.py │ ├── service.py │ ├── status.py │ ├── test.py │ └── type.py ├── core.py ├── data │ └── soap_messages │ │ ├── deploy │ │ ├── cancel.msg │ │ ├── deploy.msg │ │ └── status.msg │ │ ├── login │ │ └── login.msg │ │ └── retrieve │ │ ├── retrieve.msg │ │ └── status.msg ├── exceptions.py ├── login.py ├── models │ ├── __init__.py │ ├── base.py │ ├── bulk.py │ ├── deploy.py │ ├── retrieve.py │ ├── shared.py │ └── tooling.py ├── services │ ├── __init__.py │ ├── base.py │ ├── basic.py │ ├── bulk │ │ ├── __init__.py │ │ ├── base.py │ │ ├── default.py │ │ ├── v1.py │ │ └── v2.py │ ├── deploy.py │ ├── retrieve.py │ ├── sobjects.py │ └── tooling.py └── utils │ ├── __init__.py │ ├── bulk.py │ ├── misc.py │ ├── retrieve.py │ └── soap.py ├── setup.py └── tests ├── __init__.py ├── data ├── basic │ ├── limits.txt │ ├── search_failure.txt │ └── versions.txt ├── bulk │ ├── v1 │ │ └── success_result.txt │ └── v2 │ │ ├── create.txt │ │ ├── failed_results.txt │ │ ├── info.txt │ │ ├── successful_results.txt │ │ └── upload_complete.txt ├── deploy │ ├── create_failure.txt │ ├── create_success.txt │ ├── status_failed_code_coverage.txt │ ├── status_failed_multiple.txt │ ├── status_failed_single.txt │ ├── status_pending.txt │ └── status_success.txt ├── login │ ├── oauth │ │ ├── invalid_client_id.txt │ │ ├── invalid_client_secret.txt │ │ ├── invalid_grant.txt │ │ └── success.txt │ └── soap │ │ ├── invalid_login.txt │ │ ├── missing_token.txt │ │ └── success.txt └── retrieve │ ├── create_failure.txt │ ├── create_success.txt │ ├── status_failed.txt │ ├── status_pending.txt │ ├── status_success.txt │ └── status_with_zip.txt ├── helpers.py ├── test_login.py ├── test_service_basic.py ├── test_service_bulk_v1.py ├── test_service_bulk_v2.py ├── test_service_deploy.py ├── test_service_retrieve.py ├── test_service_sobjects.py └── test_service_tooling.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | target/ 74 | 75 | # Jupyter Notebook 76 | .ipynb_checkpoints 77 | 78 | # IPython 79 | profile_default/ 80 | ipython_config.py 81 | 82 | # pyenv 83 | .python-version 84 | 85 | # celery beat schedule file 86 | celerybeat-schedule 87 | 88 | # SageMath parsed files 89 | *.sage.py 90 | 91 | # Environments 92 | .env 93 | .venv 94 | env/ 95 | venv/ 96 | ENV/ 97 | env.bak/ 98 | venv.bak/ 99 | 100 | # Spyder project settings 101 | .spyderproject 102 | .spyproject 103 | 104 | # Rope project settings 105 | .ropeproject 106 | 107 | # mkdocs documentation 108 | /site 109 | 110 | # mypy 111 | .mypy_cache/ 112 | .dmypy.json 113 | dmypy.json 114 | 115 | # Pyre type checker 116 | .pyre/ 117 | 118 | # IDE 119 | .idea 120 | /test.py -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MESSAGES CONTROL] 2 | disable=missing-docstring,line-too-long,no-self-use,too-few-public-methods,too-many-instance-attributes,too-many-arguments -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.6" 4 | install: 5 | - pip install -r requirements.txt 6 | - pip install -r requirements-test.txt 7 | script: 8 | - pytest -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md LICENSE 2 | recursive-include salesforce_api/data * -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | python setup.py sdist 3 | 4 | upload: 5 | python -m twine upload --repository-url https://upload.pypi.org/legacy/ dist/* -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Python Salesforce API 2 | ===================== 3 | 4 | [![Build Status](https://travis-ci.org/felixlindstrom/python-salesforce-api.svg?branch=master)](https://travis-ci.org/felixlindstrom/python-salesforce-api) 5 | 6 | This project aims to provide an easy to use, highly flexible and testable solution for communicating with Salesforce 7 | through its REST and SOAP api. 8 | 9 | Content 10 | ------- 11 | 12 | - [Simple Usage](#simple-usage) 13 | - [Authentication](#authentication) 14 | - [Record management](#record-management) 15 | - [Insert](#insert) 16 | - [Upsert](#upsert) 17 | - [Update](#update) 18 | - [Get](#get) 19 | - [Delete](#delete) 20 | - [Quering SObjects](#querying-sobjects) 21 | - [Bulk](#bulk) 22 | - [Tooling](#tooling) 23 | - [Deploying](#deploying) 24 | - [Retrieving](#retrieving) 25 | - [Additional features](#additional-features) 26 | 27 | 28 | Simple usage 29 | ------------ 30 | 31 | Creating a new connection / client is as simple as this: 32 | ```python 33 | from salesforce_api import Salesforce 34 | client = Salesforce( 35 | username='test@example.com', 36 | password='my-password', 37 | security_token='password-token' 38 | ) 39 | ``` 40 | 41 | Authentication 42 | -------------- 43 | To get started in the simples of ways, you would do the following 44 | 45 | ```python 46 | from salesforce_api import Salesforce 47 | client = Salesforce(username='test@example.com', 48 | password='my-password', 49 | security_token='password-token') 50 | ``` 51 | 52 | If you are trying to connect to a sandbox, you have to specify this using the `is_sandbox` argument. 53 | ```python 54 | from salesforce_api import Salesforce 55 | client = Salesforce(username='test@example.com', 56 | password='my-password', 57 | security_token='password-token', 58 | is_sandbox=True) 59 | ``` 60 | 61 | If for some reason the login-url differs from the standard prod/test login urls, you can specify the login url. This can be useful if you are using a mock-server, for example. This will override the `is_sandbox` argument. 62 | ```python 63 | from salesforce_api import Salesforce 64 | client = Salesforce(username='test@example.com', 65 | password='my-password', 66 | security_token='password-token', 67 | domain='login.example.com') 68 | ``` 69 | 70 | The examples so far would use the SOAP API for authenticating. If you want to authenticate using an app, that's easy engough. The login-url and sandbox-arguments applies here as well. 71 | ```python 72 | from salesforce_api import Salesforce 73 | client = Salesforce(username='test@example.com', 74 | password='my-password', 75 | client_id='123', 76 | client_secret='my-secret') 77 | ``` 78 | 79 | You can also authenticate using the OAuth Client Credentials Flow given that's enabled for your connected app. 80 | ```python 81 | from salesforce_api import Salesforce 82 | client = Salesforce(client_id='123', 83 | client_secret='my-secret', 84 | domain='my-domain') 85 | ``` 86 | 87 | If you already have an OAuth access token, obtained elsewhere, you can just as easily create a new client. 88 | ```python 89 | from salesforce_api import Salesforce 90 | client = Salesforce(access_token='access-token-here', 91 | domain='access-token-domain') 92 | ``` 93 | 94 | If you want to explicitly use one or the other methods of authenticating, you can do that as well 95 | ```python 96 | from salesforce_api import Salesforce, login 97 | client = Salesforce(login.oauth2(username='test@example.com', 98 | password='my-password', 99 | client_id='123', 100 | client_secret='my-secret')) 101 | ``` 102 | 103 | If you want to use a specific version of the Salesforce API, you can specify this: 104 | ```python 105 | from salesforce_api import Salesforce 106 | client = Salesforce(access_token='access-token-here', 107 | domain='access-token-domain', 108 | api_version='51.0') 109 | ``` 110 | 111 | Record management 112 | ----------------- 113 | 114 | Wokring with records is easy. All SObject-related methods are exposed through the `sobjects`-property on the client. 115 | 116 | The data returned from the different calls is the decoded data from the raw response. 117 | 118 | ##### Insert 119 | Example 120 | ```python 121 | client.sobjects.Contact.insert({'LastName': 'Example', 'Email': 'test@example.com'}) 122 | ``` 123 | Returns 124 | ``` 125 | {"id":"0031l000007rU3vAAE","success":true,"errors":[]} 126 | ``` 127 | 128 | ##### Get 129 | Example 130 | ```python 131 | client.sobjects.Contact.get('0031l000007rU3vAAE') 132 | ``` 133 | Returns 134 | ``` 135 | { 136 | "attributes": { 137 | "type": "Contact", 138 | "url": "/services/data/v44.0/sobjects/Contact/0031l000007rU3vAAE" 139 | }, 140 | "Id": "0031l000007rU3vAAE", 141 | "LastName": "Example", 142 | "FirstName": "Test", 143 | ... 144 | } 145 | ``` 146 | 147 | ##### Update 148 | Example 149 | ```python 150 | client.sobjects.Contact.update('0031l000007rU3vAAE', {'FirstName': 'Felix', 'LastName': 'Lindstrom'}) 151 | ``` 152 | Returns 153 | ```python 154 | True 155 | ``` 156 | 157 | ##### Upsert 158 | Example 159 | ```python 160 | client.sobjects.Contact.upsert('customExtIdField__c', '11999', {'FirstName': 'Felix', 'LastName': 'Lindstrom'}) 161 | ``` 162 | Returns 163 | ```python 164 | True 165 | ``` 166 | 167 | ##### Delete 168 | Example 169 | ```python 170 | client.sobjects.Contact.delete('0031l000007rU3vAAE') 171 | ``` 172 | Returns 173 | ```python 174 | True 175 | ``` 176 | 177 | ##### Metadata 178 | Example 179 | ```python 180 | client.sobjects.Contact.metadata() 181 | ``` 182 | Returns 183 | ``` 184 | { 185 | 'objectDescribe': { 186 | 'activateable': False, 187 | 'createable': True, 188 | 'custom': False, 189 | ... 190 | 'urls': { 191 | 'compactLayouts': '/services/data/v44.0/sobjects/Contact/describe/compactLayouts', 192 | 'rowTemplate': '/services/data/v44.0/sobjects/Contact/{ID}', 193 | 'approvalLayouts': '/services/data/v44.0/sobjects/Contact/describe/approvalLayouts', 194 | ... 195 | } 196 | }, 197 | 'recentItems': [] 198 | } 199 | ``` 200 | 201 | ##### Describe 202 | Example 203 | ```python 204 | client.sobjects.Contact.describe() 205 | ``` 206 | Returns 207 | ``` 208 | { 209 | ... 210 | } 211 | ``` 212 | 213 | Querying SObjects 214 | ------------- 215 | 216 | The Salesforce API is great at returning large amounts of data, so the pagination that Salesforce implements for the result of your queries is taken cared of automagically when querying for data. 217 | 218 | Example 219 | ```python 220 | client.sobjects.query("SELECT Id, FirstName, LastName FROM Contact WHERE FirstName='Felix'") 221 | ``` 222 | Return 223 | ``` 224 | [{ 225 | 'attributes': { 226 | 'type': 'Contact', 227 | 'url': '/services/data/v44.0/sobjects/Contact/0031l000007Jia4AAC' 228 | }, 229 | 'Id': '0031l000007Jia4AAC', 230 | 'FirstName': 'Felix', 231 | 'LastName': 'Lindstrom' 232 | }, ...] 233 | ``` 234 | 235 | If the amount of data is too large and you want to avoid having it all in memory at once, use the `query_iter` method. 236 | 237 | Example 238 | ```python 239 | client.sobjects.query_iter("SELECT Id, FirstName, LastName FROM Contact") 240 | ``` 241 | 242 | Bulk 243 | ---- 244 | 245 | This module implements the Bulk V2 API. Basically, it allows you to think less and do more. 246 | 247 | Note that the correct permission-set might be needed on the user, see https://success.salesforce.com/issues_view?id=a1p3A000000BMPFQA4 248 | 249 | ##### Bulk Insert 250 | Example 251 | ```python 252 | client.bulk.insert('Contact', [ 253 | {'LastName': 'Lindstrom', 'Email': 'test@example.com'}, 254 | {'LastName': 'Something else', 'Email': 'test@example.com'} 255 | ]) 256 | ``` 257 | Returns 258 | ```python 259 | [, 260 | ] 261 | ``` 262 | 263 | ##### Bulk Insert 264 | Example 265 | ```python 266 | client.bulk.insert('Contact', [ 267 | {'LastName': 'Lindstrom', 'Email': 'test@example.com'}, 268 | {'LastName': 'Something else', 'Email': 'test@example.com'} 269 | ]) 270 | ``` 271 | Returns 272 | ```python 273 | [, 274 | ] 275 | ``` 276 | 277 | ##### Bulk Upsert 278 | Example 279 | ```python 280 | client.bulk.upsert('Contact', [ 281 | {'LastName': 'Lindstrom', 'Email': 'test@example.com', 'MyId__c': 1}, 282 | {'LastName': 'Something else', 'Email': 'test@example.com', 'MyId__c': 2} 283 | ], external_id_field_name='MyId__c') 284 | ``` 285 | Returns 286 | ```python 287 | [, 288 | ] 289 | ``` 290 | 291 | ##### Bulk Update 292 | Example 293 | ```python 294 | client.bulk.update('Contact', [ 295 | {'LastName': 'Lindstrom', 'Email': 'test@example.com'}, 296 | {'LastName': 'Something else', 'Email': 'test@example.com'} 297 | ]) 298 | ``` 299 | Returns 300 | ```python 301 | [, 302 | ] 303 | ``` 304 | 305 | ##### Bulk Delete 306 | Example 307 | ```python 308 | client.bulk.delete('Contact', ['0031l000007rU5rAAE', '0031l000007rU5sAAE']) 309 | ``` 310 | Returns 311 | ```python 312 | [, 313 | ] 314 | ``` 315 | 316 | ##### Failed requests 317 | Example (_Given that the records no longer exists_) 318 | ```python 319 | client.bulk.update('Contact', ['0031l000007rU5rAAE', '0031l000007rU5sAAE']) 320 | ``` 321 | Returns 322 | ```python 323 | [, 324 | ] 325 | ``` 326 | 327 | ##### Manual managing bulk job 328 | By using the api above, the library hides the uploading and waiting for the bulk-process to get processed. 329 | 330 | In some cases you might want to handle this differently. Perhaps you want to upload bunch of records to be inserted and then forget about the process. This can be done by creating a job and managing it by yourself. 331 | ```python 332 | bulk_job = client.bulk.create_job(OPERATION.INSERT, 'Contact') 333 | bulk_job.upload([ 334 | {'LastName': 'Lindstrom', 'Email': 'test@example.com'}, 335 | {'LastName': 'Something else', 'Email': 'test@example.com'} 336 | ]) 337 | while not bulk_job.is_done(): 338 | time.sleep(5) 339 | ``` 340 | 341 | Tooling 342 | ------- 343 | 344 | ##### Execute Apex 345 | Example 346 | ```python 347 | client.tooling.execute_apex("System.debug('Test');") 348 | ``` 349 | Return on success 350 | ```python 351 | 352 | ``` 353 | Return on failure 354 | ``` 355 | 356 | ``` 357 | 358 | Deploying 359 | --------- 360 | Deploying an existing package 361 | ```python 362 | from salesforce_api.models.deploy import Options 363 | 364 | 365 | deployment = client.deploy.deploy('/path/to/file.zip') 366 | deployment.wait() 367 | result = deployment.get_status() 368 | 369 | ``` 370 | 371 | Only validating 372 | ```python 373 | from salesforce_api.models.deploy import Options 374 | 375 | 376 | deployment = client.deploy.deploy('/path/to/file.zip', Options(checkOnly=True)) 377 | deployment.wait() 378 | result = deployment.get_status() 379 | 380 | ``` 381 | 382 | Validating running specific tests 383 | ```python 384 | from salesforce_api.models.deploy import Options 385 | 386 | 387 | deployment = client.deploy.deploy('/path/to/file.zip', Options( 388 | checkOnly=True, 389 | testLevel='RunSpecifiedTests', 390 | runTests=[ 391 | 'TesterIntegrationApplicationTest', 392 | 'TesterIntegrationProcessTest' 393 | ] 394 | )) 395 | deployment.wait() 396 | result = deployment.get_status() 397 | ``` 398 | 399 | Canceling a deployment as soon as it fails 400 | ```python 401 | from salesforce_api.models.deploy import Options 402 | 403 | 404 | deployment = client.deploy.deploy('/path/to/file.zip', Options(checkOnly=True)) 405 | 406 | while not deployment.is_done(): 407 | if deployment.has_failed(): 408 | deployment.cancel() 409 | break 410 | 411 | ``` 412 | 413 | Retrieving 414 | ---------- 415 | Example 416 | ```python 417 | from salesforce_api.models.shared import Type 418 | 419 | 420 | # Decide what you want to retrieve 421 | types = [ 422 | Type('ApexTrigger'), 423 | Type('ApexClass', ['MyMainClass', 'AnotherClass']) 424 | ] 425 | 426 | # Create retrievement-job 427 | retrievement = client.retrieve.retrieve(types) 428 | 429 | # Wait for the job to finish 430 | retrievement.wait() 431 | 432 | # Write the resulting zip-archive to a file 433 | open('retrieve.zip', 'wb').write(retrievement.get_zip_file().read()) 434 | ``` 435 | 436 | Additional features 437 | ------------------- 438 | 439 | If for some reason you need to specify how the client communicates with Salesforce, you can create the requests-session yourself and pass it to the client upon creation. This would, for example, allow you proxy your requests: 440 | ```python 441 | import requests 442 | from salesforce_api import Salesforce 443 | 444 | 445 | session = requests.Session() 446 | session.proxies.update({'https': 'https://my-proxy.com/'}) 447 | session.headers.update({'User-Agent': 'My-User-Agent'}) 448 | 449 | client = Salesforce(username='test@example.com', 450 | password='my-password', 451 | security_token='password-token', 452 | session=session) 453 | ``` 454 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | requests_mock -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | xmltodict 3 | url-normalize -------------------------------------------------------------------------------- /salesforce_api/__init__.py: -------------------------------------------------------------------------------- 1 | from .client import Client as Salesforce -------------------------------------------------------------------------------- /salesforce_api/client.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from . import login 4 | from .core import Connection 5 | from .services import basic, bulk, deploy, retrieve, sobjects, tooling 6 | from .utils import misc as misc_utils 7 | 8 | 9 | class Client: 10 | def __init__(self, 11 | connection: Connection = None, 12 | domain: str = None, 13 | username: str = None, 14 | password: str = None, 15 | security_token: str = None, 16 | password_and_security_token: str = None, 17 | client_id: str = None, 18 | client_secret: str = None, 19 | access_token: str = None, 20 | session: requests.Session = None, 21 | is_sandbox=False, 22 | api_version: str = None): 23 | self.connection = connection if connection else login.magic( 24 | domain=domain, 25 | username=username, 26 | password=password, 27 | security_token=security_token, 28 | password_and_security_token=password_and_security_token, 29 | client_id=client_id, 30 | client_secret=client_secret, 31 | access_token=access_token, 32 | session=misc_utils.get_session(session), 33 | is_sandbox=is_sandbox, 34 | api_version=api_version 35 | ) 36 | self._setup_services() 37 | 38 | def _setup_services(self): 39 | self.basic = basic.Basic(self.connection) 40 | self.sobjects = sobjects.SObjects(self.connection) 41 | self.tooling = tooling.Tooling(self.connection) 42 | self.deploy = deploy.Deploy(self.connection) 43 | self.retrieve = retrieve.Retrieve(self.connection) 44 | 45 | self.bulk = bulk.Client(self.connection) 46 | self.bulk_v1 = bulk.v1.Client(self.connection) 47 | self.bulk_v2 = bulk.v2.Client(self.connection) 48 | -------------------------------------------------------------------------------- /salesforce_api/config.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | RETRIEVE_SLEEP_SECONDS = 10 4 | DEPLOY_SLEEP_SECONDS = 20 5 | BULK_SLEEP_SECONDS = 5 6 | DATA_DIRECTORY = Path(__file__).parent / 'data' 7 | CLIENT_NAME = 'python-salesforce-api' 8 | BULK_VERSION = 2 -------------------------------------------------------------------------------- /salesforce_api/const/__init__.py: -------------------------------------------------------------------------------- 1 | from .misc import * 2 | from .status import * 3 | from .test import * 4 | from .type import * -------------------------------------------------------------------------------- /salesforce_api/const/misc.py: -------------------------------------------------------------------------------- 1 | API_VERSION = '48.0' 2 | -------------------------------------------------------------------------------- /salesforce_api/const/service.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class VERB(Enum): 5 | POST = 'POST' 6 | PATCH = 'PATCH' 7 | PUT = 'PUT' 8 | GET = 'GET' 9 | DELETE = 'DELETE' 10 | -------------------------------------------------------------------------------- /salesforce_api/const/status.py: -------------------------------------------------------------------------------- 1 | STATUS_SUCCEEDED = 'Succeeded' 2 | STATUS_FAILED = 'Failed' 3 | STATUS_QUEUED = 'Queued' 4 | STATUS_PENDING = 'Pending' 5 | STATUS_INPROGRESS = 'InProgress' 6 | STATUS_CANCELED = 'Canceled' 7 | STATUSES_PENDING = [ 8 | STATUS_QUEUED, 9 | STATUS_PENDING, 10 | STATUS_INPROGRESS 11 | ] 12 | STATUSES_DONE = [ 13 | STATUS_SUCCEEDED, 14 | STATUS_FAILED, 15 | STATUS_CANCELED 16 | ] -------------------------------------------------------------------------------- /salesforce_api/const/test.py: -------------------------------------------------------------------------------- 1 | TEST_LEVEL_NO_TEST_RUN = 'NoTestRun' 2 | TEST_LEVEL_RUN_SPECIFIED_TESTS = 'RunSpecifiedTests' 3 | TEST_LEVEL_RUN_LOCAL_TESTS = 'RunLocalTests' 4 | TEST_LEVEL_RUN_ALL_TESTS_IN_ORG = 'RunAllTestsInOrg' 5 | TEST_LEVELS = [ 6 | TEST_LEVEL_NO_TEST_RUN, 7 | TEST_LEVEL_RUN_SPECIFIED_TESTS, 8 | TEST_LEVEL_RUN_LOCAL_TESTS, 9 | TEST_LEVEL_RUN_ALL_TESTS_IN_ORG 10 | ] -------------------------------------------------------------------------------- /salesforce_api/const/type.py: -------------------------------------------------------------------------------- 1 | ACTION_LINK_GROUP_TEMPLATE = 'ActionLinkGroupTemplate' 2 | ANALYTIC_SNAPSHOT = 'AnalyticSnapshot' 3 | ARTICLE_TYPE = 'ArticleType' 4 | APEX_CLASS = 'ApexClass' 5 | APEX_COMPONENT = 'ApexComponent' 6 | APEX_PAGE = 'ApexPage' 7 | APEX_TEST_SUITE = 'ApexTestSuite' 8 | APEX_TRIGGER = 'ApexTrigger' 9 | APP_MENU = 'AppMenu' 10 | APPROVAL_PROCESS = 'ApprovalProcess' 11 | ASSIGNMENT_RULES = 'AssignmentRules' 12 | AURA_DEFINITION_BUNDLE = 'AuraDefinitionBundle' 13 | AUTH_PROVIDER = 'AuthProvider' 14 | AUTO_RESPONSE_RULES = 'AutoResponseRules' 15 | BOT_VERSION = 'BotVersion' 16 | BRANDING_SET = 'BrandingSet' 17 | CALL_CENTER = 'CallCenter' 18 | CAMPAIGN_INFLUENCE_MODEL = 'CampaignInfluenceModel' 19 | CASE_SUBJECT_PARTICLE = 'CaseSubjectParticle' 20 | CERTIFICATE = 'Certificate' 21 | CHATTER_EXTENSION = 'ChatterExtension' 22 | CLEAN_DATA_SERVICE = 'CleanDataService' 23 | C_M_S_CONNECT_SOURCE = 'CMSConnectSource' 24 | COMMUNITY = 'Community' 25 | COMMUNITY_TEMPLATE_DEFINITION = 'CommunityTemplateDefinition' 26 | COMMUNITY_THEME_DEFINITION = 'CommunityThemeDefinition' 27 | CONNECTED_APP = 'ConnectedApp' 28 | CONTENT_ASSET = 'ContentAsset' 29 | CORS_WHITELIST_ORIGIN = 'CorsWhitelistOrigin' 30 | CSP_TRUSTED_SITE = 'CspTrustedSite' 31 | CUSTOM_APPLICATION = 'CustomApplication' 32 | CUSTOM_APPLICATION_COMPONENT = 'CustomApplicationComponent' 33 | CUSTOM_FEED_FILTER = 'CustomFeedFilter' 34 | CUSTOM_LABELS = 'CustomLabels' 35 | CUSTOM_OBJECT = 'CustomObject' 36 | CUSTOM_OBJECT_TRANSLATION = 'CustomObjectTranslation' 37 | CUSTOM_PAGE_WEB_LINK = 'CustomPageWebLink' 38 | CUSTOM_PERMISSION = 'CustomPermission' 39 | CUSTOM_SITE = 'CustomSite' 40 | CUSTOM_TAB = 'CustomTab' 41 | CUSTOM_VALUE = 'CustomValue' 42 | DASHBOARD = 'Dashboard' 43 | DATA_CATEGORY_GROUP = 'DataCategoryGroup' 44 | DELEGATE_GROUP = 'DelegateGroup' 45 | DOCUMENT = 'Document' 46 | DUPLICATE_RULE = 'DuplicateRule' 47 | ECLAIR_GEO_DATA = 'EclairGeoData' 48 | EMAIL_SERVICES_FUNCTION = 'EmailServicesFunction' 49 | EMAIL_TEMPLATE = 'EmailTemplate' 50 | EMBEDDED_SERVICE_BRANDING = 'EmbeddedServiceBranding' 51 | EMBEDDED_SERVICE_CONFIG = 'EmbeddedServiceConfig' 52 | EMBEDDED_SERVICE_FIELD_SERVICE = 'EmbeddedServiceFieldService' 53 | EMBEDDED_SERVICE_LIVE_AGENT = 'EmbeddedServiceLiveAgent' 54 | ENTITLEMENT_PROCESS = 'EntitlementProcess' 55 | ENTITLEMENT_TEMPLATE = 'EntitlementTemplate' 56 | ESCALATION_RULES = 'EscalationRules' 57 | EVENT_DELIVERY = 'EventDelivery' 58 | EVENT_SUBSCRIPTION = 'EventSubscription' 59 | EXTERNAL_DATA_SOURCE = 'ExternalDataSource' 60 | EXTERNAL_SERVICE_REGISTRATION = 'ExternalServiceRegistration' 61 | FEATURE_PARAMETER_BOOLEAN = 'FeatureParameterBoolean' 62 | FEATURE_PARAMETER_DATE = 'FeatureParameterDate' 63 | FEATURE_PARAMETER_INTEGER = 'FeatureParameterInteger' 64 | FLEXI_PAGE = 'FlexiPage' 65 | FLOW = 'Flow' 66 | FLOW_CATEGORY = 'FlowCategory' 67 | FLOW_DEFINITION = 'FlowDefinition' 68 | FOLDER = 'Folder' 69 | GLOBAL_PICKLIST = 'GlobalPicklist' 70 | GLOBAL_PICKLIST_VALUE = 'GlobalPicklistValue' 71 | GLOBAL_VALUE_SET = 'GlobalValueSet' 72 | GLOBAL_VALUE_SET_TRANSLATION = 'GlobalValueSetTranslation' 73 | GROUP = 'Group' 74 | HOME_PAGE_COMPONENT = 'HomePageComponent' 75 | HOME_PAGE_LAYOUT = 'HomePageLayout' 76 | INSTALLED_PACKAGE = 'InstalledPackage' 77 | KEYWORD_LIST = 'KeywordList' 78 | LAYOUT = 'Layout' 79 | LETTERHEAD = 'Letterhead' 80 | LIGHTNING_BOLT = 'LightningBolt' 81 | LIGHTNING_COMPONENT_BUNDLE = 'LightningComponentBundle' 82 | LIGHTNING_EXPERIENCE_THEME = 'LightningExperienceTheme' 83 | LIVE_CHAT_AGENT_CONFIG = 'LiveChatAgentConfig' 84 | LIVE_CHAT_BUTTON = 'LiveChatButton' 85 | LIVE_CHAT_DEPLOYMENT = 'LiveChatDeployment' 86 | LIVE_CHAT_SENSITIVE_DATA_RULE = 'LiveChatSensitiveDataRule' 87 | MANAGED_TOPICS = 'ManagedTopics' 88 | MATCHING_RULE = 'MatchingRule' 89 | METADATA = 'Metadata' 90 | METADATA_WITH_CONTENT = 'MetadataWithContent' 91 | MILESTONE_TYPE = 'MilestoneType' 92 | ML_DOMAIN = 'MlDomain' 93 | MODERATION_RULE = 'ModerationRule' 94 | NAMED_CREDENTIAL = 'NamedCredential' 95 | NETWORK = 'Network' 96 | NETWORK_BRANDING = 'NetworkBranding' 97 | PACKAGE = 'Package' 98 | PATH_ASSISTANT = 'PathAssistant' 99 | PERMISSION_SET = 'PermissionSet' 100 | PLATFORM_CACHE_PARTITION = 'PlatformCachePartition' 101 | PORTAL = 'Portal' 102 | POST_TEMPLATE = 'PostTemplate' 103 | PRESENCE_DECLINE_REASON = 'PresenceDeclineReason' 104 | PRESENCE_USER_CONFIG = 'PresenceUserConfig' 105 | PROFILE = 'Profile' 106 | PROFILE_ACTION_OVERRIDE = 'ProfileActionOverride' 107 | PROFILE_PASSWORD_POLICY = 'ProfilePasswordPolicy' 108 | QUEUE = 'Queue' 109 | QUEUE_ROUTING_CONFIG = 'QueueRoutingConfig' 110 | QUICK_ACTION = 'QuickAction' 111 | REMOTE_SITE_SETTING = 'RemoteSiteSetting' 112 | REPORT = 'Report' 113 | REPORT_TYPE = 'ReportType' 114 | ROLE = 'Role' 115 | ROLE_OR_TERRITORY = 'RoleOrTerritory' 116 | SAML_SSO_CONFIG = 'SamlSsoConfig' 117 | SCONTROL = 'Scontrol' 118 | SETTING = 'Setting' 119 | SERVICE_CHANNEL = 'ServiceChannel' 120 | SERVICE_PRESENCE_STATUS = 'ServicePresenceStatus' 121 | SHARED_TO = 'SharedTo' 122 | SHARING_BASE_RULE = 'SharingBaseRule' 123 | SHARING_RULES = 'SharingRules' 124 | SHARING_SET = 'SharingSet' 125 | SITE_DOT_COM = 'SiteDotCom' 126 | SKILL = 'Skill' 127 | STANDARD_VALUE_SET = 'StandardValueSet' 128 | STANDARD_VALUE_SET_TRANSLATION = 'StandardValueSetTranslation' 129 | STATIC_RESOURCE = 'StaticResource' 130 | SYNONYM_DICTIONARY = 'SynonymDictionary' 131 | TERRITORY = 'Territory' 132 | TERRITORY2 = 'Territory2' 133 | TERRITORY2_MODEL = 'Territory2Model' 134 | TERRITORY2_RULE = 'Territory2Rule' 135 | TERRITORY2_TYPE = 'Territory2Type' 136 | TOPICS_FOR_OBJECTS = 'TopicsForObjects' 137 | TRANSACTION_SECURITY_POLICY = 'TransactionSecurityPolicy' 138 | TRANSLATIONS = 'Translations' 139 | USER_CRITERIA = 'UserCriteria' 140 | WAVE_APPLICATION = 'WaveApplication' 141 | WAVE_DATAFLOW = 'WaveDataflow' 142 | WAVE_DASHBOARD = 'WaveDashboard' 143 | WAVE_DATASET = 'WaveDataset' 144 | WAVE_LENS = 'WaveLens' 145 | WAVE_TEMPLATE_BUNDLE = 'WaveTemplateBundle' 146 | WAVE_XMD = 'WaveXmd' 147 | WORKFLOW = 'Workflow' 148 | 149 | EXTENSION_TO_TYPE = { 150 | 'app': CUSTOM_APPLICATION, 151 | 'appMenu': APP_MENU, 152 | 'approvalProcess': APPROVAL_PROCESS, 153 | 'assignmentRules': ASSIGNMENT_RULES, 154 | 'autoResponseRules': AUTO_RESPONSE_RULES, 155 | 'cls': APEX_CLASS, 156 | 'community': COMMUNITY, 157 | 'component': APEX_COMPONENT, 158 | 'connectedApp': CONNECTED_APP, 159 | 'crt': CERTIFICATE, 160 | 'customPermission': CUSTOM_PERMISSION, 161 | 'dashboard': DASHBOARD, 162 | 'duplicateRule': DUPLICATE_RULE, 163 | 'dataSource': EXTERNAL_DATA_SOURCE, 164 | 'email': EMAIL_TEMPLATE, 165 | 'escalationRules': ESCALATION_RULES, 166 | 'flexipage': FLEXI_PAGE, 167 | 'globalValueSet': GLOBAL_VALUE_SET, 168 | 'group': GROUP, 169 | 'homePageLayout': HOME_PAGE_LAYOUT, 170 | 'labels': CUSTOM_LABELS, 171 | 'layout': LAYOUT, 172 | 'letter': LETTERHEAD, 173 | 'managedTopics': MANAGED_TOPICS, 174 | 'matchingRule': MATCHING_RULE, 175 | 'network': NETWORK, 176 | 'object': CUSTOM_OBJECT, 177 | 'objectTranslation': CUSTOM_OBJECT_TRANSLATION, 178 | 'page': APEX_PAGE, 179 | 'permissionset': PERMISSION_SET, 180 | 'profile': PROFILE, 181 | 'queue': QUEUE, 182 | 'quickAction': QUICK_ACTION, 183 | 'remoteSite': REMOTE_SITE_SETTING, 184 | 'reportType': REPORT_TYPE, 185 | 'report': REPORT, 186 | 'resource': STATIC_RESOURCE, 187 | 'role': ROLE, 188 | 'settings': SETTING, 189 | 'sharingRules': SHARING_RULES, 190 | 'standardValueSet': STANDARD_VALUE_SET, 191 | 'site': CUSTOM_SITE, 192 | 'tab': CUSTOM_TAB, 193 | 'translation': TRANSLATIONS, 194 | 'territory2Type': TERRITORY2_TYPE, 195 | 'trigger': APEX_TRIGGER, 196 | 'workflow': WORKFLOW 197 | } 198 | 199 | 200 | FOLDER_TO_TYPE = { 201 | 'actionLinkGroupTemplates' : ACTION_LINK_GROUP_TEMPLATE, 202 | 'analyticSnapshots' : ANALYTIC_SNAPSHOT, 203 | 'articleTypes' : ARTICLE_TYPE, 204 | 'classes' : APEX_CLASS, 205 | 'components' : APEX_COMPONENT, 206 | 'pages' : APEX_PAGE, 207 | 'apexTestSuite' : APEX_TEST_SUITE, 208 | 'triggers' : APEX_TRIGGER, 209 | 'appMenus' : APP_MENU, 210 | 'approvalProcesses' : APPROVAL_PROCESS, 211 | 'assignmentRules' : ASSIGNMENT_RULES, 212 | 'aura' : AURA_DEFINITION_BUNDLE, 213 | 'authProviders' : AUTH_PROVIDER, 214 | 'autoResponseRules' : AUTO_RESPONSE_RULES, 215 | 'bots' : BOT_VERSION, 216 | 'brandingSets' : BRANDING_SET, 217 | 'callCenters' : CALL_CENTER, 218 | 'campaignInfluenceModels' : CAMPAIGN_INFLUENCE_MODEL, 219 | 'caseSubjectParticles' : CASE_SUBJECT_PARTICLE, 220 | 'certs' : CERTIFICATE, 221 | 'chatterExtensions' : CHATTER_EXTENSION, 222 | 'cleanDataServices' : CLEAN_DATA_SERVICE, 223 | 'cMSConnectSources' : C_M_S_CONNECT_SOURCE, 224 | 'communities' : COMMUNITY, 225 | 'communityTemplateDefinitions' : COMMUNITY_TEMPLATE_DEFINITION, 226 | 'communityThemeDefinitions' : COMMUNITY_THEME_DEFINITION, 227 | 'connectedApps' : CONNECTED_APP, 228 | 'contentAssets' : CONTENT_ASSET, 229 | 'corsWhitelistOrigins' : CORS_WHITELIST_ORIGIN, 230 | 'cspTrustedSite' : CSP_TRUSTED_SITE, 231 | 'applications' : CUSTOM_APPLICATION, 232 | 'applicationComponents' : CUSTOM_APPLICATION_COMPONENT, 233 | 'feedFilters' : CUSTOM_FEED_FILTER, 234 | 'labels' : CUSTOM_LABELS, 235 | 'objects' : CUSTOM_OBJECT, 236 | 'objectTranslations' : CUSTOM_OBJECT_TRANSLATION, 237 | 'customPageWebLink' : CUSTOM_PAGE_WEB_LINK, 238 | 'permissions' : CUSTOM_PERMISSION, 239 | 'sites' : CUSTOM_SITE, 240 | 'tabs' : CUSTOM_TAB, 241 | 'values' : CUSTOM_VALUE, 242 | 'customValues' : CUSTOM_VALUE, 243 | 'dashboards' : DASHBOARD, 244 | 'dataCategoryGroups' : DATA_CATEGORY_GROUP, 245 | 'delegateGroups' : DELEGATE_GROUP, 246 | 'documents' : DOCUMENT, 247 | 'duplicateRules' : DUPLICATE_RULE, 248 | 'eclairGeoDatas' : ECLAIR_GEO_DATA, 249 | 'emailServices' : EMAIL_SERVICES_FUNCTION, 250 | 'email' : EMAIL_TEMPLATE, 251 | 'embeddedServiceBrandings' : EMBEDDED_SERVICE_BRANDING, 252 | 'embeddedServiceConfigs' : EMBEDDED_SERVICE_CONFIG, 253 | 'embeddedServiceFieldServices' : EMBEDDED_SERVICE_FIELD_SERVICE, 254 | 'embeddedServiceLiveAgents' : EMBEDDED_SERVICE_LIVE_AGENT, 255 | 'entitlementProcesses' : ENTITLEMENT_PROCESS, 256 | 'entitlementTemplates' : ENTITLEMENT_TEMPLATE, 257 | 'escalationRules' : ESCALATION_RULES, 258 | 'eventDeliveries' : EVENT_DELIVERY, 259 | 'eventSubscriptions' : EVENT_SUBSCRIPTION, 260 | 'externalDataSources' : EXTERNAL_DATA_SOURCE, 261 | 'externalServiceRegistrations' : EXTERNAL_SERVICE_REGISTRATION, 262 | 'featureParameterBooleans' : FEATURE_PARAMETER_BOOLEAN, 263 | 'featureParameterDates' : FEATURE_PARAMETER_DATE, 264 | 'featureParameterIntegers' : FEATURE_PARAMETER_INTEGER, 265 | 'flexiPages' : FLEXI_PAGE, 266 | 'flows' : FLOW, 267 | 'flowCategories' : FLOW_CATEGORY, 268 | 'flowDefinitions' : FLOW_DEFINITION, 269 | 'folders' : FOLDER, 270 | 'globalPicklists' : GLOBAL_PICKLIST, 271 | 'globalPicklistValues' : GLOBAL_PICKLIST_VALUE, 272 | 'globalValueSets' : GLOBAL_VALUE_SET, 273 | 'globalValueSetTranslations' : GLOBAL_VALUE_SET_TRANSLATION, 274 | 'groups' : GROUP, 275 | 'homePageComponents' : HOME_PAGE_COMPONENT, 276 | 'homePageLayouts' : HOME_PAGE_LAYOUT, 277 | 'installedPackages' : INSTALLED_PACKAGE, 278 | 'keywordLists' : KEYWORD_LIST, 279 | 'layouts' : LAYOUT, 280 | 'letterheads' : LETTERHEAD, 281 | 'lightningBolts' : LIGHTNING_BOLT, 282 | 'lwc' : LIGHTNING_COMPONENT_BUNDLE, 283 | 'lightningExperienceThemes' : LIGHTNING_EXPERIENCE_THEME, 284 | 'liveChatAgentConfigs' : LIVE_CHAT_AGENT_CONFIG, 285 | 'liveChatButtons' : LIVE_CHAT_BUTTON, 286 | 'liveChatDeployments' : LIVE_CHAT_DEPLOYMENT, 287 | 'liveChatSensitiveDataRules' : LIVE_CHAT_SENSITIVE_DATA_RULE, 288 | 'managedTopics' : MANAGED_TOPICS, 289 | 'matchingRules' : MATCHING_RULE, 290 | 'metadata' : METADATA, 291 | 'metadataWithContents' : METADATA_WITH_CONTENT, 292 | 'milestoneTypes' : MILESTONE_TYPE, 293 | 'mlDomains' : ML_DOMAIN, 294 | 'moderationRules' : MODERATION_RULE, 295 | 'namedCredentials' : NAMED_CREDENTIAL, 296 | 'networks' : NETWORK, 297 | 'networkBrandings' : NETWORK_BRANDING, 298 | 'packages' : PACKAGE, 299 | 'pathAssistants' : PATH_ASSISTANT, 300 | 'permissionSets' : PERMISSION_SET, 301 | 'platformCachePartitions' : PLATFORM_CACHE_PARTITION, 302 | 'portals' : PORTAL, 303 | 'postTemplates' : POST_TEMPLATE, 304 | 'presenceDeclineReasons' : PRESENCE_DECLINE_REASON, 305 | 'presenceUserConfigs' : PRESENCE_USER_CONFIG, 306 | 'profiles' : PROFILE, 307 | 'profileActionOverrides' : PROFILE_ACTION_OVERRIDE, 308 | 'profilePasswordPolicies' : PROFILE_PASSWORD_POLICY, 309 | 'queues' : QUEUE, 310 | 'queueRoutingConfigs' : QUEUE_ROUTING_CONFIG, 311 | 'quickActions' : QUICK_ACTION, 312 | 'remoteSiteSettings' : REMOTE_SITE_SETTING, 313 | 'reports' : REPORT, 314 | 'reportTypes' : REPORT_TYPE, 315 | 'roles' : ROLE, 316 | 'roleOrTerritorys' : ROLE_OR_TERRITORY, 317 | 'samlSsoConfigs' : SAML_SSO_CONFIG, 318 | 'scontrols' : SCONTROL, 319 | 'serviceChannels' : SERVICE_CHANNEL, 320 | 'servicePresenceStatus' : SERVICE_PRESENCE_STATUS, 321 | 'sharedTos' : SHARED_TO, 322 | 'sharingBaseRules' : SHARING_BASE_RULE, 323 | 'sharingRules' : SHARING_RULES, 324 | 'sharingSets' : SHARING_SET, 325 | 'siteDotComs' : SITE_DOT_COM, 326 | 'skills' : SKILL, 327 | 'standardValueSets' : STANDARD_VALUE_SET, 328 | 'standardValueSetTranslations' : STANDARD_VALUE_SET_TRANSLATION, 329 | 'staticResources' : STATIC_RESOURCE, 330 | 'synonymDictionaries' : SYNONYM_DICTIONARY, 331 | 'territories' : TERRITORY, 332 | 'territory2s' : TERRITORY2, 333 | 'territory2Models' : TERRITORY2_MODEL, 334 | 'territory2Rules' : TERRITORY2_RULE, 335 | 'territory2Types' : TERRITORY2_TYPE, 336 | 'topicsForObjects' : TOPICS_FOR_OBJECTS, 337 | 'transactionSecurityPolicies' : TRANSACTION_SECURITY_POLICY, 338 | 'translations' : TRANSLATIONS, 339 | 'userCriterias' : USER_CRITERIA, 340 | 'waveApplications' : WAVE_APPLICATION, 341 | 'waveDataflows' : WAVE_DATAFLOW, 342 | 'waveDashboards' : WAVE_DASHBOARD, 343 | 'waveDatasets' : WAVE_DATASET, 344 | 'waveLens' : WAVE_LENS, 345 | 'waveTemplateBundles' : WAVE_TEMPLATE_BUNDLE, 346 | 'waveXmds' : WAVE_XMD, 347 | 'workflows' : WORKFLOW 348 | } 349 | -------------------------------------------------------------------------------- /salesforce_api/core.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | import requests 4 | from url_normalize import url_normalize 5 | 6 | from .const import API_VERSION 7 | from .const.service import VERB 8 | from .utils.misc import get_session, join_path 9 | 10 | 11 | class Connection: 12 | def __init__(self, version: str = API_VERSION, access_token: str = None, instance_url: str = None, session: requests.Session = None): 13 | self.version = version 14 | self.access_token = access_token 15 | self.instance_url = instance_url 16 | self.session = get_session(session) 17 | 18 | def request(self, verb: VERB, uri: Union[str, None], **kwargs) -> requests.Response: 19 | if 'url' not in kwargs: 20 | kwargs['url'] = join_path(self.instance_url, uri).format(version=self.version) 21 | kwargs['url'] = url_normalize(kwargs['url']) 22 | return self.session.request(verb.value, **kwargs) 23 | -------------------------------------------------------------------------------- /salesforce_api/data/soap_messages/deploy/cancel.msg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | {client_name} 7 | 8 | 9 | {session_id} 10 | 11 | 12 | 13 | 14 | {async_process_id} 15 | 16 | 17 | -------------------------------------------------------------------------------- /salesforce_api/data/soap_messages/deploy/deploy.msg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | {client_name} 7 | 8 | 9 | {session_id} 10 | 11 | 12 | 13 | 14 | {zip_file} 15 | 16 | {options} 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /salesforce_api/data/soap_messages/deploy/status.msg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | {client_name} 7 | 8 | 9 | {session_id} 10 | 11 | 12 | 13 | 14 | {async_process_id} 15 | true 16 | 17 | 18 | -------------------------------------------------------------------------------- /salesforce_api/data/soap_messages/login/login.msg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | {username} 7 | {password} 8 | 9 | 10 | -------------------------------------------------------------------------------- /salesforce_api/data/soap_messages/retrieve/retrieve.msg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | {client_name} 7 | 8 | 9 | {session_id} 10 | 11 | 12 | 13 | 14 | 15 | {api_version} 16 | {single_package} 17 | {unpackaged} 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /salesforce_api/data/soap_messages/retrieve/status.msg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | {client_name} 7 | 8 | 9 | {session_id} 10 | 11 | 12 | 13 | 14 | {async_process_id} 15 | {include_zip} 16 | 17 | 18 | -------------------------------------------------------------------------------- /salesforce_api/exceptions.py: -------------------------------------------------------------------------------- 1 | class SalesforceBaseError(Exception): 2 | pass 3 | 4 | 5 | class AuthenticationError(SalesforceBaseError): 6 | pass 7 | 8 | 9 | class ServiceConnectionError(SalesforceBaseError): 10 | pass 11 | 12 | 13 | class ServiceConnectionResourceNotFoundError(SalesforceBaseError): 14 | pass 15 | 16 | 17 | class AuthenticationInvalidClientIdError(AuthenticationError): 18 | pass 19 | 20 | 21 | class AuthenticationInvalidClientSecretError(AuthenticationError): 22 | pass 23 | 24 | 25 | class AuthenticationMissingTokenError(AuthenticationError): 26 | pass 27 | 28 | 29 | class NodeNotFoundError(SalesforceBaseError): 30 | pass 31 | 32 | 33 | class NotTextFound(SalesforceBaseError): 34 | pass 35 | 36 | 37 | class RequestFailedError(SalesforceBaseError): 38 | pass 39 | 40 | 41 | class ZipNotAvailableError(SalesforceBaseError): 42 | pass 43 | 44 | 45 | class NoEntriesError(SalesforceBaseError): 46 | pass 47 | 48 | 49 | class MultipleDifferentHeadersError(SalesforceBaseError): 50 | pass 51 | 52 | 53 | class BulkEmptyRowsError(SalesforceBaseError): 54 | pass 55 | 56 | 57 | class BulkCouldNotCreateJobError(SalesforceBaseError): 58 | pass 59 | 60 | 61 | class BulkJobFailedError(SalesforceBaseError): 62 | pass 63 | 64 | 65 | class DeployCreateError(SalesforceBaseError): 66 | pass 67 | 68 | 69 | class RetrieveCreateError(SalesforceBaseError): 70 | pass 71 | 72 | 73 | class RetrieveNotDone(SalesforceBaseError): 74 | pass 75 | 76 | 77 | class RestRequestCouldNotBeUnderstoodError(SalesforceBaseError): 78 | pass 79 | 80 | 81 | class RestSessionHasExpiredError(SalesforceBaseError): 82 | pass 83 | 84 | 85 | class RestRequestRefusedError(SalesforceBaseError): 86 | pass 87 | 88 | 89 | class RestResourceNotFoundError(SalesforceBaseError): 90 | pass 91 | 92 | 93 | class RestMethodNotAllowedForResourceError(SalesforceBaseError): 94 | pass 95 | 96 | 97 | class RestNotWellFormattedEntityInRequestError(SalesforceBaseError): 98 | pass 99 | 100 | 101 | class RestSalesforceInternalServerError(SalesforceBaseError): 102 | pass 103 | -------------------------------------------------------------------------------- /salesforce_api/login.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Union 3 | 4 | import requests 5 | from . import core, exceptions 6 | from .utils import misc as misc_utils 7 | from .utils import soap as soap_utils 8 | 9 | 10 | def magic(domain: str = None, username: str = None, password: str = None, security_token: str = None, 11 | password_and_security_token: str = None, client_id: str = None, client_secret: str = None, 12 | access_token: str = None, session: requests.Session = None, is_sandbox=False, api_version: str = None) -> core.Connection: 13 | session = misc_utils.get_session(session) 14 | # Determine instance url 15 | if domain is None: 16 | domain = 'test.salesforce.com' if is_sandbox else 'login.salesforce.com' 17 | instance_url = f'https://{domain}' 18 | 19 | # Figure out how to authenticate 20 | if username: 21 | return soap( 22 | instance_url=instance_url, 23 | username=username, 24 | password=password, 25 | security_token=security_token, 26 | password_and_security_token=password_and_security_token, 27 | session=session, 28 | api_version=api_version 29 | ) 30 | elif client_id and client_secret: 31 | return oauth2( 32 | instance_url=instance_url, 33 | client_id=client_id, 34 | client_secret=client_secret, 35 | username=username, 36 | password=password, 37 | session=session, 38 | api_version=api_version 39 | ) 40 | elif access_token: 41 | return plain_access_token( 42 | instance_url=instance_url, 43 | access_token=access_token, 44 | session=session, 45 | api_version=api_version 46 | ) 47 | 48 | # Could not decide 49 | raise exceptions.AuthenticationError('Not enough information to select authentication-method') 50 | 51 | 52 | def plain_access_token(instance_url: str, access_token: str, session: requests.Session, api_version: str = None) -> core.Connection: 53 | return core.Connection( 54 | version=misc_utils.decide_version(api_version), 55 | access_token=access_token, 56 | instance_url=instance_url, 57 | session=misc_utils.get_session(session) 58 | ) 59 | 60 | 61 | def oauth2(instance_url: str, client_id: str, client_secret: str, username: Union[str, None] = None, password: Union[str, None] = None, 62 | session: Union[requests.Session, None] = None, api_version: Union[str, None] = None) -> core.Connection: 63 | session = misc_utils.get_session(session) 64 | url = f'{instance_url}/services/oauth2/token' 65 | if username and password: 66 | response = session.post(url, data=dict( 67 | grant_type='password', 68 | client_id=client_id, 69 | client_secret=client_secret, 70 | username=username, 71 | password=password, 72 | )) 73 | else: 74 | response = session.post( 75 | url, 76 | data=dict(grant_type='client_credentials'), 77 | auth=(client_id, client_secret), 78 | ) 79 | response_json = response.json() 80 | 81 | if response_json.get('error') == 'invalid_client_id': 82 | raise exceptions.AuthenticationInvalidClientIdError 83 | elif response_json.get('error') == 'invalid_client': 84 | raise exceptions.AuthenticationInvalidClientSecretError 85 | elif response.status_code != 200: 86 | raise exceptions.AuthenticationError(f'Status-code {response.status_code} returned while trying to authenticate') 87 | 88 | return plain_access_token(response_json['instance_url'], access_token=response_json['access_token'], session=session, api_version=api_version) 89 | 90 | 91 | def soap(instance_url: str, username: str, password: str = None, security_token: str = None, 92 | password_and_security_token: str = None, session: requests.Session = None, 93 | api_version: str = None) -> core.Connection: 94 | if not ((password and security_token) or password_and_security_token): 95 | raise exceptions.AuthenticationError('password and security_token or password_and_security_token required') 96 | session = misc_utils.get_session(session) 97 | instance_url = f'{instance_url}/services/Soap/c/{misc_utils.decide_version(api_version)}' 98 | 99 | body = soap_utils.get_message('login/login.msg').format( 100 | username=username, 101 | password=password_and_security_token or password + security_token 102 | ) 103 | 104 | response = soap_utils.Result(session.post(instance_url, headers={ 105 | 'Content-Type': 'text/xml', 106 | 'SOAPAction': 'login' 107 | }, data=body).text) 108 | 109 | if response.has('soapenv:Envelope/soapenv:Body/loginResponse/result/sessionId'): 110 | session_id = response.get_value('soapenv:Envelope/soapenv:Body/loginResponse/result/sessionId') 111 | server_url = response.get_value('soapenv:Envelope/soapenv:Body/loginResponse/result/serverUrl') 112 | instance = re.match(r'(https://(.*).salesforce\.com/)', server_url).group(1) 113 | return plain_access_token(access_token=session_id, instance_url=instance, session=session, api_version=api_version) 114 | 115 | if not response.has('soapenv:Envelope/soapenv:Body/soapenv:Fault/faultcode'): 116 | raise exceptions.AuthenticationError 117 | code = response.get_value('soapenv:Envelope/soapenv:Body/soapenv:Fault/faultcode') 118 | if code.find('LOGIN_MUST_USE_SECURITY_TOKEN'): 119 | raise exceptions.AuthenticationMissingTokenError('Missing or invalid security-token provided.') 120 | raise exceptions.AuthenticationError 121 | -------------------------------------------------------------------------------- /salesforce_api/models/__init__.py: -------------------------------------------------------------------------------- 1 | from . import bulk, deploy, retrieve, tooling 2 | 3 | 4 | __all__ = [ 5 | 'bulk', 6 | 'deploy', 7 | 'retrieve', 8 | 'tooling', 9 | 'shared' 10 | ] 11 | -------------------------------------------------------------------------------- /salesforce_api/models/base.py: -------------------------------------------------------------------------------- 1 | class Model: 2 | def as_dict(self): 3 | return self.__dict__ 4 | 5 | def __repr__(self) -> str: 6 | return '<{name} {attributes} />'.format( 7 | name=self.__class__.__name__, 8 | attributes=' '.join(f'{k}="{v}"' for k, v in self.__dict__.items()) 9 | ) 10 | -------------------------------------------------------------------------------- /salesforce_api/models/bulk.py: -------------------------------------------------------------------------------- 1 | from . import base 2 | 3 | 4 | class ResultRecord(base.Model): 5 | def __init__(self, record_id: str, success: bool, data: dict): 6 | self.record_id = record_id 7 | self.success = success 8 | self.data = { 9 | key: value 10 | for key, value in data.items() 11 | if not key.startswith('sf__') 12 | } 13 | 14 | 15 | class SuccessResultRecord(ResultRecord): 16 | def __init__(self, record_id, data): 17 | super().__init__(record_id, True, data) 18 | 19 | 20 | class FailResultRecord(ResultRecord): 21 | def __init__(self, record_id, error, data): 22 | super().__init__(record_id, False, data) 23 | self.error = error 24 | -------------------------------------------------------------------------------- /salesforce_api/models/deploy.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from .. import const 3 | from . import base 4 | 5 | 6 | #pylint: disable=invalid-name 7 | class Options(base.Model): 8 | def __init__(self, **kwargs): 9 | self.allowMissingFiles = False 10 | self.autoUpdatePackage = False 11 | self.checkOnly = False 12 | self.ignoreWarnings = False 13 | self.performRetrieve = False 14 | self.purgeOnDelete = False 15 | self.rollbackOnError = True 16 | self.runTests = [] 17 | self.singlePackage = True 18 | self.testLevel = 'RunLocalTests' 19 | 20 | self.set_values(**kwargs) 21 | 22 | def set_values(self, **kwargs): 23 | acceptable = vars(self) 24 | specials = ['runTests', 'testLevel'] 25 | 26 | for key in kwargs: 27 | if key not in acceptable: 28 | raise Exception('Invalid option') 29 | elif key == 'testLevel' and kwargs[key] not in const.TEST_LEVELS: 30 | raise Exception('Invalid test-level') 31 | elif key == 'runTests' and not isinstance(kwargs[key], list): 32 | raise Exception('Tests must be specified as a list') 33 | elif key not in specials and not isinstance(kwargs[key], bool): 34 | raise Exception(f'Invalid option value for {key}') 35 | 36 | self.__setattr__(key, kwargs[key]) 37 | 38 | def as_xml(self): 39 | return '\n'.join( 40 | self._get_data_for_key(key, value) 41 | for key, value in vars(self).items() 42 | ) 43 | 44 | def _get_data_for_key(self, key, value): 45 | if key == 'runTests': 46 | return ''.join( 47 | f'{test}' 48 | for test in value 49 | ) 50 | return f'{value}' 51 | 52 | 53 | class Status(base.Model): 54 | def __init__(self, status, details, components: 'DeployDetails', tests: 'DeployDetails'): 55 | self.status = status 56 | self.details = details 57 | self.components = components 58 | self.tests = tests 59 | 60 | 61 | class _Failure(base.Model): 62 | pass 63 | 64 | 65 | class DeployDetails(base.Model): 66 | def __init__(self, total_count: int, failed_count: int, completed_count: int, failures: List[_Failure] = None): 67 | self.total_count = total_count 68 | self.failed_count = failed_count 69 | self.completed_count = completed_count 70 | self.failures = failures or [] 71 | 72 | def append_failure(self, failure: _Failure): 73 | self.failures.append(failure) 74 | 75 | 76 | class ComponentFailure(_Failure): 77 | def __init__(self, component_type, file, status, message): 78 | self.component_type = component_type 79 | self.file = file 80 | self.status = status 81 | self.message = message 82 | 83 | 84 | class UnitTestFailure(_Failure): 85 | def __init__(self, class_name, method, message, stack_trace): 86 | self.class_name = class_name 87 | self.method = method 88 | self.message = message 89 | self.stack_trace = stack_trace 90 | -------------------------------------------------------------------------------- /salesforce_api/models/retrieve.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from . import base 3 | 4 | 5 | class Options(base.Model): 6 | def __init__(self): 7 | self.single_package = True 8 | self.unpackaged = [] 9 | 10 | 11 | class StatusMessage(base.Model): 12 | def __init__(self, file: str, message: str): 13 | self.file = file 14 | self.message = message 15 | 16 | 17 | class Status(base.Model): 18 | def __init__(self, status: str, error_message: str, messages: List[StatusMessage] = None): 19 | self.status = status 20 | self.error_message = error_message 21 | self.messages = messages or [] 22 | 23 | def append_message(self, message: StatusMessage): 24 | self.messages.append(message) 25 | -------------------------------------------------------------------------------- /salesforce_api/models/shared.py: -------------------------------------------------------------------------------- 1 | class Type: 2 | def __init__(self, name, members: list = None): 3 | self.name = name 4 | self.members = members or ['*'] 5 | -------------------------------------------------------------------------------- /salesforce_api/models/tooling.py: -------------------------------------------------------------------------------- 1 | from . import base 2 | 3 | 4 | class ApexExecutionResult(base.Model): 5 | def __init__(self, input_data): 6 | self.line = input_data['line'] 7 | self.column = input_data['column'] 8 | self.compiled = input_data['compiled'] 9 | self.success = input_data['success'] 10 | self.compile_problem = input_data['compileProblem'] 11 | self.exception_stack_trace = input_data['exceptionStackTrace'] 12 | self.exception_message = input_data['exceptionMessage'] 13 | 14 | @staticmethod 15 | def create(input_data): 16 | if input_data.get('success'): 17 | return SuccessfulApexExecutionResult(input_data) 18 | return FailedApexExecutionResult(input_data) 19 | 20 | 21 | class SuccessfulApexExecutionResult(ApexExecutionResult): 22 | pass 23 | 24 | 25 | class FailedApexExecutionResult(ApexExecutionResult): 26 | pass 27 | -------------------------------------------------------------------------------- /salesforce_api/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/felixlindstrom/python-salesforce-api/8233dc5107db9c0daf7ce7e28845a627ee140a49/salesforce_api/services/__init__.py -------------------------------------------------------------------------------- /salesforce_api/services/base.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | import requests 4 | 5 | from .. import config, core, exceptions 6 | from ..const.service import VERB 7 | from ..utils import soap 8 | from ..utils.misc import join_path 9 | 10 | 11 | class _Service: 12 | def __init__(self, connection: core.Connection, base_uri: str = None): 13 | self.connection = connection 14 | self.base_uri = base_uri 15 | self._setup() 16 | 17 | def _setup(self): 18 | pass 19 | 20 | def request(self, verb: VERB, uri: Union[str, None] = None, **kwargs) -> requests.Response: 21 | return self.connection.request(verb, join_path(self.base_uri, uri), **kwargs) 22 | 23 | 24 | class RestService(_Service): 25 | def __init__(self, connection: core.Connection, base_uri: str = None): 26 | super().__init__(connection, join_path('services/data/v{version}', base_uri)) 27 | 28 | def _setup(self): 29 | self._setup_session() 30 | 31 | def _setup_session(self) -> None: 32 | self.connection.session.headers['Authorization'] = f'Bearer {self.connection.access_token}' 33 | 34 | def _request(self, verb: VERB, uri: Union[str, None] = None, **kwargs): 35 | response = self.request(verb, uri, **kwargs) 36 | return self._parse_response(response) 37 | 38 | def _response_is_json(self, response): 39 | return 'Content-Type' in response.headers and \ 40 | 'application/json' in response.headers['Content-Type'] 41 | 42 | def _handle_status_codes(self, response): 43 | if response.status_code < 400: 44 | return 45 | exceptions_by_code = { 46 | 400: exceptions.RestRequestCouldNotBeUnderstoodError, 47 | 401: exceptions.RestSessionHasExpiredError, 48 | 403: exceptions.RestRequestRefusedError, 49 | 404: exceptions.RestResourceNotFoundError, 50 | 405: exceptions.RestMethodNotAllowedForResourceError, 51 | 415: exceptions.RestNotWellFormattedEntityInRequestError, 52 | 500: exceptions.RestSalesforceInternalServerError 53 | } 54 | 55 | message = '' 56 | if self._response_is_json(response): 57 | if isinstance(response.json(), list) and 'errorCode' in response.json()[0]: 58 | message = response.json()[0] 59 | 60 | if response.status_code in exceptions_by_code: 61 | raise exceptions_by_code[response.status_code](message) 62 | 63 | def _parse_response(self, response: requests.Response): 64 | self._handle_status_codes(response) 65 | try: 66 | return response.json() 67 | except requests.JSONDecodeError: 68 | return response.text 69 | 70 | def _get(self, uri: Union[str, None] = None, **kwargs): 71 | return self._request(VERB.GET, uri, **kwargs) 72 | 73 | def _post(self, uri: Union[str, None] = None, **kwargs): 74 | return self._request(VERB.POST, uri, **kwargs) 75 | 76 | def _put(self, uri: Union[str, None] = None, **kwargs): 77 | return self._request(VERB.PUT, uri, **kwargs) 78 | 79 | def _patch(self, uri: Union[str, None] = None, **kwargs): 80 | return self._request(VERB.PATCH, uri, **kwargs) 81 | 82 | def _delete(self, uri: Union[str, None] = None, **kwargs): 83 | return self._request(VERB.DELETE, uri, **kwargs) 84 | 85 | 86 | class AsyncService(_Service): 87 | def __init__(self, connection: core.Connection, base_uri: str = None): 88 | super().__init__(connection, join_path('services/async/{version}', base_uri)) 89 | 90 | def _setup(self): 91 | self._setup_session() 92 | 93 | def _setup_session(self) -> None: 94 | self.connection.session.headers['X-SFDC-Session'] = self.connection.access_token 95 | 96 | def _request(self, verb: VERB, uri: Union[str, None] = None, **kwargs): 97 | response = self.request(verb, uri, **kwargs) 98 | return self._parse_response(response) 99 | 100 | def _parse_response(self, response: requests.Response): 101 | try: 102 | return response.json() 103 | except requests.JSONDecodeError: 104 | return response.text 105 | 106 | def _get(self, uri: Union[str, None] = None, **kwargs): 107 | return self._request(VERB.GET, uri, **kwargs) 108 | 109 | def _post(self, uri: Union[str, None] = None, **kwargs): 110 | return self._request(VERB.POST, uri, **kwargs) 111 | 112 | 113 | class SoapService(_Service): 114 | def __init__(self, connection: core.Connection): 115 | super().__init__(connection, 'services/Soap/m/{version}') 116 | 117 | def _extend_attributes(self, attributes: dict) -> dict: 118 | return {**attributes, **{ 119 | 'client_name': config.CLIENT_NAME, 120 | 'session_id': self.connection.access_token 121 | }} 122 | 123 | def _prepare_message(self, message_path: str, attributes: dict) -> str: 124 | return soap.get_message(message_path) \ 125 | .format_map(self._extend_attributes(attributes)) 126 | 127 | def _post(self, action=None, message_path=None, message_attributes=None) -> soap.Result: 128 | data = self._prepare_message(message_path, message_attributes or {}) 129 | result = self.request(VERB.POST, data=data, headers={ 130 | 'Content-type': 'text/xml', 131 | 'SOAPAction': action 132 | }) 133 | return soap.Result(result.text) 134 | -------------------------------------------------------------------------------- /salesforce_api/services/basic.py: -------------------------------------------------------------------------------- 1 | from . import base 2 | 3 | 4 | class Basic(base.RestService): 5 | def __init__(self, connection): 6 | super().__init__(connection) 7 | 8 | def versions(self) -> dict: 9 | return self._get('..') 10 | 11 | def resources(self) -> dict: 12 | return self._get() 13 | 14 | def limits(self) -> dict: 15 | return self._get('limits') 16 | -------------------------------------------------------------------------------- /salesforce_api/services/bulk/__init__.py: -------------------------------------------------------------------------------- 1 | from . import v1 2 | from . import v2 3 | from .default import * -------------------------------------------------------------------------------- /salesforce_api/services/bulk/base.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import List 3 | 4 | from ...models import bulk as models 5 | 6 | 7 | class Client(abc.ABC): 8 | @abc.abstractmethod 9 | def insert(self, object_name: str, entries: List[dict]) -> List[models.ResultRecord]: 10 | pass 11 | 12 | def update(self, object_name: str, entries: List[dict]) -> List[models.ResultRecord]: 13 | pass 14 | 15 | def upsert(self, object_name: str, entries: List[dict], external_id_field_name: str = 'Id') -> List[models.ResultRecord]: 16 | pass 17 | 18 | def select(self, **kwargs): 19 | pass 20 | 21 | def delete(self, object_name: str, ids: List[str]) -> List[models.ResultRecord]: 22 | pass 23 | -------------------------------------------------------------------------------- /salesforce_api/services/bulk/default.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from . import v1, v2 4 | from ...models import bulk as models 5 | from ... import config 6 | 7 | 8 | def get_default_client(): 9 | return v1.Client if config.BULK_VERSION == 1 else v2.Client 10 | 11 | 12 | class Client(get_default_client()): 13 | pass 14 | 15 | 16 | class BulkObject: 17 | def __init__(self, object_name, connection): 18 | self.object_name = object_name 19 | self.bulk_service = get_default_client()(connection) 20 | 21 | def insert(self, entries: List[dict]) -> List[models.ResultRecord]: 22 | return self.bulk_service.insert(self.object_name, entries) 23 | 24 | def delete(self, ids: List[str]) -> List[models.ResultRecord]: 25 | return self.bulk_service.delete(self.object_name, ids) 26 | 27 | def update(self, entries: List[dict]) -> List[models.ResultRecord]: 28 | return self.bulk_service.update(self.object_name, entries) 29 | 30 | def upsert(self, entries: List[dict], external_id_field_name='Id') -> List[models.ResultRecord]: 31 | return self.bulk_service.upsert(self.object_name, entries, external_id_field_name) -------------------------------------------------------------------------------- /salesforce_api/services/bulk/v1.py: -------------------------------------------------------------------------------- 1 | import json 2 | import time 3 | from enum import Enum 4 | from typing import List 5 | 6 | from . import base as bulk_base 7 | from .. import base 8 | from ... import config, exceptions 9 | from ...models import bulk as models 10 | 11 | 12 | class OPERATION(Enum): 13 | DELETE = 'delete' 14 | INSERT = 'insert' 15 | QUERY = 'query' 16 | QUERY_ALL = 'queryall' 17 | UPSERT = 'upsert' 18 | UPDATE = 'update' 19 | HARD_DELETE = 'hardDelete' 20 | 21 | 22 | class JOB_STATE(Enum): 23 | OPEN = 'Open' 24 | CLOSED = 'Closed' 25 | ABORTED = 'Aborted' 26 | FAILED = 'Failed' 27 | 28 | 29 | class BATCH_STATE(Enum): 30 | QUEUED = 'Queued' 31 | IN_PROGRESS = 'InProgress' 32 | COMPLETED = 'Completed' 33 | FAILED = 'Failed' 34 | NOT_PROCESSED = 'Not Processed' 35 | 36 | 37 | BATCH_STATE_DONE = [BATCH_STATE.COMPLETED, BATCH_STATE.FAILED] 38 | 39 | 40 | class Client(bulk_base.Client, base.AsyncService): 41 | def insert(self, object_name: str, entries: List[dict]) -> List[models.ResultRecord]: 42 | return self._execute_operation(OPERATION.INSERT, object_name, entries) 43 | 44 | def update(self, object_name: str, entries: List[dict]) -> List[models.ResultRecord]: 45 | return self._execute_operation(OPERATION.UPDATE, object_name, entries) 46 | 47 | def upsert(self, object_name: str, entries: List[dict], external_id_field_name: str = 'Id') -> List[models.ResultRecord]: 48 | return self._execute_operation(OPERATION.UPSERT, object_name, entries, external_id_field_name) 49 | 50 | def select(self, **kwargs): 51 | raise NotImplementedError 52 | 53 | def delete(self, object_name: str, ids: List[str]) -> List[models.ResultRecord]: 54 | return self._execute_operation(OPERATION.DELETE, object_name, [{'Id': id} for id in ids]) 55 | 56 | def _execute_operation(self, operation: OPERATION, object_name: str, entries: List[dict], external_id_field_name: str = None) -> List[models.ResultRecord]: 57 | job = Job.create(self.connection, operation, object_name, external_id_field_name) 58 | job.upload(entries) 59 | return job.wait() 60 | 61 | 62 | class Job(base.AsyncService): 63 | def __init__(self, connection, job_id): 64 | super().__init__(connection, f'job/{job_id}') 65 | self.job_id = job_id 66 | self.batches = [] 67 | 68 | def _set_state(self, new_state: JOB_STATE): 69 | result = self._post(json={ 70 | 'state': new_state.value 71 | }) 72 | 73 | def upload(self, entries: List[dict]): 74 | self.add_batch(entries) 75 | self.close() 76 | 77 | def add_batch(self, entries: List[dict]): 78 | return self.batches.append( 79 | Batch.create(self.connection, self.job_id, entries) 80 | ) 81 | 82 | def close(self): 83 | return self._set_state(JOB_STATE.CLOSED) 84 | 85 | def get_result(self) -> List[models.ResultRecord]: 86 | results = [batch.get_result() for batch in self.batches] 87 | return [item for sublist in results for item in sublist] 88 | 89 | def get_errors(self): 90 | return [ 91 | batch.get_state_message() 92 | for batch in self.batches 93 | if batch.is_failed() 94 | ] 95 | 96 | def wait(self) -> List[models.ResultRecord]: 97 | batch_states = [batch.get_state() for batch in self.batches] 98 | 99 | for state in batch_states: 100 | if state not in BATCH_STATE_DONE: 101 | time.sleep(config.BULK_SLEEP_SECONDS) 102 | return self.wait() 103 | 104 | if BATCH_STATE.FAILED in batch_states: 105 | raise exceptions.BulkJobFailedError('One or more batches failed') 106 | 107 | return self.get_result() 108 | 109 | @classmethod 110 | def create(cls, connection, operation: OPERATION, object_name: str, external_id_field_name: str = None): 111 | result = base.AsyncService(connection, 'job')._post(json={ 112 | 'operation': operation.value, 113 | 'object': object_name, 114 | 'contentType': 'JSON', 115 | 'externalIdFieldName': external_id_field_name 116 | }) 117 | return cls(connection, result['id']) 118 | 119 | 120 | class Batch(base.AsyncService): 121 | def __init__(self, connection, job_id, batch_id): 122 | super().__init__(connection, f'job/{job_id}/batch/{batch_id}') 123 | 124 | def get_info(self): 125 | return self._get() 126 | 127 | def get_state(self) -> BATCH_STATE: 128 | return BATCH_STATE(self.get_info().get('state')) 129 | 130 | def get_state_message(self) -> BATCH_STATE: 131 | return self.get_info().get('stateMessage') 132 | 133 | def is_done(self): 134 | return self.get_state() in BATCH_STATE_DONE 135 | 136 | def is_failed(self): 137 | return self.get_state() == BATCH_STATE.FAILED 138 | 139 | def get_result(self) -> List[models.ResultRecord]: 140 | reader = self._get('result') 141 | return [ 142 | self._convert_result(x) 143 | for x in reader 144 | ] 145 | 146 | def _convert_result(self, row): 147 | if row['success']: 148 | return models.SuccessResultRecord(row['id'], row) 149 | error = ', '.join( 150 | x['message'] 151 | for x in row['errors'] 152 | if x['message'] is not None 153 | ) 154 | return models.FailResultRecord(row['id'], error, row) 155 | 156 | def wait(self) -> List[models.ResultRecord]: 157 | while not self.is_done(): 158 | time.sleep(config.BULK_SLEEP_SECONDS) 159 | if self.get_state() == BATCH_STATE.FAILED: 160 | raise exceptions.BulkJobFailedError(self.get_state_message()) 161 | return self.get_result() 162 | 163 | @classmethod 164 | def create(cls, connection, job_id, entries): 165 | result = base.AsyncService(connection, f'job/{job_id}/batch')._post( 166 | data=json.dumps(entries, default=str), 167 | headers={'Content-type': 'application/json'} 168 | ) 169 | return cls(connection, job_id, result['id']) 170 | -------------------------------------------------------------------------------- /salesforce_api/services/bulk/v2.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import json 3 | import time 4 | from enum import Enum 5 | from typing import List 6 | 7 | from . import base as bulk_base 8 | from .. import base 9 | from ... import config, exceptions 10 | from ...const.service import VERB 11 | from ...models import bulk as models 12 | from ...utils import bulk as bulk_utils 13 | 14 | 15 | class OPERATION(Enum): 16 | INSERT = 'insert' 17 | UPDATE = 'update' 18 | UPSERT = 'upsert' 19 | DELETE = 'delete' 20 | SELECT = 'select' 21 | 22 | 23 | class JOB_STATE(Enum): 24 | OPEN = 'Open' 25 | UPLOAD_COMPLETE = 'UploadComplete' 26 | ABORTED = 'Aborted' 27 | JOB_COMPLETE = 'JobComplete' 28 | FAILED = 'Failed' 29 | IN_PROGRESS = 'InProgress' 30 | 31 | 32 | JOB_STATES_DONE = [JOB_STATE.JOB_COMPLETE, JOB_STATE.ABORTED, JOB_STATE.FAILED] 33 | JOB_STATES_FAIL = [JOB_STATE.ABORTED, JOB_STATE.FAILED] 34 | 35 | 36 | class Client(bulk_base.Client, base.RestService): 37 | def __init__(self, connection): 38 | super().__init__(connection, 'jobs/ingest') 39 | 40 | def insert(self, object_name: str, entries: List[dict]) -> List[models.ResultRecord]: 41 | return self._execute_operation(OPERATION.INSERT, object_name, entries) 42 | 43 | def update(self, object_name: str, entries: List[dict]) -> List[models.ResultRecord]: 44 | return self._execute_operation(OPERATION.UPDATE, object_name, entries) 45 | 46 | def upsert(self, object_name: str, entries: List[dict], external_id_field_name: str = 'Id') -> List[models.ResultRecord]: 47 | return self._execute_operation(OPERATION.UPSERT, object_name, entries, external_id_field_name) 48 | 49 | def select(self, **kwargs): 50 | raise NotImplementedError 51 | 52 | def delete(self, object_name: str, ids: List[str]) -> List[models.ResultRecord]: 53 | return self._execute_operation(OPERATION.DELETE, object_name, [{'Id': id} for id in ids]) 54 | 55 | def _execute_operation(self, operation: OPERATION, object_name: str, entries: List[dict], external_id_field_name: str = None) -> List[models.ResultRecord]: 56 | job = Job.create(self.connection, operation, object_name, external_id_field_name) 57 | job.upload(entries) 58 | return job.wait() 59 | 60 | 61 | class Job(base.RestService): 62 | def __init__(self, connection, job_id): 63 | super().__init__(connection, f'jobs/ingest/{job_id}') 64 | self.job_id = job_id 65 | 66 | def _set_state(self, new_state: JOB_STATE): 67 | return self._patch(json={'state': new_state.value}) 68 | 69 | def _prepare_data(self, entries): 70 | return bulk_utils.FilePreparer(entries).get_csv_string() 71 | 72 | def upload(self, entries): 73 | try: 74 | self._put('batches', data=self._prepare_data(entries), headers={ 75 | 'Content-Type': 'text/csv' 76 | }) 77 | except json.decoder.JSONDecodeError: 78 | pass 79 | self._set_state(JOB_STATE.UPLOAD_COMPLETE) 80 | return True 81 | 82 | def close(self): 83 | return self._set_state(JOB_STATE.UPLOAD_COMPLETE) 84 | 85 | def abort(self): 86 | return self._set_state(JOB_STATE.ABORTED) 87 | 88 | def delete(self): 89 | return self._delete() 90 | 91 | def info(self): 92 | return self._get() 93 | 94 | def get_state(self) -> JOB_STATE: 95 | return JOB_STATE(self.info().get('state')) 96 | 97 | def is_done(self) -> bool: 98 | return self.get_state() in JOB_STATES_DONE 99 | 100 | def _get_results(self, uri: str, callback): 101 | response = self.request(VERB.GET, uri) 102 | reader = csv.DictReader(response.iter_lines(decode_unicode=True)) 103 | return [callback(x) for x in reader] 104 | 105 | def get_successful_results(self) -> List[models.ResultRecord]: 106 | return self._get_results('successfulResults', lambda x: models.SuccessResultRecord(x['sf__Id'], x)) 107 | 108 | def get_failed_results(self) -> List[models.ResultRecord]: 109 | return self._get_results('failedResults', lambda x: models.FailResultRecord(x['sf__Id'], x['sf__Error'], x)) 110 | 111 | def get_unprocessed_records(self) -> List[models.ResultRecord]: 112 | raise NotImplementedError 113 | 114 | def wait(self) -> List[models.ResultRecord]: 115 | while not self.is_done(): 116 | time.sleep(config.BULK_SLEEP_SECONDS) 117 | 118 | if self.get_state() in JOB_STATES_FAIL: 119 | raise exceptions.BulkJobFailedError(self.info().get('errorMessage')) 120 | 121 | return self.get_failed_results() + \ 122 | self.get_successful_results() 123 | 124 | @classmethod 125 | def create(cls, connection, operation: OPERATION, object_name: str, external_id_field_name: str = None) -> 'Job': 126 | result = base.RestService(connection, 'jobs/ingest')._post(json={ 127 | 'columnDelimiter': 'COMMA', 128 | 'contentType': 'CSV', 129 | 'lineEnding': 'LF', 130 | 'object': object_name, 131 | 'operation': operation.value, 132 | 'externalIdFieldName': external_id_field_name 133 | }) 134 | return Job(connection, result.get('id')) 135 | -------------------------------------------------------------------------------- /salesforce_api/services/deploy.py: -------------------------------------------------------------------------------- 1 | import time 2 | from base64 import b64encode 3 | from pathlib import Path 4 | 5 | from .. import exceptions, const, config 6 | from ..utils import misc 7 | from ..models import deploy as models 8 | from . import base 9 | 10 | 11 | class Deploy(base.SoapService): 12 | def _get_zip_content(self, input_file) -> str: 13 | try: 14 | s = input_file.read() 15 | except AttributeError: 16 | s = Path(input_file).read_bytes() 17 | return b64encode(s).decode() 18 | 19 | def deploy(self, input_zip, options: models.Options = models.Options()) -> 'Deployment': 20 | result = self._post(action='deploy', message_path='deploy/deploy.msg', message_attributes={ 21 | 'zip_file': self._get_zip_content(input_zip), 22 | 'options': options.as_xml() 23 | }) 24 | if result.has('soapenv:Envelope/soapenv:Body/soapenv:Fault/faultcode'): 25 | raise exceptions.DeployCreateError(result.get_value('soapenv:Envelope/soapenv:Body/soapenv:Fault/faultstring')) 26 | return Deployment(self, result.get_value('soapenv:Envelope/soapenv:Body/deployResponse/result/id')) 27 | 28 | def check_deploy_status(self, async_process_id: str) -> models.Status: 29 | result = self._post(action='checkDeployStatus', message_path='deploy/status.msg', message_attributes={ 30 | 'async_process_id': async_process_id 31 | }) 32 | 33 | result = result.get('soapenv:Envelope/soapenv:Body/checkDeployStatusResponse/result') 34 | 35 | status = models.Status(result.get_value('status'), result.get_value('stateDetail', None), models.DeployDetails( 36 | int(result.get_value('numberComponentsTotal')), 37 | int(result.get_value('numberComponentErrors')), 38 | int(result.get_value('numberComponentsDeployed')) 39 | ), models.DeployDetails( 40 | int(result.get_value('numberTestsTotal')), 41 | int(result.get_value('numberTestErrors')), 42 | int(result.get_value('numberTestsCompleted')) 43 | )) 44 | 45 | if status.status.lower().strip() == 'failed': 46 | for failure in result.get_list('details/componentFailures'): 47 | status.components.append_failure(models.ComponentFailure( 48 | failure.get('componentType'), 49 | failure.get('fileName'), 50 | failure.get('problemType'), 51 | failure.get('problem') 52 | )) 53 | 54 | for failure in result.get_list('details/runTestResult/failures'): 55 | status.tests.append_failure(models.UnitTestFailure( 56 | failure.get('name'), 57 | failure.get('methodName'), 58 | failure.get('message'), 59 | failure.get('stackTrace') 60 | )) 61 | 62 | return status 63 | 64 | def cancel(self, async_process_id: str) -> bool: 65 | result = self._post(action='cancelDeploy', message_path='deploy/cancel.msg', message_attributes={ 66 | 'async_process_id': async_process_id 67 | }) 68 | return misc.parse_bool(result.get_value('soapenv:Envelope/soapenv:Body/cancelDeployResponse/result/done')) 69 | 70 | 71 | class Deployment: 72 | def __init__(self, deploy_service: Deploy, async_process_id: str): 73 | self.deploy_service = deploy_service 74 | self.async_process_id = async_process_id 75 | self.start_time = time.time() 76 | 77 | def get_elapsed_seconds(self): 78 | return time.time() - self.start_time 79 | 80 | def get_elapsed_time(self): 81 | return time.strftime("%H:%M:%S", time.gmtime(self.get_elapsed_seconds())) 82 | 83 | def cancel(self) -> bool: 84 | return self.deploy_service.cancel(self.async_process_id) 85 | 86 | def get_status(self) -> models.Status: 87 | return self.deploy_service.check_deploy_status(self.async_process_id) 88 | 89 | def is_done(self): 90 | return self.get_status().status in const.STATUSES_DONE 91 | 92 | def has_failed(self): 93 | return self.get_status().status == const.STATUS_FAILED 94 | 95 | def has_succeeded(self): 96 | return self.get_status().status == const.STATUS_SUCCEEDED 97 | 98 | def wait(self, tick: callable = None): 99 | while True: 100 | status = self.get_status() 101 | if tick is not None and callable(tick): 102 | tick(status) 103 | if status.status in const.STATUSES_DONE: 104 | break 105 | time.sleep(config.DEPLOY_SLEEP_SECONDS) 106 | -------------------------------------------------------------------------------- /salesforce_api/services/retrieve.py: -------------------------------------------------------------------------------- 1 | import time 2 | from base64 import b64decode 3 | from io import BytesIO 4 | from typing import List 5 | from .. import exceptions, const, config 6 | from ..utils import soap 7 | from ..models import retrieve as retrieve_models 8 | from ..models import shared as shared_models 9 | from . import base 10 | 11 | 12 | class Retrieve(base.SoapService): 13 | def retrieve(self, types: List[shared_models.Type], options: retrieve_models.Options = retrieve_models.Options()) -> 'Retrievement': 14 | result = self._post(action='retrieve', message_path='retrieve/retrieve.msg', message_attributes={ 15 | 'api_version': self.connection.version, 16 | 'single_package': options.single_package, 17 | 'unpackaged': self._prepare_unpackaged_xml(types) 18 | }) 19 | if result.has('soapenv:Envelope/soapenv:Body/soapenv:Fault'): 20 | raise exceptions.RetrieveCreateError(result.get_value('soapenv:Envelope/soapenv:Body/soapenv:Fault/faultstring')) 21 | return Retrievement(self, result.get_value('soapenv:Envelope/soapenv:Body/retrieveResponse/result/id')) 22 | 23 | def _retrieve_status(self, async_process_id: str, include_zip_file: bool = False) -> soap.Result: 24 | result = self._post(action='checkRetrieveStatus', message_path='retrieve/status.msg', message_attributes={ 25 | 'async_process_id': async_process_id, 26 | 'include_zip': include_zip_file 27 | }) 28 | return result.get('soapenv:Envelope/soapenv:Body/checkRetrieveStatusResponse/result') 29 | 30 | def get_status(self, async_process_id: str) -> retrieve_models.Status: 31 | result = self._retrieve_status(async_process_id, False) 32 | status = retrieve_models.Status(result.get_value('status'), result.get_value('errorMessage')) 33 | messages = result.get('messages', []) 34 | messages = messages if isinstance(messages, list) else [messages] 35 | for message in messages: 36 | status.append_message(retrieve_models.StatusMessage( 37 | message.get('fileName'), 38 | message.get('problem') 39 | )) 40 | return status 41 | 42 | def get_zip_file(self, async_process_id: str) -> BytesIO: 43 | result = self._retrieve_status(async_process_id, True) 44 | status = result.get_value('status') 45 | if status not in const.STATUSES_DONE: 46 | raise exceptions.RetrieveNotDone() 47 | if status != 'Succeeded': 48 | raise exceptions.ZipNotAvailableError 49 | return BytesIO(b64decode(result.get_value('zipFile'))) 50 | 51 | def _prepare_unpackaged_xml(self, types: List[shared_models.Type]): 52 | output = [] 53 | for _type in types: 54 | output.append('') 55 | for member in _type.members: 56 | output.append(f'{member}') 57 | output.append(f'{_type.name}') 58 | output.append('') 59 | return '\n'.join(output) 60 | 61 | 62 | class Retrievement: 63 | def __init__(self, retrieve_service: Retrieve, async_process_id: str): 64 | self.async_process_id = async_process_id 65 | self.retrieve_service = retrieve_service 66 | 67 | def get_status(self) -> retrieve_models.Status: 68 | return self.retrieve_service.get_status(self.async_process_id) 69 | 70 | def is_done(self): 71 | return self.get_status().status in const.STATUSES_DONE 72 | 73 | def wait(self, tick: callable = None): 74 | while True: 75 | status = self.get_status() 76 | if status.status in const.STATUSES_DONE: 77 | break 78 | if tick is not None and callable(tick): 79 | tick(status) 80 | time.sleep(config.RETRIEVE_SLEEP_SECONDS) 81 | 82 | def get_zip_file(self) -> BytesIO: 83 | return self.retrieve_service.get_zip_file(self.async_process_id) 84 | -------------------------------------------------------------------------------- /salesforce_api/services/sobjects.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Iterator, List 2 | 3 | from . import base, bulk 4 | from .. import core 5 | 6 | 7 | class SObjects(base.RestService): 8 | def __init__(self, connection: core.Connection): 9 | super().__init__(connection, 'sobjects') 10 | 11 | def describe(self): 12 | return self._get() 13 | 14 | def _query(self, query_string: str, include_deleted: bool = False): 15 | return self._get('../queryAll' if include_deleted else '../query', params={'q': query_string}) 16 | 17 | def _query_more(self, next_url: str): 18 | return self._get(url=f'{self.connection.instance_url}{next_url}') 19 | 20 | def query_iter(self, query_string: str, include_deleted: bool = False) -> Iterator[Dict[str, Any]]: 21 | result = self._query(query_string, include_deleted) 22 | yield from result['records'] 23 | while not result['done']: 24 | result = self._query_more(result['nextRecordsUrl']) 25 | yield from result['records'] 26 | 27 | def query(self, query_string: str, include_deleted: bool = False) -> List[Dict[str, Any]]: 28 | return list(self.query_iter(query_string, include_deleted)) 29 | 30 | def __getattr__(self, name: str): 31 | return SObject(self.connection, name) 32 | 33 | 34 | class SObject(base.RestService): 35 | def __init__(self, connection: core.Connection, object_name: str): 36 | super().__init__(connection, f'sobjects/{object_name}') 37 | self.bulk = bulk.BulkObject(object_name, connection) 38 | 39 | def metadata(self): 40 | return self._get() 41 | 42 | def describe(self): 43 | return self._get('describe') 44 | 45 | def get(self, record_id: str): 46 | return self._get(record_id) 47 | 48 | def insert(self, data: dict): 49 | return self._post(json=data) 50 | 51 | def upsert(self, external_id_field: str, external_id_value: str, data: dict): 52 | self._patch(f'{external_id_field}/{external_id_value}', json=data) 53 | return True 54 | 55 | def update(self, record_id: str, data: dict): 56 | self._patch(record_id, json=data) 57 | return True 58 | 59 | def delete(self, record_id: str): 60 | self._delete(record_id) 61 | return True 62 | 63 | def updated(self, start: str, end: str): 64 | raise NotImplementedError 65 | 66 | def deleted(self, start: str, end: str): 67 | raise NotImplementedError 68 | -------------------------------------------------------------------------------- /salesforce_api/services/tooling.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from . import base 4 | from .. import core 5 | from ..models.tooling import ApexExecutionResult 6 | 7 | 8 | # https://developer.salesforce.com/docs/atlas.en-us.api_tooling.meta/api_tooling/intro_rest_resources.htm 9 | class Tooling(base.RestService): 10 | def __init__(self, connection: core.Connection): 11 | super().__init__(connection, 'tooling') 12 | 13 | def completions(self, sf_type: str): 14 | return self._get('completions', params={'type': sf_type}) 15 | 16 | def execute_apex(self, body: str): 17 | return ApexExecutionResult.create( 18 | self._get('executeAnonymous', params={'anonymousBody': body}) 19 | ) 20 | 21 | def execute_apex_from_file(self, file_path: str): 22 | return self.execute_apex(Path(file_path).read_text()) 23 | 24 | def query(self, query: str): 25 | return self._get('query', params={'q': query}) 26 | 27 | def run_tests_asynchronous(self): 28 | raise NotImplementedError 29 | 30 | def run_tests_synchronous(self): 31 | raise NotImplementedError 32 | 33 | def search(self, query: str): 34 | raise NotImplementedError 35 | 36 | def sobjects(self): 37 | return self._get('sobjects') 38 | 39 | def __getattr__(self, name: str): 40 | return ToolingObject(name, self.connection) 41 | 42 | 43 | class ToolingObject(base.RestService): 44 | def __init__(self, object_name: str, connection: core.Connection): 45 | super().__init__(connection, f'tooling/sobjects/{object_name}') 46 | 47 | def describe(self): 48 | return self._get('describe') 49 | 50 | def create(self, json: dict = None): 51 | return self._post(json=json) 52 | 53 | def get(self, record_id: str): 54 | return self._get(record_id) 55 | 56 | def update(self, record_id: str, json: dict = None): 57 | return self._patch(record_id, json=json) 58 | 59 | def delete(self, record_id: str): 60 | return self._delete(record_id) 61 | -------------------------------------------------------------------------------- /salesforce_api/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/felixlindstrom/python-salesforce-api/8233dc5107db9c0daf7ce7e28845a627ee140a49/salesforce_api/utils/__init__.py -------------------------------------------------------------------------------- /salesforce_api/utils/bulk.py: -------------------------------------------------------------------------------- 1 | import io 2 | import csv 3 | from typing import List 4 | from .. import exceptions 5 | 6 | 7 | class FilePreparer: 8 | def __init__(self, entries: List[dict]): 9 | self.entries = entries 10 | 11 | def _has_entries(self) -> bool: 12 | return len(self.entries) > 0 13 | 14 | def _check_headers(self) -> bool: 15 | headers = {tuple(x.keys()) for x in self.entries} 16 | return len(headers) == 1 17 | 18 | def _check_empty_rows(self) -> bool: 19 | for row in self.entries: 20 | if all(x is None or str(x).strip() == '' for x in row.values()): 21 | return False 22 | return True 23 | 24 | def get_csv_file(self) -> io.StringIO: 25 | if not self._has_entries(): 26 | raise exceptions.NoEntriesError 27 | 28 | if not self._check_headers(): 29 | raise exceptions.MultipleDifferentHeadersError('Multiple different data-structures found. Only supports one.') 30 | 31 | if not self._check_empty_rows(): 32 | raise exceptions.BulkEmptyRowsError 33 | 34 | file_handle = io.StringIO() 35 | writer = csv.writer(file_handle, delimiter=',', lineterminator='\n') 36 | writer.writerow(self.entries[0].keys()) 37 | writer.writerows([ 38 | entry.values() 39 | for entry in self.entries 40 | ]) 41 | return file_handle 42 | 43 | def get_csv_string(self) -> str: 44 | return self.get_csv_file().getvalue().strip() 45 | -------------------------------------------------------------------------------- /salesforce_api/utils/misc.py: -------------------------------------------------------------------------------- 1 | import distutils.util 2 | import hashlib 3 | from typing import Union 4 | 5 | import requests 6 | 7 | from ..const import API_VERSION 8 | 9 | 10 | def parse_bool(input_value: str) -> bool: 11 | return distutils.util.strtobool(input_value) 12 | 13 | 14 | def get_session(session: Union[requests.Session, None] = None): 15 | if session is None: 16 | return requests.Session() 17 | return session 18 | 19 | 20 | def hash_list(input_list): 21 | if not isinstance(input_list, list) or input_list is None: 22 | return None 23 | return hashlib.md5(str(input_list).encode())\ 24 | .hexdigest() 25 | 26 | 27 | def decide_version(version: Union[str, None] = None) -> str: 28 | if version is None: 29 | return API_VERSION 30 | return version 31 | 32 | 33 | def join_path(*args): 34 | return '/'.join(x.strip('/') for x in args if x) 35 | -------------------------------------------------------------------------------- /salesforce_api/utils/retrieve.py: -------------------------------------------------------------------------------- 1 | import xmltodict 2 | from ..models import shared 3 | 4 | 5 | def package_xml_to_type_list(xml_string): 6 | doc = xmltodict.parse(xml_string) 7 | return [ 8 | shared.Type( 9 | x['name'], 10 | x['members'] if isinstance(x['members'], list) else [x['members']] 11 | ) 12 | for x in doc['Package']['types'] 13 | ] 14 | -------------------------------------------------------------------------------- /salesforce_api/utils/soap.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | import xmltodict 3 | from .. import exceptions, config 4 | 5 | 6 | def get_message(path: str) -> str: 7 | return (config.DATA_DIRECTORY / 'soap_messages' / path).read_text() 8 | 9 | 10 | def parse_path(path: str) -> list: 11 | return path.split('/') 12 | 13 | 14 | class Result: 15 | def __init__(self, data): 16 | if isinstance(data, str): 17 | self._dict = xmltodict.parse(data) 18 | elif isinstance(data, dict): 19 | self._dict = data 20 | 21 | def has(self, path: str) -> bool: 22 | try: 23 | self._get(self._dict, parse_path(path)) 24 | except exceptions.NodeNotFoundError: 25 | return False 26 | return True 27 | 28 | def get(self, path, default_value=None): 29 | try: 30 | result = self._get(self._dict, parse_path(path)) 31 | except exceptions.NodeNotFoundError: 32 | return default_value 33 | if isinstance(result, (dict, OrderedDict)): 34 | return Result(result) 35 | return result 36 | 37 | def get_list(self, path): 38 | try: 39 | result = self._get(self._dict, parse_path(path)) 40 | except exceptions.NodeNotFoundError: 41 | return [] 42 | if isinstance(result, list): 43 | return result 44 | if isinstance(result, (dict, OrderedDict)): 45 | return [result] 46 | return 47 | 48 | def get_value(self, path, default_value=False): 49 | result = self.get(path, default_value) 50 | if isinstance(result, Result): 51 | raise exceptions.NotTextFound 52 | return result 53 | 54 | def _get(self, data, keys): 55 | try: 56 | return self._get(data[keys[0]], keys[1:]) if keys else data 57 | except KeyError: 58 | raise exceptions.NodeNotFoundError 59 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from pathlib import Path 3 | 4 | from setuptools import setup, find_packages 5 | 6 | 7 | def data_files_inventory(): 8 | data_roots = ['salesforce_api/data'] 9 | return [ 10 | str(x.relative_to('salesforce_api')) 11 | for data_root in data_roots 12 | for x in Path(data_root).glob('**/*') 13 | if not x.is_dir() 14 | ] 15 | 16 | 17 | PACKAGE_DATA = {'salesforce_api': data_files_inventory()} 18 | 19 | 20 | if __name__ == '__main__': 21 | setup( 22 | name="salesforce-api", 23 | version='0.1.43', 24 | author="Felix Lindstrom", 25 | author_email='felix.lindstrom@gmail.com', 26 | description="Salesforce API wrapper", 27 | long_description=Path('README.md').read_text(), 28 | long_description_content_type="text/markdown", 29 | keywords=['salesforce', 'salesforce api', 'salesforce bulk api'], 30 | license='MIT', 31 | packages=find_packages(exclude=['docs', 'tests*']), 32 | package_data=PACKAGE_DATA, 33 | include_package_data=True, 34 | install_requires=[ 35 | 'requests', 36 | 'xmltodict', 37 | 'url-normalize' 38 | ], 39 | zip_safe=True, 40 | url='https://github.com/felixlindstrom/python-salesforce-api', 41 | classifiers=[ 42 | 'Intended Audience :: Developers', 43 | 'License :: OSI Approved :: MIT License', 44 | 'Operating System :: OS Independent', 45 | 'Programming Language :: Python', 46 | 'Programming Language :: Python :: 3', 47 | 'Topic :: Software Development :: Libraries :: Python Modules', 48 | ] 49 | ) -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/felixlindstrom/python-salesforce-api/8233dc5107db9c0daf7ce7e28845a627ee140a49/tests/__init__.py -------------------------------------------------------------------------------- /tests/data/basic/limits.txt: -------------------------------------------------------------------------------- 1 | { 2 | "ConcurrentAsyncGetReportInstances": { 3 | "Max": 200, 4 | "Remaining": 200 5 | }, 6 | "ConcurrentSyncReportRuns": { 7 | "Max": 20, 8 | "Remaining": 20 9 | }, 10 | "DailyAnalyticsDataflowJobExecutions": { 11 | "Max": 50, 12 | "Remaining": 50 13 | }, 14 | "DailyApiRequests": { 15 | "Max": 5000000, 16 | "Remaining": 4999974, 17 | "Salesforce Chatter": { 18 | "Max": 0, 19 | "Remaining": 0 20 | }, 21 | "Salesforce Files": { 22 | "Max": 0, 23 | "Remaining": 0 24 | }, 25 | "Salesforce Mobile Dashboards": { 26 | "Max": 0, 27 | "Remaining": 0 28 | }, 29 | "Salesforce Touch": { 30 | "Max": 0, 31 | "Remaining": 0 32 | }, 33 | "Salesforce for Android": { 34 | "Max": 0, 35 | "Remaining": 0 36 | }, 37 | "Salesforce for Outlook": { 38 | "Max": 0, 39 | "Remaining": 0 40 | }, 41 | "Salesforce for iOS": { 42 | "Max": 0, 43 | "Remaining": 0 44 | } 45 | }, 46 | "DailyAsyncApexExecutions": { 47 | "Max": 250000, 48 | "Remaining": 250000 49 | }, 50 | "DailyBulkApiRequests": { 51 | "Max": 10000, 52 | "Remaining": 10000, 53 | "Ant Migration Tool": { 54 | "Max": 0, 55 | "Remaining": 0 56 | }, 57 | "Salesforce Chatter": { 58 | "Max": 0, 59 | "Remaining": 0 60 | }, 61 | "Salesforce Files": { 62 | "Max": 0, 63 | "Remaining": 0 64 | }, 65 | "Salesforce Mobile Dashboards": { 66 | "Max": 0, 67 | "Remaining": 0 68 | }, 69 | "Salesforce Touch": { 70 | "Max": 0, 71 | "Remaining": 0 72 | }, 73 | "Salesforce for Android": { 74 | "Max": 0, 75 | "Remaining": 0 76 | }, 77 | "Salesforce for Outlook": { 78 | "Max": 0, 79 | "Remaining": 0 80 | }, 81 | "Salesforce for iOS": { 82 | "Max": 0, 83 | "Remaining": 0 84 | }, 85 | "SalesforceA": { 86 | "Max": 0, 87 | "Remaining": 0 88 | }, 89 | "SalesforceA for Android": { 90 | "Max": 0, 91 | "Remaining": 0 92 | }, 93 | "SalesforceA for iOS": { 94 | "Max": 0, 95 | "Remaining": 0 96 | }, 97 | "SalesforceDX Namespace Registry": { 98 | "Max": 0, 99 | "Remaining": 0 100 | } 101 | }, 102 | "DailyDurableGenericStreamingApiEvents": { 103 | "Max": 200000, 104 | "Remaining": 200000 105 | }, 106 | "DailyDurableStreamingApiEvents": { 107 | "Max": 200000, 108 | "Remaining": 200000 109 | }, 110 | "DailyGenericStreamingApiEvents": { 111 | "Max": 10000, 112 | "Remaining": 10000, 113 | "Ant Migration Tool": { 114 | "Max": 0, 115 | "Remaining": 0 116 | }, 117 | "Salesforce Chatter": { 118 | "Max": 0, 119 | "Remaining": 0 120 | }, 121 | "Salesforce Files": { 122 | "Max": 0, 123 | "Remaining": 0 124 | }, 125 | "Salesforce Mobile Dashboards": { 126 | "Max": 0, 127 | "Remaining": 0 128 | }, 129 | "Salesforce Touch": { 130 | "Max": 0, 131 | "Remaining": 0 132 | }, 133 | "Salesforce for Android": { 134 | "Max": 0, 135 | "Remaining": 0 136 | }, 137 | "Salesforce for Outlook": { 138 | "Max": 0, 139 | "Remaining": 0 140 | }, 141 | "Salesforce for iOS": { 142 | "Max": 0, 143 | "Remaining": 0 144 | }, 145 | "SalesforceA": { 146 | "Max": 0, 147 | "Remaining": 0 148 | }, 149 | "SalesforceA for Android": { 150 | "Max": 0, 151 | "Remaining": 0 152 | }, 153 | "SalesforceA for iOS": { 154 | "Max": 0, 155 | "Remaining": 0 156 | }, 157 | "SalesforceDX Namespace Registry": { 158 | "Max": 0, 159 | "Remaining": 0 160 | } 161 | }, 162 | "DailyStandardVolumePlatformEvents": { 163 | "Max": 25000, 164 | "Remaining": 25000 165 | }, 166 | "DailyStreamingApiEvents": { 167 | "Max": 200000, 168 | "Remaining": 200000, 169 | "Salesforce Chatter": { 170 | "Max": 0, 171 | "Remaining": 0 172 | }, 173 | "Salesforce Files": { 174 | "Max": 0, 175 | "Remaining": 0 176 | }, 177 | "Salesforce Mobile Dashboards": { 178 | "Max": 0, 179 | "Remaining": 0 180 | }, 181 | "Salesforce Touch": { 182 | "Max": 0, 183 | "Remaining": 0 184 | }, 185 | "Salesforce for Android": { 186 | "Max": 0, 187 | "Remaining": 0 188 | }, 189 | "Salesforce for Outlook": { 190 | "Max": 0, 191 | "Remaining": 0 192 | }, 193 | "Salesforce for iOS": { 194 | "Max": 0, 195 | "Remaining": 0 196 | }, 197 | "SalesforceA": { 198 | "Max": 0, 199 | "Remaining": 0 200 | }, 201 | "SalesforceA for Android": { 202 | "Max": 0, 203 | "Remaining": 0 204 | }, 205 | "SalesforceA for iOS": { 206 | "Max": 0, 207 | "Remaining": 0 208 | }, 209 | "SalesforceDX Namespace Registry": { 210 | "Max": 0, 211 | "Remaining": 0 212 | } 213 | }, 214 | "DailyWorkflowEmails": { 215 | "Max": 418000, 216 | "Remaining": 418000 217 | }, 218 | "DataStorageMB": { 219 | "Max": 200, 220 | "Remaining": 190 221 | }, 222 | "DurableStreamingApiConcurrentClients": { 223 | "Max": 1000, 224 | "Remaining": 1000 225 | }, 226 | "FileStorageMB": { 227 | "Max": 200, 228 | "Remaining": 200 229 | }, 230 | "HourlyAsyncReportRuns": { 231 | "Max": 1200, 232 | "Remaining": 1200 233 | }, 234 | "HourlyDashboardRefreshes": { 235 | "Max": 200, 236 | "Remaining": 200 237 | }, 238 | "HourlyDashboardResults": { 239 | "Max": 5000, 240 | "Remaining": 5000 241 | }, 242 | "HourlyDashboardStatuses": { 243 | "Max": 999999999, 244 | "Remaining": 999999999 245 | }, 246 | "HourlyLongTermIdMapping": { 247 | "Max": 100000, 248 | "Remaining": 100000 249 | }, 250 | "HourlyODataCallout": { 251 | "Max": 20000, 252 | "Remaining": 20000 253 | }, 254 | "HourlyShortTermIdMapping": { 255 | "Max": 100000, 256 | "Remaining": 100000 257 | }, 258 | "HourlySyncReportRuns": { 259 | "Max": 500, 260 | "Remaining": 500 261 | }, 262 | "HourlyTimeBasedWorkflow": { 263 | "Max": 50, 264 | "Remaining": 50 265 | }, 266 | "MassEmail": { 267 | "Max": 5000, 268 | "Remaining": 5000 269 | }, 270 | "MonthlyPlatformEvents": { 271 | "Max": 750000, 272 | "Remaining": 750000 273 | }, 274 | "Package2VersionCreates": { 275 | "Max": 6, 276 | "Remaining": 6 277 | }, 278 | "PermissionSets": { 279 | "Max": 1500, 280 | "Remaining": 1427, 281 | "CreateCustom": { 282 | "Max": 1000, 283 | "Remaining": 932 284 | } 285 | }, 286 | "SingleEmail": { 287 | "Max": 5000, 288 | "Remaining": 5000 289 | }, 290 | "StreamingApiConcurrentClients": { 291 | "Max": 1000, 292 | "Remaining": 1000 293 | } 294 | } -------------------------------------------------------------------------------- /tests/data/basic/search_failure.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/felixlindstrom/python-salesforce-api/8233dc5107db9c0daf7ce7e28845a627ee140a49/tests/data/basic/search_failure.txt -------------------------------------------------------------------------------- /tests/data/basic/versions.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/felixlindstrom/python-salesforce-api/8233dc5107db9c0daf7ce7e28845a627ee140a49/tests/data/basic/versions.txt -------------------------------------------------------------------------------- /tests/data/bulk/v1/success_result.txt: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "success" : true, 4 | "created" : true, 5 | "id" : "001xx000003DHP0AAO", 6 | "errors" : [] 7 | }, 8 | { 9 | "success" : true, 10 | "created" : true, 11 | "id" : "001xx000003DHP1AAO", 12 | "errors" : [] 13 | }, 14 | { 15 | "success" : false, 16 | "created" : true, 17 | "id" : "001xx000003DHP1AAO", 18 | "errors" : [] 19 | } 20 | ] -------------------------------------------------------------------------------- /tests/data/bulk/v2/create.txt: -------------------------------------------------------------------------------- 1 | { 2 | "id": "123", 3 | "operation": "${operation}", 4 | "object": "${object}", 5 | "createdById": "123", 6 | "createdDate": "2019-02-06T11:44:05.000+0000", 7 | "systemModstamp": "2019-02-06T11:44:05.000+0000", 8 | "state": "Open", 9 | "concurrencyMode": "Parallel", 10 | "contentType": "CSV", 11 | "apiVersion": ${version}, 12 | "contentUrl": "services/data/v44.0/jobs/ingest/123/batches", 13 | "lineEnding": "LF", 14 | "columnDelimiter": "COMMA" 15 | } -------------------------------------------------------------------------------- /tests/data/bulk/v2/failed_results.txt: -------------------------------------------------------------------------------- 1 | "sf__Id","sf__Error",FirstName,LastName 2 | "","REQUIRED_FIELD_MISSING:Required fields are missing: [LastName]:LastName --","asd","" -------------------------------------------------------------------------------- /tests/data/bulk/v2/info.txt: -------------------------------------------------------------------------------- 1 | { 2 | "id": "123", 3 | "operation": "insert", 4 | "object": "Contact", 5 | "createdById": "123", 6 | "createdDate": "2019-02-06T11:44:05.000+0000", 7 | "systemModstamp": "2019-02-06T11:45:31.000+0000", 8 | "state": "JobComplete", 9 | "concurrencyMode": "Parallel", 10 | "contentType": "CSV", 11 | "apiVersion": 44.0, 12 | "jobType": "V2Ingest", 13 | "lineEnding": "LF", 14 | "columnDelimiter": "COMMA", 15 | "numberRecordsProcessed": 0, 16 | "numberRecordsFailed": 0, 17 | "retries": 0, 18 | "totalProcessingTime": 0, 19 | "apiActiveProcessingTime": 0, 20 | "apexProcessingTime": 0 21 | } -------------------------------------------------------------------------------- /tests/data/bulk/v2/successful_results.txt: -------------------------------------------------------------------------------- 1 | "sf__Id","sf__Created",FirstName,LastName 2 | "123","true","asd","Test" 3 | "456","true","asd2","Test 2" -------------------------------------------------------------------------------- /tests/data/bulk/v2/upload_complete.txt: -------------------------------------------------------------------------------- 1 | { 2 | "id": "123", 3 | "operation": "insert", 4 | "object": "${object}", 5 | "createdById": "123", 6 | "createdDate": "2019-02-06T11:44:05.000+0000", 7 | "systemModstamp": "2019-02-06T11:44:05.000+0000", 8 | "state": "UploadComplete", 9 | "concurrencyMode": "Parallel", 10 | "contentType": "CSV", 11 | "apiVersion": 44.0 12 | } -------------------------------------------------------------------------------- /tests/data/deploy/create_failure.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | sf:INVALID_OPERATION 6 | INVALID_OPERATION: runTests can only be used with a testLevel of RunSpecifiedTests 7 | 8 | 9 | -------------------------------------------------------------------------------- /tests/data/deploy/create_success.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | false 7 | 123 8 | Queued 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /tests/data/deploy/status_failed_code_coverage.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | true 7 | 2019-02-10T17:52:37.000Z 8 | 123 9 | Felix Lindström 10 | 2019-02-10T17:49:32.000Z 11 |
12 | 13 | 0 14 | 13 15 | 36029.0 16 | 17 |
18 | true 19 | 123 20 | false 21 | 2019-02-10T17:52:37.000Z 22 | 0 23 | 503 24 | 503 25 | 0 26 | 13 27 | 13 28 | true 29 | true 30 | 2019-02-10T17:50:51.000Z 31 | Failed 32 | false 33 |
34 |
35 |
36 |
-------------------------------------------------------------------------------- /tests/data/deploy/status_failed_multiple.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | true 7 | 2019-02-10T17:32:36.000Z 8 | 123 9 | Felix Lindström 10 | 2019-02-10T17:28:57.000Z 11 |
12 | 13 | false 14 | ApexTrigger 15 | false 16 | 2019-02-10T17:31:39.000Z 17 | false 18 | triggers/UserTrigger.trigger 19 | UserTrigger 20 | 123 21 | true 22 | 23 | 24 | true 25 | 26 | false 27 | 2019-02-10T17:31:39.000Z 28 | false 29 | package.xml 30 | package.xml 31 | true 32 | 33 | 34 | 35 | 123 36 | System.AssertException: Assertion Failed: Expected: 9876543, Actual: null 37 | tester 38 | TesterIntegrationApplicationTest 39 | 40 | TesterIntegrationApplicationTest 41 | Class.TesterIntegrationApplicationTest.tester: line 231, column 1 42 | Class 43 | 44 | 45 | 123 46 | System.AssertException: Assertion Failed: Expected: 9876543, Actual: null 47 | tester 48 | TesterIntegrationApplicationTest 49 | 50 | TesterIntegrationApplicationTest 51 | Class.TesterIntegrationApplicationTest.tester: line 231, column 1 52 | Class 53 | 54 | 55 | 123 56 | System.AssertException: Assertion Failed: Expected: 9876543, Actual: null 57 | tester 58 | TesterIntegrationApplicationTest 59 | 60 | TesterIntegrationApplicationTest 61 | Class.TesterIntegrationApplicationTest.tester: line 231, column 1 62 | Class 63 | 64 | 2 65 | 13 66 | 67 | 123 68 | tester 69 | TesterIntegrationApplicationTest 70 | 71 | 72 | 73 | 123 74 | tester 75 | TesterIntegrationApplicationTest 76 | 77 | 78 | 34265.0 79 | 80 |
81 | true 82 | 123 83 | false 84 | 2019-02-10T17:32:36.000Z 85 | 0 86 | 503 87 | 503 88 | 2 89 | 11 90 | 13 91 | true 92 | true 93 | 2019-02-10T17:30:50.000Z 94 | Failed 95 | false 96 |
97 |
98 |
99 |
-------------------------------------------------------------------------------- /tests/data/deploy/status_failed_single.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | true 7 | 2019-02-10T17:32:36.000Z 8 | 123 9 | Felix Lindström 10 | 2019-02-10T17:28:57.000Z 11 |
12 | 13 | false 14 | ApexTrigger 15 | false 16 | 2019-02-10T17:31:39.000Z 17 | false 18 | triggers/UserTrigger.trigger 19 | UserTrigger 20 | 123 21 | true 22 | 23 | 24 | true 25 | 26 | false 27 | 2019-02-10T17:31:39.000Z 28 | false 29 | package.xml 30 | package.xml 31 | true 32 | 33 | 34 | 35 | 123 36 | System.AssertException: Assertion Failed: Expected: 9876543, Actual: null 37 | tester 38 | TesterIntegrationApplicationTest 39 | 40 | TesterIntegrationApplicationTest 41 | Class.TesterIntegrationApplicationTest.tester: line 231, column 1 42 | Class 43 | 44 | 2 45 | 13 46 | 47 | 123 48 | tester 49 | TesterIntegrationApplicationTest 50 | 51 | 52 | 53 | 123 54 | tester 55 | TesterIntegrationApplicationTest 56 | 57 | 58 | 34265.0 59 | 60 |
61 | true 62 | 123 63 | false 64 | 2019-02-10T17:32:36.000Z 65 | 0 66 | 503 67 | 503 68 | 2 69 | 11 70 | 13 71 | true 72 | true 73 | 2019-02-10T17:30:50.000Z 74 | Failed 75 | false 76 |
77 |
78 |
79 |
-------------------------------------------------------------------------------- /tests/data/deploy/status_pending.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | true 7 | 123 8 | Felix Lindström 9 | 2019-02-06T14:46:27.000Z 10 |
11 | 12 | 0 13 | 0 14 | 0.0 15 | 16 |
17 | false 18 | 123 19 | false 20 | 2019-02-06T14:46:30.000Z 21 | 0 22 | 0 23 | 0 24 | 0 25 | 0 26 | 0 27 | true 28 | false 29 | Pending 30 | false 31 |
32 |
33 |
34 |
-------------------------------------------------------------------------------- /tests/data/deploy/status_success.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | true 7 | 123 8 | Felix Lindström 9 | 2019-02-06T14:46:27.000Z 10 |
11 | 12 | 0 13 | 0 14 | 0.0 15 | 16 |
17 | true 18 | 123 19 | false 20 | 2019-02-06T14:46:30.000Z 21 | 0 22 | 0 23 | 0 24 | 0 25 | 0 26 | 0 27 | true 28 | false 29 | Successful 30 | true 31 |
32 |
33 |
34 |
-------------------------------------------------------------------------------- /tests/data/login/oauth/invalid_client_id.txt: -------------------------------------------------------------------------------- 1 | {"error":"invalid_client_id","error_description":"client identifier invalid"} -------------------------------------------------------------------------------- /tests/data/login/oauth/invalid_client_secret.txt: -------------------------------------------------------------------------------- 1 | {"error":"invalid_client","error_description":"invalid client credentials"} -------------------------------------------------------------------------------- /tests/data/login/oauth/invalid_grant.txt: -------------------------------------------------------------------------------- 1 | {"error":"invalid_grant","error_description":"authentication failure"} -------------------------------------------------------------------------------- /tests/data/login/oauth/success.txt: -------------------------------------------------------------------------------- 1 | {"access_token":"${access_token}","instance_url":"${instance_url}","id":"${id}","token_type":"Bearer","issued_at":"${issued_at}","signature":"${signature}"} -------------------------------------------------------------------------------- /tests/data/login/soap/invalid_login.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | INVALID_LOGIN 6 | INVALID_LOGIN: Invalid username, password, security token; or user locked out. 7 | 8 | 9 | INVALID_LOGIN 10 | Invalid username, password, security token; or user locked out. 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /tests/data/login/soap/missing_token.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | sf:LOGIN_MUST_USE_SECURITY_TOKEN 6 | LOGIN_MUST_USE_SECURITY_TOKEN: Invalid username, password, security token; or user locked out. Are you at a new location? When accessing Salesforce--either via a desktop client or the API--from outside of your company’s trusted networks, you must add a security token to your password to log in. To get your new security token, log in to Salesforce. From your personal settings, enter Reset My Security Token in the Quick Find box, then select Reset My Security Token. 7 | 8 | 9 | LOGIN_MUST_USE_SECURITY_TOKEN 10 | Invalid username, password, security token; or user locked out. Are you at a new location? When accessing Salesforce--either via a desktop client or the API--from outside of your company’s trusted networks, you must add a security token to your password to log in. To get your new security token, log in to Salesforce. From your personal settings, enter Reset My Security Token in the Quick Find box, then select Reset My Security Token. 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /tests/data/login/soap/success.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ${metadata_url} 7 | false 8 | true 9 | ${server_url} 10 | ${access_token} 11 | ${user_id} 12 | 13 | false 14 | false 15 | 16 | 5242880 17 | 18 | 19 | false 20 | false 21 | ${org_id} 22 | true 23 | ${org_name} 24 | ${profile_id} 25 | ${role_id} 26 | 7200 27 | SEK 28 | ${user_email} 29 | ${user_full_name} 30 | ${user_id} 31 | en_US 32 | sv_SE 33 | ${user_email} 34 | Europe/Amsterdam 35 | Standard 36 | Theme3 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /tests/data/retrieve/create_failure.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | soapenv:Client 6 | Element {}membersa invalid at this location 7 | 8 | 9 | -------------------------------------------------------------------------------- /tests/data/retrieve/create_success.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | false 7 | 123 8 | Queued 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /tests/data/retrieve/status_failed.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | true 7 | 8 | 123 9 | Felix Lindström 10 | 2019-02-07T11:48:07.723Z 11 | package.xml 12 | package.xml 13 | 14 | 123 15 | Felix Lindström 16 | 2019-02-07T11:48:07.723Z 17 | unmanaged 18 | Package 19 | 20 | 123 21 | 22 | package.xml 23 | Entity type: 'ApexClassa' is unknown 24 | 25 | Succeeded 26 | true 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /tests/data/retrieve/status_pending.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | false 7 | 123 8 | Pending 9 | false 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /tests/data/retrieve/status_success.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | true 7 | 123 8 | Succeeded 9 | true 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /tests/data/retrieve/status_with_zip.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | true 7 | 123 8 | Succeeded 9 | true 10 | emlwLWZpbGUtY29udGVudA== 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | import time 2 | from pathlib import Path 3 | from string import Template 4 | 5 | from salesforce_api import Salesforce 6 | from salesforce_api.const import API_VERSION 7 | from salesforce_api.const.service import VERB 8 | 9 | TEST_CLIENT_KEY = 'test-key' 10 | TEST_CLIENT_SECRET = 'test-secret' 11 | TEST_SECURITY_TOKEN = 'test-token' 12 | TEST_ACCESS_TOKEN = 'test-access-token' 13 | TEST_DOMAIN = 'example.cs108.my.salesforce.com' 14 | TEST_INSTANCE_URL = f'https://{TEST_DOMAIN}' 15 | TEST_ISSUE_TIME = time.time() 16 | TEST_ID = 'https://test.salesforce.com/id/123/456' 17 | TEST_SIGNATURE = '123456789' 18 | TEST_USER_ID = 'user-123' 19 | TEST_USER_FULL_NAME = 'Test Name' 20 | TEST_USER_EMAIL = 'test@example.com.dev' 21 | TEST_PASSWORD = 'test-password' 22 | TEST_ROLE_ID = '123' 23 | TEST_PROFILE_ID = '123' 24 | TEST_ORG_NAME = 'Test Org' 25 | TEST_ORG_ID = '123' 26 | TEST_SERVER_URL = f'{TEST_INSTANCE_URL}/services/Soap/c/44.0/123' 27 | TEST_METADATA_URL = f'{TEST_INSTANCE_URL}/services/Soap/m/44.0/123' 28 | TEST_OBJECT_NAME = 'Contact' 29 | TEST_BULK_OPERATION = 'insert' 30 | 31 | 32 | def get_data(path, sub_overrides={}): 33 | substitutes = {**{ 34 | 'access_token': TEST_ACCESS_TOKEN, 35 | 'instance_url': TEST_INSTANCE_URL, 36 | 'id': TEST_ID, 37 | 'issued_at': TEST_ISSUE_TIME, 38 | 'signature': TEST_SIGNATURE, 39 | 'user_email': TEST_USER_EMAIL, 40 | 'user_id': TEST_USER_ID, 41 | 'user_full_name': TEST_USER_FULL_NAME, 42 | 'role_id': TEST_ROLE_ID, 43 | 'profile_id': TEST_PROFILE_ID, 44 | 'org_name': TEST_ORG_NAME, 45 | 'org_id': TEST_ORG_ID, 46 | 'server_url': TEST_SERVER_URL, 47 | 'metadata_url': TEST_METADATA_URL, 48 | 'object': TEST_OBJECT_NAME, 49 | 'operation': TEST_BULK_OPERATION, 50 | 'version': API_VERSION 51 | }, **sub_overrides} 52 | return Template((Path(__file__).parent / 'data' / path).read_text()).substitute(substitutes) 53 | 54 | 55 | class BaseTest: 56 | def create_client(self): 57 | return Salesforce( 58 | domain=TEST_DOMAIN, 59 | access_token=TEST_ACCESS_TOKEN 60 | ) 61 | 62 | def get_service(self, service_name): 63 | return self.create_client().__getattribute__(service_name) 64 | 65 | def register_uri(self, requests_mock, verb: VERB, uri: str, **kwargs): 66 | if 'json' in kwargs: 67 | headers = kwargs.setdefault('headers', {}) 68 | headers['Content-Type'] = 'application/json' 69 | requests_mock.register_uri( 70 | verb.value, 71 | uri.format_map({'version': API_VERSION}), 72 | **kwargs 73 | ) -------------------------------------------------------------------------------- /tests/test_login.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from salesforce_api import core, exceptions, login 4 | from salesforce_api.const import API_VERSION 5 | from salesforce_api.const.service import VERB 6 | from . import helpers 7 | 8 | 9 | class TestOAuth: 10 | def create_connection(self, api_version: str = None): 11 | return login.oauth2( 12 | client_id=helpers.TEST_CLIENT_KEY, 13 | client_secret=helpers.TEST_CLIENT_SECRET, 14 | username=helpers.TEST_USER_EMAIL, 15 | password=helpers.TEST_PASSWORD, 16 | instance_url=helpers.TEST_INSTANCE_URL, 17 | api_version=api_version 18 | ) 19 | 20 | def test_authenticate_success(self, requests_mock): 21 | requests_mock.register_uri('POST', '/services/oauth2/token', text=helpers.get_data('login/oauth/success.txt'), status_code=200) 22 | connection = self.create_connection() 23 | assert isinstance(connection, core.Connection) 24 | assert connection.access_token == helpers.TEST_ACCESS_TOKEN 25 | 26 | def test_authenticate_success_client_credentials(self, requests_mock): 27 | requests_mock.register_uri('POST', '/services/oauth2/token', text=helpers.get_data('login/oauth/success.txt'), status_code=200) 28 | connection = login.oauth2( 29 | instance_url=helpers.TEST_INSTANCE_URL, 30 | client_id=helpers.TEST_CLIENT_KEY, 31 | client_secret=helpers.TEST_CLIENT_SECRET, 32 | ) 33 | assert isinstance(connection, core.Connection) 34 | assert connection.access_token == helpers.TEST_ACCESS_TOKEN 35 | 36 | def test_authenticate_client_id_failure(self, requests_mock): 37 | requests_mock.register_uri('POST', '/services/oauth2/token', text=helpers.get_data('login/oauth/invalid_client_id.txt'), status_code=400) 38 | with pytest.raises(exceptions.AuthenticationInvalidClientIdError): 39 | self.create_connection() 40 | 41 | def test_authenticate_client_secret_failure(self, requests_mock): 42 | requests_mock.register_uri('POST', '/services/oauth2/token', text=helpers.get_data('login/oauth/invalid_client_secret.txt'), status_code=400) 43 | with pytest.raises(exceptions.AuthenticationInvalidClientSecretError): 44 | self.create_connection() 45 | 46 | def test_authenticate_invalid_grant_failure(self, requests_mock): 47 | requests_mock.register_uri('POST', '/services/oauth2/token', text=helpers.get_data('login/oauth/invalid_grant.txt'), status_code=400) 48 | with pytest.raises(exceptions.AuthenticationError): 49 | self.create_connection() 50 | 51 | def test_automatic_api_version(self, requests_mock): 52 | requests_mock.register_uri('POST', '/services/oauth2/token', text=helpers.get_data('login/oauth/success.txt'), status_code=200) 53 | connection = self.create_connection() 54 | assert connection.version == API_VERSION 55 | 56 | def test_manual_api_version(self, requests_mock): 57 | expected_api_version = '123.4' 58 | requests_mock.register_uri('POST', '/services/oauth2/token', text=helpers.get_data('login/oauth/success.txt'), status_code=200) 59 | connection = self.create_connection(expected_api_version) 60 | assert connection.version == expected_api_version 61 | 62 | 63 | class TestSoap(helpers.BaseTest): 64 | def create_connection(self, api_version: str = None): 65 | return login.soap( 66 | instance_url=helpers.TEST_INSTANCE_URL, 67 | username=helpers.TEST_USER_EMAIL, 68 | password=helpers.TEST_PASSWORD, 69 | security_token=helpers.TEST_SECURITY_TOKEN, 70 | api_version=api_version 71 | ) 72 | 73 | def test_authenticate_success(self, requests_mock): 74 | self.register_uri(requests_mock, VERB.POST, '/services/Soap/c/{version}', text=helpers.get_data('login/soap/success.txt')) 75 | connection = self.create_connection() 76 | 77 | assert isinstance(connection, core.Connection) 78 | assert connection.access_token == helpers.TEST_ACCESS_TOKEN 79 | 80 | def test_authenticate_alt_password_success(self, requests_mock): 81 | self.register_uri(requests_mock, VERB.POST, '/services/Soap/c/{version}', text=helpers.get_data('login/soap/success.txt')) 82 | connection = login.soap( 83 | instance_url=helpers.TEST_INSTANCE_URL, 84 | username=helpers.TEST_USER_EMAIL, 85 | password_and_security_token=helpers.TEST_PASSWORD 86 | ) 87 | assert isinstance(connection, core.Connection) 88 | assert connection.access_token == helpers.TEST_ACCESS_TOKEN 89 | 90 | def test_authenticate_missing_token_failure(self, requests_mock): 91 | self.register_uri(requests_mock, VERB.POST, '/services/Soap/c/{version}', text=helpers.get_data('login/soap/missing_token.txt')) 92 | with pytest.raises(exceptions.AuthenticationMissingTokenError): 93 | self.create_connection() 94 | 95 | def test_automatic_api_version(self, requests_mock): 96 | self.register_uri(requests_mock, VERB.POST, '/services/Soap/c/{version}', text=helpers.get_data('login/soap/success.txt')) 97 | assert self.create_connection().version == API_VERSION 98 | 99 | def test_manual_api_version(self, requests_mock): 100 | expected_api_version = '123.4' 101 | self.register_uri(requests_mock, VERB.POST, f'/services/Soap/c/{expected_api_version}', text=helpers.get_data('login/soap/success.txt', {'version': expected_api_version})) 102 | assert self.create_connection(expected_api_version).version == expected_api_version 103 | -------------------------------------------------------------------------------- /tests/test_service_basic.py: -------------------------------------------------------------------------------- 1 | from salesforce_api.const.service import VERB 2 | from . import helpers 3 | 4 | 5 | class TestServiceBasic(helpers.BaseTest): 6 | def test_versions(self, requests_mock): 7 | self.register_uri(requests_mock, VERB.GET, '/services/data/', json={}) 8 | result = self.get_service('basic').versions() 9 | assert result == {} 10 | 11 | def test_resources(self, requests_mock): 12 | self.register_uri(requests_mock, VERB.GET, '/services/data/v{version}', json={}) 13 | result = self.get_service('basic').resources() 14 | assert result == {} 15 | 16 | def test_limits(self, requests_mock): 17 | self.register_uri(requests_mock, VERB.GET, '/services/data/v{version}/limits', json={}) 18 | result = self.get_service('basic').limits() 19 | assert result == {} 20 | -------------------------------------------------------------------------------- /tests/test_service_bulk_v1.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pytest 4 | 5 | from salesforce_api import exceptions, models 6 | from salesforce_api.const.service import VERB 7 | from salesforce_api.services.bulk.v1 import BATCH_STATE, JOB_STATE 8 | from . import helpers 9 | 10 | _BASE_URL = '/services/async/{version}/job' 11 | _JOB_ID = '123' 12 | _BATCH_ID = '456' 13 | 14 | 15 | class TestServiceBulk(helpers.BaseTest): 16 | def setup_instance(self, requests_mock): 17 | self.register_uri(requests_mock, VERB.POST, _BASE_URL, json={ 18 | 'id': _JOB_ID 19 | }) 20 | 21 | self.register_uri(requests_mock, VERB.POST, f'{_BASE_URL}/{_JOB_ID}', json={ 22 | 'id': _JOB_ID 23 | }) 24 | 25 | self.register_uri(requests_mock, VERB.GET, f'{_BASE_URL}/{_JOB_ID}', json={ 26 | 'id': _JOB_ID, 27 | 'state': JOB_STATE.CLOSED.value 28 | }) 29 | 30 | self.register_uri(requests_mock, VERB.POST, f'{_BASE_URL}/{_JOB_ID}/batch', json={ 31 | 'id': _BATCH_ID 32 | }) 33 | 34 | self.register_uri(requests_mock, VERB.GET, f'{_BASE_URL}/{_JOB_ID}/batch/{_BATCH_ID}', json={ 35 | 'id': _BATCH_ID, 36 | 'state': BATCH_STATE.COMPLETED.value 37 | }) 38 | 39 | self.register_uri(requests_mock, VERB.GET, f'{_BASE_URL}/{_JOB_ID}/batch/{_BATCH_ID}/result', text=helpers.get_data('bulk/v1/success_result.txt')) 40 | 41 | def create_contact(self, first_name, last_name): 42 | return { 43 | 'FirstName': first_name, 44 | 'LastName': last_name 45 | } 46 | 47 | def test_insert_successful(self, requests_mock): 48 | self.setup_instance(requests_mock) 49 | result = self.get_service('bulk_v1').insert('Contact', [ 50 | self.create_contact('FirstName', 'LastName'), 51 | self.create_contact('FirstName2', 'LastName2'), 52 | self.create_contact('FirstName3', ''), 53 | ]) 54 | assert 2 == len([x for x in result if isinstance(x, models.bulk.SuccessResultRecord)]) 55 | assert 1 == len([x for x in result if isinstance(x, models.bulk.FailResultRecord)]) 56 | 57 | def test_insert_different_types_successful(self, requests_mock): 58 | self.setup_instance(requests_mock) 59 | result = self.get_service('bulk_v1').insert('Contact', [ 60 | { 61 | 'a': 1, 62 | 'b': 1.23, 63 | 'c': datetime.datetime.now(), 64 | 'd': False, 65 | 'e': True, 66 | 'f': 'string' 67 | } 68 | ]) 69 | assert 2 == len([x for x in result if isinstance(x, models.bulk.SuccessResultRecord)]) 70 | assert 1 == len([x for x in result if isinstance(x, models.bulk.FailResultRecord)]) 71 | 72 | def test_delete_successful(self, requests_mock): 73 | self.setup_instance(requests_mock) 74 | result = self.get_service('bulk_v1').delete('Contact', ['123', '456']) 75 | assert 2 == len([x for x in result if isinstance(x, models.bulk.SuccessResultRecord)]) 76 | -------------------------------------------------------------------------------- /tests/test_service_bulk_v2.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import datetime 3 | from salesforce_api import exceptions, models 4 | from salesforce_api.const.service import VERB 5 | from . import helpers 6 | 7 | 8 | _BASE_URL = '/services/data/v{version}/jobs/ingest' 9 | _JOB_ID = '123' 10 | 11 | 12 | class TestServiceBulk(helpers.BaseTest): 13 | def setup_instance(self, requests_mock): 14 | self.register_uri(requests_mock, VERB.POST, _BASE_URL, text=helpers.get_data('bulk/v2/create.txt')) 15 | 16 | self.register_uri(requests_mock, VERB.GET, f'{_BASE_URL}/{_JOB_ID}', text=helpers.get_data('bulk/v2/info.txt')) 17 | self.register_uri(requests_mock, VERB.PATCH, f'{_BASE_URL}/{_JOB_ID}', text=helpers.get_data('bulk/v2/upload_complete.txt')) 18 | self.register_uri(requests_mock, VERB.PUT, f'{_BASE_URL}/{_JOB_ID}/batches', text='') 19 | 20 | self.register_uri(requests_mock, VERB.GET, f'{_BASE_URL}/{_JOB_ID}/successfulResults', text=helpers.get_data('bulk/v2/successful_results.txt')) 21 | self.register_uri(requests_mock, VERB.GET, f'{_BASE_URL}/{_JOB_ID}/failedResults', text=helpers.get_data('bulk/v2/failed_results.txt')) 22 | 23 | 24 | def create_contact(self, first_name, last_name): 25 | return { 26 | 'FirstName': first_name, 27 | 'LastName': last_name 28 | } 29 | 30 | 31 | def test_insert_successful(self, requests_mock): 32 | self.setup_instance(requests_mock) 33 | result = self.get_service('bulk').insert('Contact', [ 34 | self.create_contact('FirstName', 'LastName'), 35 | self.create_contact('FirstName2', 'LastName2'), 36 | self.create_contact('FirstName3', ''), 37 | ]) 38 | assert 2 == len([x for x in result if isinstance(x, models.bulk.SuccessResultRecord)]) 39 | assert 1 == len([x for x in result if isinstance(x, models.bulk.FailResultRecord)]) 40 | 41 | 42 | def test_insert_different_types_successful(self, requests_mock): 43 | self.setup_instance(requests_mock) 44 | result = self.get_service('bulk').insert('Contact', [ 45 | { 46 | 'a': 1, 47 | 'b': 1.23, 48 | 'c': datetime.datetime.now(), 49 | 'd': False, 50 | 'e': True, 51 | 'f': 'string' 52 | } 53 | ]) 54 | assert 2 == len([x for x in result if isinstance(x, models.bulk.SuccessResultRecord)]) 55 | assert 1 == len([x for x in result if isinstance(x, models.bulk.FailResultRecord)]) 56 | 57 | 58 | def test_insert_multiple_structures_failure(self, requests_mock): 59 | self.setup_instance(requests_mock) 60 | with pytest.raises(exceptions.MultipleDifferentHeadersError): 61 | self.get_service('bulk').insert('Contact', [{ 62 | 'a': '123' 63 | }, { 64 | 'b': '456' 65 | }]) 66 | 67 | 68 | def test_insert_empty_rows_failure(self, requests_mock): 69 | self.setup_instance(requests_mock) 70 | with pytest.raises(exceptions.BulkEmptyRowsError): 71 | self.get_service('bulk').insert('Contact', [ 72 | self.create_contact('FirstName', 'LastName'), 73 | self.create_contact('', '') 74 | ]) 75 | 76 | def test_delete_successful(self, requests_mock): 77 | self.setup_instance(requests_mock) 78 | result = self.get_service('bulk').delete('Contact', ['123', '456']) 79 | assert 2 == len([x for x in result if isinstance(x, models.bulk.SuccessResultRecord)]) -------------------------------------------------------------------------------- /tests/test_service_deploy.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from io import BytesIO 3 | from salesforce_api import exceptions 4 | from salesforce_api.const.service import VERB 5 | from . import helpers 6 | 7 | 8 | class TestServiceDeploy(helpers.BaseTest): 9 | def setup_instance(self, requests_mock, texts): 10 | self.register_uri(requests_mock, VERB.POST, '/services/Soap/m/{version}', response_list=[ 11 | {'text': x, 'status_code': 200} if isinstance(x, str) else x 12 | for x in texts 13 | ]) 14 | 15 | def _create_zip(self): 16 | return BytesIO() 17 | 18 | def test_create_successful(self, requests_mock): 19 | self.setup_instance(requests_mock, [ 20 | helpers.get_data('deploy/create_success.txt') 21 | ]) 22 | deployment = self.create_client().deploy.deploy(self._create_zip()) 23 | assert '123' == deployment.async_process_id 24 | 25 | def test_create_failure(self, requests_mock): 26 | self.setup_instance(requests_mock, [ 27 | helpers.get_data('deploy/create_failure.txt') 28 | ]) 29 | with pytest.raises(exceptions.DeployCreateError): 30 | self.create_client().deploy.deploy(self._create_zip()) 31 | 32 | def test_full_successful(self, requests_mock): 33 | self.setup_instance(requests_mock, [ 34 | helpers.get_data('deploy/create_success.txt'), 35 | helpers.get_data('deploy/status_success.txt') 36 | ]) 37 | deployment = self.create_client().deploy.deploy(self._create_zip()) 38 | deployment.get_status() 39 | 40 | def test_single_test_error_failure(self, requests_mock): 41 | self.setup_instance(requests_mock, [ 42 | helpers.get_data('deploy/create_success.txt'), 43 | helpers.get_data('deploy/status_failed_single.txt') 44 | ]) 45 | deployment = self.create_client().deploy.deploy(self._create_zip()) 46 | status = deployment.get_status() 47 | assert 1 == len(status.tests.failures) 48 | 49 | def test_multiple_test_error_failure(self, requests_mock): 50 | self.setup_instance(requests_mock, [ 51 | helpers.get_data('deploy/create_success.txt'), 52 | helpers.get_data('deploy/status_failed_multiple.txt') 53 | ]) 54 | deployment = self.create_client().deploy.deploy(self._create_zip()) 55 | status = deployment.get_status() 56 | assert 3 == len(status.tests.failures) 57 | 58 | def test_cancel_successful(self, requests_mock): 59 | pass 60 | 61 | def test_full_failure(self, requests_mock): 62 | pass 63 | 64 | def test_code_coverage_failure(self, requests_mock): 65 | pass 66 | -------------------------------------------------------------------------------- /tests/test_service_retrieve.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from salesforce_api import models, exceptions 3 | from salesforce_api.const.service import VERB 4 | from . import helpers 5 | 6 | 7 | class TestServiceRetrieve(helpers.BaseTest): 8 | def setup_instance(self, requests_mock, texts): 9 | self.register_uri(requests_mock, VERB.POST, '/services/Soap/m/{version}', response_list=[ 10 | {'text': x, 'status_code': 200} if isinstance(x, str) else x 11 | for x in texts 12 | ]) 13 | 14 | def test_create_successful(self, requests_mock): 15 | self.setup_instance(requests_mock, [ 16 | helpers.get_data('retrieve/create_success.txt') 17 | ]) 18 | retrievement = self.create_client().retrieve.retrieve([ 19 | models.shared.Type('ApexClass') 20 | ]) 21 | assert '123' == retrievement.async_process_id 22 | 23 | def test_create_failure(self, requests_mock): 24 | self.setup_instance(requests_mock, [ 25 | helpers.get_data('retrieve/create_failure.txt') 26 | ]) 27 | with pytest.raises(exceptions.RetrieveCreateError): 28 | self.create_client().retrieve.retrieve([ 29 | models.shared.Type('ApexClass') 30 | ]) 31 | 32 | def test_full_retrieve_successful(self, requests_mock): 33 | self.setup_instance(requests_mock, [ 34 | helpers.get_data('retrieve/create_success.txt'), 35 | helpers.get_data('retrieve/status_success.txt'), 36 | helpers.get_data('retrieve/status_with_zip.txt') 37 | ]) 38 | retrievement = self.create_client().retrieve.retrieve([ 39 | models.shared.Type('ApexClass') 40 | ]) 41 | assert retrievement.is_done() 42 | 43 | zip_file = retrievement.get_zip_file() 44 | assert b'zip-file-content' == zip_file.read() -------------------------------------------------------------------------------- /tests/test_service_sobjects.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from salesforce_api import exceptions 3 | from salesforce_api.const.service import VERB 4 | from . import helpers 5 | 6 | 7 | _TEST_OBJECT = 'Contact' 8 | 9 | 10 | class TestServiceSObjects(helpers.BaseTest): 11 | def test_describe(self, requests_mock): 12 | self.register_uri(requests_mock, VERB.GET, '/services/data/v{version}/sobjects', text='{}') 13 | result = self.get_service('sobjects').describe() 14 | assert {} == result 15 | 16 | def test_query(self, requests_mock): 17 | self.register_uri(requests_mock, VERB.GET, '/services/data/v{version}/query', json={'done': True, 'records': []}) 18 | result = self.get_service('sobjects').query('test') 19 | assert [] == result 20 | 21 | 22 | class TestServiceSObject(helpers.BaseTest): 23 | def _get_service(self): 24 | return self.get_service('sobjects').__getattr__(_TEST_OBJECT) 25 | 26 | def _get_url(self, additional=None): 27 | return '/services/data/v{version}/sobjects/' + _TEST_OBJECT + (f'/{additional}' if additional else '') 28 | 29 | def test_metadata(self, requests_mock): 30 | self.register_uri(requests_mock, VERB.GET, self._get_url(), text='{}') 31 | result = self._get_service().metadata() 32 | assert {} == result 33 | 34 | def test_describe(self, requests_mock): 35 | self.register_uri(requests_mock, VERB.GET, self._get_url('describe'), text='{}') 36 | result = self._get_service().describe() 37 | assert {} == result 38 | 39 | def test_get_successful(self, requests_mock): 40 | self.register_uri(requests_mock, VERB.GET, self._get_url('123'), text='{}') 41 | result = self._get_service().get('123') 42 | assert {} == result 43 | 44 | def test_get_failure(self, requests_mock): 45 | self.register_uri(requests_mock, VERB.GET, self._get_url('123'), text='{}', status_code=404) 46 | with pytest.raises(exceptions.RestResourceNotFoundError): 47 | self._get_service().get('123') 48 | 49 | def test_insert_successful(self, requests_mock): 50 | self.register_uri(requests_mock, VERB.POST, self._get_url(), text='{}') 51 | result = self._get_service().insert({'LastName': 'Example'}) 52 | assert {} == result 53 | 54 | def test_insert_failure(self, requests_mock): 55 | self.register_uri(requests_mock, VERB.POST, self._get_url(), text='', status_code=415) 56 | with pytest.raises(exceptions.RestNotWellFormattedEntityInRequestError): 57 | self._get_service().insert({'LastName': 'Example'}) 58 | 59 | def test_upsert_successful(self, requests_mock): 60 | self.register_uri(requests_mock, VERB.PATCH, self._get_url('customExtIdField__c/11999'), text='{}') 61 | result = self._get_service().upsert('customExtIdField__c', '11999', {'LastName': 'Example'}) 62 | assert True == result 63 | 64 | def test_upsert_failure(self, requests_mock): 65 | self.register_uri(requests_mock, VERB.PATCH, self._get_url('customExtIdField__c/11999'), text='{}', status_code=415) 66 | with pytest.raises(exceptions.RestNotWellFormattedEntityInRequestError): 67 | self._get_service().upsert('customExtIdField__c', '11999', {'LastName': 'Example'}) 68 | 69 | def test_update_successful(self, requests_mock): 70 | self.register_uri(requests_mock, VERB.PATCH, self._get_url('123'), text='{}') 71 | result = self._get_service().update('123', {'LastName': 'Example'}) 72 | assert True == result 73 | 74 | def test_update_failure(self, requests_mock): 75 | self.register_uri(requests_mock, VERB.PATCH, self._get_url('123'), text='{}', status_code=404) 76 | with pytest.raises(exceptions.RestResourceNotFoundError): 77 | self._get_service().update('123', {'LastName': 'Example'}) 78 | 79 | def test_delete_successful(self, requests_mock): 80 | self.register_uri(requests_mock, VERB.DELETE, self._get_url('123'), text='') 81 | result = self._get_service().delete('123') 82 | assert True == result 83 | 84 | def test_delete_failure(self, requests_mock): 85 | self.register_uri(requests_mock, VERB.DELETE, self._get_url('123'), text='{}', status_code=404) 86 | with pytest.raises(exceptions.RestResourceNotFoundError): 87 | self._get_service().delete('123') 88 | 89 | def test_deleted(self, requests_mock): 90 | pass 91 | 92 | def test_updated(self, requests_mock): 93 | pass 94 | -------------------------------------------------------------------------------- /tests/test_service_tooling.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/felixlindstrom/python-salesforce-api/8233dc5107db9c0daf7ce7e28845a627ee140a49/tests/test_service_tooling.py --------------------------------------------------------------------------------