├── sfdclib ├── __init__.py ├── logger.py ├── rest.py ├── session.py ├── messages.py ├── tooling.py ├── bulk.py └── metadata.py ├── README.md ├── LICENSE ├── setup.py ├── .gitignore └── README.rst /sfdclib/__init__.py: -------------------------------------------------------------------------------- 1 | """SFDClib Package""" 2 | 3 | from sfdclib.logger import ( 4 | SfdcLogger 5 | ) 6 | 7 | from sfdclib.session import ( 8 | SfdcSession 9 | ) 10 | 11 | from sfdclib.tooling import ( 12 | SfdcToolingApi 13 | ) 14 | 15 | from sfdclib.metadata import ( 16 | SfdcMetadataApi 17 | ) 18 | 19 | from sfdclib.bulk import ( 20 | SfdcBulkApi 21 | ) 22 | 23 | from sfdclib.rest import ( 24 | SfdcRestApi 25 | ) 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sfdclib 2 | Python library for accessing and managing Salesforce metadata and tooling API 3 | 4 | Documentation 5 | ------------- 6 | Please refer to PyPi documentation https://github.com/rbauction/sfdclib/blob/master/README.rst 7 | 8 | How to build 9 | ------------ 10 | Build source package 11 | ```sh 12 | $ python setup.py sdist 13 | ``` 14 | 15 | Build Pure Python Wheel 16 | ```sh 17 | $ python setup.py bdist_wheel 18 | ``` 19 | 20 | Install package in 'develop mode' 21 | ```sh 22 | $ pip install -e . 23 | ``` 24 | 25 | Install new version locally 26 | ```sh 27 | $ pip install . 28 | ``` 29 | 30 | Upload new package to PyPi 31 | ```sh 32 | $ twine upload dist/`python setup.py --fullname`* 33 | ``` 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /sfdclib/logger.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import time 3 | 4 | 5 | class SfdcLogger(): 6 | _QUIET = 0 7 | _ERRORS = 1 8 | _WARNINGS = 2 9 | _INFO = 3 10 | _DEBUG = 4 11 | 12 | def __init__(self, level=_INFO): 13 | self._level = level 14 | self._ts_started = time.time() 15 | self._ts_last_logged = self._ts_started 16 | 17 | def _log(self, level, msg): 18 | ts = time.time() 19 | print("[%.2f][%.2f][%s] %s" % ( 20 | ts - self._ts_started, 21 | ts - self._ts_last_logged, 22 | level, 23 | msg) 24 | ) 25 | sys.stdout.flush() 26 | self._ts_last_logged = ts 27 | 28 | def err(self, msg): 29 | if self._level >= SfdcLogger._ERRORS: 30 | self._log("ERR", msg) 31 | 32 | def wrn(self, msg): 33 | if self._level >= SfdcLogger._WARNINGS: 34 | self._log("WRN", msg) 35 | 36 | def inf(self, msg): 37 | if self._level >= SfdcLogger._INFO: 38 | self._log("INF", msg) 39 | 40 | def dbg(self, msg): 41 | if self._level >= SfdcLogger._DEBUG: 42 | self._log("DBG", msg) 43 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """sfdclib package setup""" 2 | 3 | import textwrap 4 | from setuptools import setup 5 | 6 | setup( 7 | name='sfdclib', 8 | version='0.2.26', 9 | author='Andrey Shevtsov', 10 | author_email='ashevtsov@rbauction.com', 11 | packages=['sfdclib'], 12 | url='https://github.com/rbauction/sfdclib', 13 | license='MIT', 14 | description=("SFDClib is a Salesforce.com Metadata API and Tooling " 15 | "API client built for Python 2.7, 3.3 and 3.4."), 16 | long_description=textwrap.dedent(open('README.rst', 'r').read()), 17 | package_data={'': ['LICENSE']}, 18 | package_dir={'sfdclib': 'sfdclib'}, 19 | install_requires=[ 20 | 'requests[security]' 21 | ], 22 | keywords="python salesforce salesforce.com metadata tooling api", 23 | classifiers=[ 24 | 'Development Status :: 4 - Beta', 25 | 'License :: OSI Approved :: MIT License', 26 | 'Intended Audience :: Developers', 27 | 'Intended Audience :: System Administrators', 28 | 'Operating System :: OS Independent', 29 | 'Topic :: Internet :: WWW/HTTP', 30 | 'Programming Language :: Python :: 2.7', 31 | 'Programming Language :: Python :: 3.3', 32 | 'Programming Language :: Python :: 3.4' 33 | ] 34 | ) 35 | -------------------------------------------------------------------------------- /.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 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | 91 | # IntelliJ IDEA 92 | .idea 93 | *.iml 94 | -------------------------------------------------------------------------------- /sfdclib/rest.py: -------------------------------------------------------------------------------- 1 | """ Class to work with Salesforce REST API """ 2 | import json 3 | try: 4 | from urllib.parse import urlencode 5 | except ImportError: 6 | from urllib import urlencode 7 | 8 | 9 | class SfdcRestApi: 10 | """ Class to work with Salesforce REST API """ 11 | _API_BASE_URI = "/services/data/v{version}" 12 | _SOQL_QUERY_URI = "/query/?{query}" 13 | 14 | def __init__(self, session): 15 | if not session.is_connected(): 16 | raise Exception("Session must be connected prior to instantiating this class") 17 | self._session = session 18 | 19 | def _get_api_uri(self): 20 | """ Returns REST API base URI for this connection """ 21 | return self._API_BASE_URI.format(**{'version': self._session.get_api_version()}) 22 | 23 | def _get_headers(self): 24 | """ Compose HTTP header for request """ 25 | return { 26 | 'Authorization': 'Bearer %s' % self._session.get_session_id(), 27 | 'Accept-Encoding': 'gzip', 28 | 'Content_Type': 'application/json'} 29 | 30 | @staticmethod 31 | def _parse_get_post_response(response): 32 | try: 33 | return json.loads(response.text) 34 | except ValueError: 35 | raise Exception("Request failed, response is not JSON: %s" % response.text) 36 | 37 | def get(self, uri): 38 | """ HTTP GET request """ 39 | url = self._session.construct_url(self._get_api_uri() + uri) 40 | response = self._session.get(url, headers=self._get_headers()) 41 | return self._parse_get_post_response(response) 42 | 43 | def post(self, uri, data): 44 | """ HTTP POST request """ 45 | url = self._session.construct_url(self._get_api_uri() + uri) 46 | response = self._session.post(url, headers=self._get_headers(), json=data) 47 | return self._parse_get_post_response(response) 48 | 49 | def delete(self, uri): 50 | """ HTTP DELETE request """ 51 | try: 52 | url = self._session.construct_url(self._get_api_uri() + uri) 53 | response = self._session.delete(url, headers=self._get_headers()) 54 | if response.status_code != 204: 55 | raise Exception("Request failed, status code is not 204: %s" % response.text) 56 | except ValueError: 57 | raise Exception("Request failed, response is not JSON: %s" % response.text) 58 | 59 | def soql_query(self, query): 60 | """ SOQL query """ 61 | res = self.get(self._SOQL_QUERY_URI.format(**{'query': urlencode({'q': query})})) 62 | if not isinstance(res, dict): 63 | raise Exception("Request failed. Response: %s" % res) 64 | return res 65 | -------------------------------------------------------------------------------- /sfdclib/session.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from requests import Session 4 | from xml.etree import ElementTree as ET 5 | 6 | 7 | class SfdcSession(Session): 8 | _DEFAULT_API_VERSION = "37.0" 9 | _LOGIN_URL = "https://{instance}.salesforce.com" 10 | _SOAP_API_BASE_URI = "/services/Soap/c/{version}" 11 | _XML_NAMESPACES = { 12 | 'soapenv': 'http://schemas.xmlsoap.org/soap/envelope/', 13 | 'mt': 'http://soap.sforce.com/2006/04/metadata', 14 | 'd': 'urn:enterprise.soap.sforce.com' 15 | } 16 | 17 | _LOGIN_TMPL = \ 18 | """ 21 | 22 | 23 | {username} 24 | {password} 25 | 26 | 27 | """ 28 | 29 | def __init__( 30 | self, username=None, password=None, token=None, 31 | is_sandbox=False, api_version=_DEFAULT_API_VERSION, 32 | **kwargs): 33 | super(SfdcSession, self).__init__() 34 | self._username = username 35 | self._password = password 36 | self._token = token 37 | self._is_sandbox = is_sandbox 38 | self._api_version = api_version 39 | self._session_id = kwargs.get("session_id", None) 40 | self._instance = kwargs.get("instance", None) 41 | 42 | def login(self): 43 | url = self.construct_url(self.get_soap_api_uri()) 44 | headers = {'Content-Type': 'text/xml', 'SOAPAction': 'login'} 45 | password = self._password 46 | if self._token: 47 | password += self._token 48 | data = SfdcSession._LOGIN_TMPL.format(**{'username': self._username, 'password': password}) 49 | r = self.post(url, headers=headers, data=data) 50 | root = ET.fromstring(r.text) 51 | if root.find('soapenv:Body/soapenv:Fault', SfdcSession._XML_NAMESPACES): 52 | raise Exception("Could not log in. Code: %s Message: %s" % ( 53 | root.find('soapenv:Body/soapenv:Fault/faultcode', SfdcSession._XML_NAMESPACES).text, 54 | root.find('soapenv:Body/soapenv:Fault/faultstring', SfdcSession._XML_NAMESPACES).text)) 55 | self._session_id = root.find('soapenv:Body/d:loginResponse/d:result/d:sessionId', SfdcSession._XML_NAMESPACES).text 56 | server_url = root.find('soapenv:Body/d:loginResponse/d:result/d:serverUrl', SfdcSession._XML_NAMESPACES).text 57 | self._instance = re.search("""https://(.*).salesforce.com/.*""", server_url).group(1) 58 | 59 | def get_server_url(self): 60 | if not self._instance: 61 | return SfdcSession._LOGIN_URL.format(**{'instance': 'test' if self._is_sandbox else 'login'}) 62 | return SfdcSession._LOGIN_URL.format(**{'instance': self._instance}) 63 | 64 | def get_soap_api_uri(self): 65 | return SfdcSession._SOAP_API_BASE_URI.format(**{'version': self._api_version}) 66 | 67 | def construct_url(self, uri): 68 | return "%s%s" % (self.get_server_url(), uri) 69 | 70 | def get_api_version(self): 71 | return self._api_version 72 | 73 | def get_session_id(self): 74 | return self._session_id 75 | 76 | def is_connected(self): 77 | return True if self._instance else False 78 | -------------------------------------------------------------------------------- /sfdclib/messages.py: -------------------------------------------------------------------------------- 1 | """ Salesforce API message templates """ 2 | DEPLOY_MSG = \ 3 | """ 6 | 7 | 8 | {client} 9 | 10 | 11 | {sessionId} 12 | 13 | 14 | 15 | 16 | {ZipFile} 17 | 18 | false 19 | false 20 | {checkOnly} 21 | false 22 | false 23 | false 24 | true 25 | true 26 | {testLevel} 27 | {tests} 28 | 29 | 30 | 31 | """ 32 | 33 | CHECK_DEPLOY_STATUS_MSG = \ 34 | """ 37 | 38 | 39 | {client} 40 | 41 | 42 | {sessionId} 43 | 44 | 45 | 46 | 47 | {asyncProcessId} 48 | {includeDetails} 49 | 50 | 51 | """ 52 | 53 | RETRIEVE_MSG = \ 54 | """ 57 | 58 | 59 | {client} 60 | 61 | 62 | {sessionId} 63 | 64 | 65 | 66 | 67 | 68 | {apiVersion} 69 | {singlePackage} 70 | {unpackaged} 71 | 72 | 73 | 74 | """ 75 | 76 | CHECK_RETRIEVE_STATUS_MSG = \ 77 | """ 80 | 81 | 82 | {client} 83 | 84 | 85 | {sessionId} 86 | 87 | 88 | 89 | 90 | {asyncProcessId} 91 | {includeZip} 92 | 93 | 94 | """ 95 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ******* 2 | SFDClib 3 | ******* 4 | 5 | SFDClib is a Salesforce.com Metadata API and Tooling API client built for Python 2.7, 3.3 and 3.4. 6 | 7 | Usage 8 | ----- 9 | To use API classes one needs to create a session first by instantiating SfdcSession class and passing login details to the constructor. 10 | 11 | One method is to pass in the username, password, and token: 12 | 13 | .. code-block:: python 14 | 15 | from sfdclib import SfdcSession 16 | 17 | s = SfdcSession( 18 | 'username': 'sfdcadmin@company.com.sandbox', 19 | 'password': 'Pa$sw0rd', 20 | 'token': 'TOKEN', 21 | 'is_sandbox': True 22 | ) 23 | s.login() 24 | 25 | A second method, if you've already logged in elsewhere, is to pass in the instance and session_id. This method does not require calling login(). 26 | 27 | .. code-block:: python 28 | 29 | from sfdclib import SfdcSession 30 | 31 | s = SfdcSession( 32 | 'session_id': 'thiswillbeaverylongstringofcharactersincludinglettersspacesandsymbols', 33 | 'instance': 'custom-sf-site.my' 34 | ) 35 | # Notice we are not calling the login() method for this example. 36 | 37 | Then create an instance of corresponding API class passing session object. 38 | 39 | .. code-block:: python 40 | 41 | from sfdclib import SfdcToolingApi 42 | 43 | tooling = SfdcToolingApi(s) 44 | r = tooling.anon_query("SELECT Id, Name FROM User LIMIT 10") 45 | 46 | Implemented methods 47 | ------------------- 48 | 49 | SfdcSession 50 | ^^^^^^^^^^^ 51 | | 52 | | **login()** - establishes a session with Salesforce 53 | | **is_connected()** - returns True if session has been established 54 | | **get_session_id()** - returns Salesforce session ID 55 | | **get_server_url()** - returns url to the login server (https://**test**.salesforce.com when not connected and https://**instance_name**.salesforce.com when connected) 56 | | **get_api_version()** - returns API version being used (36.0, 37.0, ...) 57 | | 58 | 59 | SfdcMetadataApi 60 | ^^^^^^^^^^^^^^^ 61 | | 62 | | **deploy(zipfile, options)** - submits deploy request 63 | | **check_deploy_status(id)** - returns 3-tuple containing state, state detail and test result errors 64 | | **retrieve(options)** - submits retrieve request 65 | | **check_retrieve_status(id)** - retrieves retrieve call status. returns 3-tuple containing state, state detail and warning/error messages 66 | | **retrieve_zip(id)** - retrieves resulting ZIP file for the specified Id of retrieve call. returns 4-tuple containing state, state detail, warning/error messages and ZIP file 67 | | 68 | 69 | SfdcToolingApi 70 | ^^^^^^^^^^^^^^ 71 | | 72 | | **anon_query(query)** - executes anonymous SOQL query and returns results in a form of `requests.Response `_ 73 | | **get(uri)** - sends GET request to specified URI 74 | | **post(uri, data)** - sends passed data in a POST request to specified URI 75 | | **delete(uri)** - sends DELETE request to specified URI 76 | | **anon_apex(apex)** - executes anonymous apex with a success or error message 77 | | **execute_AnonApex(apex) ** - executes anonymous apex and returns the System output information in the form of a text body 78 | | **apexLog_Q(auditlog_id)** - queries for and returns the AuditLog body of the AuditLog Id given to it. 79 | | **set_Traceflag(user_id)** - sets a traceflag for the supplied user Id 80 | | **delete_Traceflag(traceflag_id)** - deletes the TraceFlag associated with the provided TraceFlag Id 81 | | 82 | 83 | SfdcBulkApi 84 | ^^^^^^^^^^^ 85 | | 86 | | **export(object_name, query=None)** - exports data of specified object. If query is not passed only Id field will be exported 87 | | **upsert(object_name, csv_data, external_id_field)** - upserts data to specified object. Records will be matched by external id field 88 | | **update(object_name, csv_data)** - updates data in specified object. Records will be matched by Id field 89 | | **delete(object_name, csv_data)** - deletes data from specified object. Records will be matched by Id field 90 | | 91 | 92 | TroubleShooting 93 | ------- 94 | To use the execute_AnonApex function you will need to provide a DebugLevelId to the traceFlagPL located in the function set_Traceflag(). 95 | To get your DebugLevelId log onto the Salesforce environment, open the developer console, and execute **select Id, LogType, ExpirationDate, DebugLevelId from TraceFlag**. 96 | 97 | License 98 | ------- 99 | 100 | This package is released under the MIT license. 101 | -------------------------------------------------------------------------------- /sfdclib/tooling.py: -------------------------------------------------------------------------------- 1 | ''' Class to work with Salesforce Tooling API ''' 2 | import json 3 | import datetime 4 | try: 5 | from urllib.parse import urlencode 6 | except ImportError: 7 | from urllib import urlencode 8 | 9 | 10 | class SfdcToolingApi(): 11 | ''' Class to work with Salesforce Tooling API ''' 12 | _TOOLING_API_BASE_URI = "/services/data/v{version}/tooling" 13 | _ANON_QUERY_URI = "/query/?{query}" 14 | _EXECUTE_ANON_APEX_URI = "/executeAnonymous/?{a}" 15 | _TRACE_FLAG_URI = "/sobjects/traceFlag" 16 | _APEX_LOG_URI = "/sobjects/ApexLog/{uid}/Body" 17 | 18 | def __init__(self, session): 19 | if not session.is_connected(): 20 | raise Exception("Session must be connected prior to instantiating this class") 21 | self._session = session 22 | 23 | def _get_tooling_api_uri(self): 24 | ''' Returns Tooling API base URI for this connection ''' 25 | return self._TOOLING_API_BASE_URI.format(**{'version': self._session.get_api_version()}) 26 | 27 | def _get_headers(self): 28 | ''' Compose HTTP header for request ''' 29 | return { 30 | 'Authorization': 'Bearer %s' % self._session.get_session_id(), 31 | 'Accept-Encoding': 'gzip', 32 | 'Content-Type': 'application/json'} 33 | 34 | @staticmethod 35 | def _parse_get_post_response(response): 36 | try: 37 | return json.loads(response.text) 38 | except ValueError: 39 | raise Exception("Request failed, response is not JSON: %s" % response.text) 40 | @staticmethod 41 | def _parse_get_post_response_text(response): 42 | return response.text 43 | 44 | 45 | def get(self, uri): 46 | ''' HTTP GET request ''' 47 | url = self._session.construct_url(self._get_tooling_api_uri() + uri) 48 | response = self._session.get(url, headers=self._get_headers()) 49 | return self._parse_get_post_response(response) 50 | 51 | def get_textBody(self, uri): 52 | ''' HTTP GET request ''' 53 | url = self._session.construct_url(self._get_tooling_api_uri() + uri) 54 | response = self._session.get(url, headers=self._get_headers()) 55 | return response.text 56 | 57 | def post(self, uri, data): 58 | ''' HTTP POST request ''' 59 | url = self._session.construct_url(self._get_tooling_api_uri() + uri) 60 | response = self._session.post(url, headers=self._get_headers(), data=data) 61 | return self._parse_get_post_response(response) 62 | 63 | def delete(self, uri): 64 | ''' HTTP DELETE request ''' 65 | try: 66 | url = self._session.construct_url(self._get_tooling_api_uri() + uri) 67 | response = self._session.delete(url, headers=self._get_headers()) 68 | if response.status_code != 204: 69 | raise Exception("Request failed, status code is not 204: %s" % response.text) 70 | except ValueError: 71 | raise Exception("Request failed, response is not JSON: %s" % response.text) 72 | 73 | def anon_query(self, query): 74 | ''' Anonymous query ''' 75 | res = self.get(self._ANON_QUERY_URI.format(**{'query': urlencode({'q': query})})) 76 | if not isinstance(res, dict): 77 | raise Exception("Request failed. Response: %s" % res) 78 | return res 79 | 80 | def getDebug(self, uri): 81 | ''' HTTP GET request ''' 82 | url = self._session.construct_url(self._get_tooling_api_uri() + uri) 83 | response = self._session.get(url, headers=self._get_headers()) 84 | return self._parse_get_post_response(response) 85 | 86 | def anon_apex(self, apex): 87 | ''' Anonymous APEX ''' 88 | res = self.getDebug(self._EXECUTE_ANON_APEX_URI.format(**{'a': urlencode({'anonymousBody': apex})})) 89 | if not isinstance(res, dict): 90 | raise Exception("Request failed. Response: %s" % res) 91 | return res 92 | 93 | def apexLog_Q(self, id): 94 | ''' Anonymous APEX ''' 95 | res = self.get_textBody(self._APEX_LOG_URI.format(**{'uid': id})) 96 | return res 97 | 98 | def set_Traceflag(self,tfid): 99 | # check if there is an existing traceflag 100 | userId = self.get_sf_user_id(self._session._username) 101 | 102 | tfCheckQ = "SELECT Id FROM TraceFlag WHERE TracedEntityId = '{}'".format(userId) 103 | 104 | 105 | tfCheck = self.anon_query(tfCheckQ) 106 | json_string = json.dumps(tfCheck) 107 | parsedJSON = json.loads(json_string) 108 | 109 | tf_id = '' 110 | for q in parsedJSON['records']: 111 | uId = q['Id'] 112 | tf_id = uId 113 | 114 | if (tf_id): 115 | self.delete_Traceflag(tf_id) 116 | #tomorrowsDate = datetime.date.today() + datetime.timedelta(days=1) 117 | tomorrowsDate = datetime.datetime.utcnow() + datetime.timedelta(days=1) 118 | tomorrowsDate = tomorrowsDate.strftime('%Y-%m-%d') 119 | debugLevelId = self.get_DevDebugLevelId() 120 | traceFlagPL = '''{ 121 | "ApexCode": "Finest", 122 | "ApexProfiling": "Error", 123 | "Callout": "Error", 124 | "Database": "Error", 125 | "ExpirationDate": "%s", 126 | "TracedEntityId": "%s", 127 | "Validation": "Error", 128 | "Visualforce": "Error", 129 | "Workflow": "Error", 130 | "System": "Error", 131 | "LogType": "DEVELOPER_LOG", 132 | "DebugLevelId": "%s" 133 | }''' % (tomorrowsDate,tfid,debugLevelId) 134 | 135 | # POST 136 | res = self.post(self._TRACE_FLAG_URI, traceFlagPL) 137 | return res 138 | 139 | def delete_Traceflag(self,tfID): 140 | delURI = self._TRACE_FLAG_URI + '/' + tfID 141 | res = self.delete(delURI) 142 | return res 143 | 144 | def execute_AnonApex(self,apex): 145 | 146 | # === setup TraceFlag === 147 | # get the user id 148 | userId = self.get_sf_user_id(self._session._username) 149 | # create traceflag and store it's id for removal later 150 | traceflagCreationRes = self.set_Traceflag(userId) 151 | # get the traceflag ID 152 | 153 | traceFlagId = self.get_traceflag_id(traceflagCreationRes) 154 | # run the apex 155 | anonApexResponse = self.anon_apex(apex) 156 | 157 | # get auditlog id 158 | logQuery = "SELECT Id FROM ApexLog WHERE LogUserId = '{}' ORDER BY SystemModstamp DESC NULLS LAST LIMIT 1".format(userId) 159 | 160 | logQRes = self.anon_query(logQuery) 161 | AuditLogId = self.parse_JSON_Response(logQRes) 162 | queryResponse = self.apexLog_Q(AuditLogId) 163 | # remove the traceflag 164 | self.delete_Traceflag(traceFlagId) 165 | # return the auditlog body 166 | return queryResponse 167 | 168 | def parse_JSON_Response(self, jsonRes): 169 | json_string = json.dumps(jsonRes) 170 | parsedJSON = json.loads(json_string) 171 | 172 | for q in parsedJSON['records']: 173 | uId = q['Id'] 174 | return uId 175 | 176 | 177 | def get_sf_user_id(self, username): 178 | userIdQ = "SELECT Id FROM User WHERE Username = '%s'" % (username) 179 | userIdQRes = self.anon_query(userIdQ) 180 | userId = self.parse_JSON_Response(userIdQRes) 181 | return userId 182 | 183 | def get_traceflag_id(self,traceflagResponse): 184 | json_string = json.dumps(traceflagResponse) 185 | parsedJSON = json.loads(json_string) 186 | traceFlagId = parsedJSON['id'] 187 | return traceFlagId 188 | 189 | def get_DevDebugLevelId(self): 190 | devDebuglogIdRes = self.anon_query("select DebugLevelId from TraceFlag WHERE LogType = 'DEVELOPER_LOG' LIMIT 1") 191 | json_string = json.dumps(devDebuglogIdRes) 192 | parsedJSON = json.loads(json_string) 193 | for d in parsedJSON['records']: 194 | debugLevelId = d['DebugLevelId'] 195 | return debugLevelId 196 | -------------------------------------------------------------------------------- /sfdclib/bulk.py: -------------------------------------------------------------------------------- 1 | """ Class to work with Salesforce Bulk API """ 2 | import json 3 | import time 4 | from xml.etree import ElementTree as ET 5 | 6 | 7 | class SfdcBulkApi: 8 | """ Class to work with Salesforce Bulk API """ 9 | _API_BASE_URI = "/services/async/{version}" 10 | _SOQL_QUERY_URI = "/query/?{query}" 11 | _XML_NAMESPACES = { 12 | 'asyncapi': 'http://www.force.com/2009/06/asyncapi/dataload' 13 | } 14 | 15 | def __init__(self, session): 16 | if not session.is_connected(): 17 | raise Exception("Session must be connected prior to instantiating this class") 18 | self._session = session 19 | 20 | def _get_api_uri(self): 21 | """ Returns Bulk API base URI for this connection """ 22 | return self._API_BASE_URI.format(**{'version': self._session.get_api_version()}) 23 | 24 | def _get_headers(self, content_type='application/json'): 25 | """ Compose HTTP header for request """ 26 | return { 27 | 'X-SFDC-Session': self._session.get_session_id(), 28 | 'Accept-Encoding': 'gzip', 29 | 'Content-Type': "{0}; charset=UTF-8".format(content_type) 30 | } 31 | 32 | def _create_job(self, operation, object_name, content_type, external_id_field=None): 33 | """ Create a job """ 34 | request = { 35 | 'operation': operation, 36 | 'object': object_name, 37 | 'contentType': content_type 38 | } 39 | 40 | if operation == "upsert" and external_id_field is not None: 41 | request['externalIdFieldName'] = external_id_field 42 | 43 | url = self._session.construct_url(self._get_api_uri() + "/job") 44 | res = self._session.post(url, headers=self._get_headers(), json=request) 45 | if res.status_code != 201: 46 | raise Exception( 47 | "Request failed with %d code and error [%s]" % 48 | (res.status_code, res.text)) 49 | res_obj = json.loads(res.text) 50 | 51 | return res_obj['id'] 52 | 53 | def _close_job(self, job_id): 54 | """ Close job """ 55 | request = {'state': 'Closed'} 56 | 57 | url = self._session.construct_url(self._get_api_uri() + "/job/{0}".format(job_id)) 58 | res = self._session.post(url, headers=self._get_headers(), json=request) 59 | if res.status_code != 200: 60 | raise Exception( 61 | "Request failed with %d code and error [%s]" % 62 | (res.status_code, res.text)) 63 | 64 | def _add_batch(self, job_id, data): 65 | """ Add batch to job """ 66 | url = self._session.construct_url(self._get_api_uri() + "/job/{0}/batch".format(job_id)) 67 | res = self._session.post(url, headers=self._get_headers('text/csv'), data=data.encode('utf-8')) 68 | 69 | if res.status_code != 201: 70 | raise Exception( 71 | "Request failed with %d code and error [%s]" % 72 | (res.status_code, res.text)) 73 | 74 | return ET.fromstring(res.text).find('asyncapi:id', self._XML_NAMESPACES).text 75 | 76 | def _get_batch_state(self, job_id, batch_id): 77 | """ Get batch's state """ 78 | url = self._session.construct_url(self._get_api_uri() + "/job/{0}/batch".format(job_id)) 79 | res = self._session.get(url, headers=self._get_headers()) 80 | 81 | if res.status_code != 200: 82 | raise Exception( 83 | "Request failed with %d code and error [%s]" % 84 | (res.status_code, res.text)) 85 | 86 | batches = ET.fromstring(res.text).findall('asyncapi:batchInfo', self._XML_NAMESPACES) 87 | for batch in batches: 88 | if batch_id == batch.find('asyncapi:id', self._XML_NAMESPACES).text: 89 | state = batch.find('asyncapi:state', self._XML_NAMESPACES).text 90 | message = batch.find('asyncapi:stateMessage', self._XML_NAMESPACES) 91 | processed_count = batch.find('asyncapi:numberRecordsProcessed', self._XML_NAMESPACES).text 92 | failed_count = batch.find('asyncapi:numberRecordsFailed', self._XML_NAMESPACES).text 93 | if message is not None: 94 | message = message.text 95 | else: 96 | message = None 97 | return { 98 | 'state': state, 99 | 'message': message, 100 | 'processed': processed_count, 101 | 'failed': failed_count 102 | } 103 | 104 | raise Exception("Batch was not found") 105 | 106 | def _get_batch_result(self, job_id, batch_id, is_single_job=False): 107 | """ Get batch's result """ 108 | # Get result Id first 109 | url = self._session.construct_url(self._get_api_uri() + "/job/{0}/batch/{1}/result".format(job_id, batch_id)) 110 | res = self._session.get(url, headers=self._get_headers()) 111 | 112 | if res.status_code != 200: 113 | raise Exception( 114 | "Request failed with %d code and error [%s]" % 115 | (res.status_code, res.text)) 116 | 117 | if is_single_job: 118 | return res.text 119 | 120 | result_id = ET.fromstring(res.text).find('asyncapi:result', self._XML_NAMESPACES).text 121 | 122 | # Download CSV 123 | url = self._session.construct_url( 124 | self._get_api_uri() + "/job/{0}/batch/{1}/result/{2}".format(job_id, batch_id, result_id)) 125 | res = self._session.get(url, headers=self._get_headers('text/csv')) 126 | 127 | if res.status_code != 200: 128 | raise Exception( 129 | "Request failed with %d code and error [%s]" % 130 | (res.status_code, res.text)) 131 | # TODO: Replace with stream object 132 | return res.text 133 | 134 | def export_object(self, object_name, query=None): 135 | return self.export(object_name, query) 136 | 137 | def export(self, object_name, query=None): 138 | """ Exports data of specified object 139 | If query is not passed only Id field will be exported """ 140 | if query is None: 141 | query = "SELECT Id FROM {0}".format(object_name) 142 | 143 | # Create async job and add query batch 144 | job_id = self._create_job('query', object_name, 'CSV') 145 | batch_id = self._add_batch(job_id, query) 146 | self._close_job(job_id) 147 | 148 | # Wait until batch is processed 149 | status = self._get_batch_state(job_id, batch_id) 150 | while status['state'] not in ['Completed', 'Failed', 'Not Processed']: 151 | time.sleep(5) 152 | status = self._get_batch_state(job_id, batch_id) 153 | 154 | if status['state'] == 'Failed': 155 | raise Exception("Batch failed: {0}".format(status['message'])) 156 | 157 | if status['state'] == 'Not processed': 158 | raise Exception("Batch will not be processed: {0}".format(status['message'])) 159 | 160 | if status['processed'] == '0': 161 | return '' 162 | # Retrieve and return data in CSV format 163 | return self._get_batch_result(job_id, batch_id) 164 | 165 | def upsert_object(self, object_name, csv_data, external_id_field): 166 | return self.upsert(object_name, csv_data, external_id_field) 167 | 168 | def upsert(self, object_name, csv_data, external_id_field): 169 | """ Upserts data to specified object 170 | Records will be matched by external id field """ 171 | # Create async job and add query batch 172 | job_id = self._create_job('upsert', object_name, 'CSV', external_id_field) 173 | batch_id = self._add_batch(job_id, csv_data) 174 | self._close_job(job_id) 175 | 176 | # Wait until batch is processed 177 | status = self._get_batch_state(job_id, batch_id) 178 | while status['state'] not in ['Completed', 'Failed', 'Not Processed']: 179 | time.sleep(5) 180 | status = self._get_batch_state(job_id, batch_id) 181 | 182 | if status['state'] != 'Completed': 183 | raise Exception("Upsert call failed: {0}".format(status['message'])) 184 | 185 | if int(status['failed']) > 0: 186 | status['results'] = self._get_batch_result(job_id, batch_id, True) 187 | 188 | return status 189 | 190 | def update_object(self, object_name, csv_data): 191 | return self.update(object_name, csv_data) 192 | 193 | def update(self, object_name, csv_data): 194 | """ Updates data in specified object 195 | Records will be matched by id field """ 196 | # Create async job and add query batch 197 | job_id = self._create_job('update', object_name, 'CSV') 198 | batch_id = self._add_batch(job_id, csv_data) 199 | self._close_job(job_id) 200 | 201 | # Wait until batch is processed 202 | status = self._get_batch_state(job_id, batch_id) 203 | while status['state'] not in ['Completed', 'Failed', 'Not Processed']: 204 | time.sleep(5) 205 | status = self._get_batch_state(job_id, batch_id) 206 | 207 | if status['state'] != 'Completed': 208 | raise Exception("Update call failed: {0}".format(status['message'])) 209 | 210 | if int(status['failed']) > 0: 211 | status['results'] = self._get_batch_result(job_id, batch_id, True) 212 | 213 | return status 214 | 215 | def delete_object(self, object_name, csv_data): 216 | return self.delete(object_name, csv_data) 217 | 218 | def delete(self, object_name, csv_data): 219 | """ Deleted data from specified object 220 | Records will be matched by id field """ 221 | # Create async job and add query batch 222 | job_id = self._create_job('delete', object_name, 'CSV') 223 | batch_id = self._add_batch(job_id, csv_data) 224 | self._close_job(job_id) 225 | 226 | # Wait until batch is processed 227 | status = self._get_batch_state(job_id, batch_id) 228 | while status['state'] not in ['Completed', 'Failed', 'Not Processed']: 229 | time.sleep(5) 230 | status = self._get_batch_state(job_id, batch_id) 231 | 232 | if status['state'] != 'Completed': 233 | raise Exception("Delete call failed: {0}".format(status['message'])) 234 | 235 | if int(status['failed']) > 0: 236 | status['results'] = self._get_batch_result(job_id, batch_id, True) 237 | 238 | return status 239 | -------------------------------------------------------------------------------- /sfdclib/metadata.py: -------------------------------------------------------------------------------- 1 | """ Class to work with Salesforce Metadata API """ 2 | from base64 import b64encode, b64decode 3 | from xml.etree import ElementTree as ET 4 | 5 | import sfdclib.messages as msg 6 | 7 | 8 | class SfdcMetadataApi: 9 | """ Class to work with Salesforce Metadata API """ 10 | _METADATA_API_BASE_URI = "/services/Soap/m/{version}" 11 | _XML_NAMESPACES = { 12 | 'soapenv': 'http://schemas.xmlsoap.org/soap/envelope/', 13 | 'mt': 'http://soap.sforce.com/2006/04/metadata' 14 | } 15 | 16 | def __init__(self, session): 17 | if not session.is_connected(): 18 | raise Exception("Session must be connected prior to instantiating this class") 19 | self._session = session 20 | self._deploy_zip = None 21 | 22 | def _get_api_url(self): 23 | return "%s%s" % ( 24 | self._session.get_server_url(), 25 | self._METADATA_API_BASE_URI.format(**{'version': self._session.get_api_version()})) 26 | 27 | def deploy(self, zipfile, options): 28 | """ Kicks off async deployment, returns deployment id """ 29 | check_only = "" 30 | if 'checkonly' in options: 31 | check_only = "%s" % options['checkonly'] 32 | 33 | test_level = "" 34 | if 'testlevel' in options: 35 | test_level = "%s" % options['testlevel'] 36 | 37 | tests_tag = "" 38 | if 'tests' in options: 39 | for test in options['tests']: 40 | tests_tag += "%s\n" % test 41 | 42 | attributes = { 43 | 'client': 'Metahelper', 44 | 'checkOnly': check_only, 45 | 'sessionId': self._session.get_session_id(), 46 | 'ZipFile': self._read_deploy_zip(zipfile), 47 | 'testLevel': test_level, 48 | 'tests': tests_tag 49 | } 50 | 51 | request = msg.DEPLOY_MSG.format(**attributes) 52 | 53 | headers = {'Content-type': 'text/xml', 'SOAPAction': 'deploy'} 54 | res = self._session.post(self._get_api_url(), headers=headers, data=request) 55 | if res.status_code != 200: 56 | raise Exception( 57 | "Request failed with %d code and error [%s]" % 58 | (res.status_code, res.text)) 59 | 60 | async_process_id = ET.fromstring(res.text).find( 61 | 'soapenv:Body/mt:deployResponse/mt:result/mt:id', 62 | self._XML_NAMESPACES).text 63 | state = ET.fromstring(res.text).find( 64 | 'soapenv:Body/mt:deployResponse/mt:result/mt:state', 65 | self._XML_NAMESPACES).text 66 | 67 | return async_process_id, state 68 | 69 | @staticmethod 70 | def _read_deploy_zip(zipfile): 71 | if hasattr(zipfile, 'read'): 72 | file = zipfile 73 | file.seek(0) 74 | should_close = False 75 | else: 76 | file = open(zipfile, 'rb') 77 | should_close = True 78 | raw = file.read() 79 | if should_close: 80 | file.close() 81 | return b64encode(raw).decode("utf-8") 82 | 83 | def _retrieve_deploy_result(self, async_process_id): 84 | """ Retrieves status for specified deployment id """ 85 | attributes = { 86 | 'client': 'Metahelper', 87 | 'sessionId': self._session.get_session_id(), 88 | 'asyncProcessId': async_process_id, 89 | 'includeDetails': 'true' 90 | } 91 | mt_request = msg.CHECK_DEPLOY_STATUS_MSG.format(**attributes) 92 | headers = {'Content-type': 'text/xml', 'SOAPAction': 'checkDeployStatus'} 93 | res = self._session.post(self._get_api_url(), headers=headers, data=mt_request) 94 | root = ET.fromstring(res.text) 95 | result = root.find( 96 | 'soapenv:Body/mt:checkDeployStatusResponse/mt:result', 97 | self._XML_NAMESPACES) 98 | if result is None: 99 | raise Exception("Result node could not be found: %s" % res.text) 100 | 101 | return result 102 | 103 | def check_deploy_status(self, async_process_id): 104 | """ Checks whether deployment succeeded """ 105 | result = self._retrieve_deploy_result(async_process_id) 106 | state = result.find('mt:status', self._XML_NAMESPACES).text 107 | state_detail = result.find('mt:stateDetail', self._XML_NAMESPACES) 108 | if state_detail is not None: 109 | state_detail = state_detail.text 110 | 111 | unit_test_errors = [] 112 | deployment_errors = [] 113 | if state == 'Failed': 114 | # Deployment failures 115 | failures = result.findall('mt:details/mt:componentFailures', self._XML_NAMESPACES) 116 | for failure in failures: 117 | deployment_errors.append({ 118 | 'type': failure.find('mt:componentType', self._XML_NAMESPACES).text, 119 | 'file': failure.find('mt:fileName', self._XML_NAMESPACES).text, 120 | 'status': failure.find('mt:problemType', self._XML_NAMESPACES).text, 121 | 'message': failure.find('mt:problem', self._XML_NAMESPACES).text 122 | }) 123 | # Unit test failures 124 | failures = result.findall( 125 | 'mt:details/mt:runTestResult/mt:failures', 126 | self._XML_NAMESPACES) 127 | for failure in failures: 128 | unit_test_errors.append({ 129 | 'class': failure.find('mt:name', self._XML_NAMESPACES).text, 130 | 'method': failure.find('mt:methodName', self._XML_NAMESPACES).text, 131 | 'message': failure.find('mt:message', self._XML_NAMESPACES).text, 132 | 'stack_trace': failure.find('mt:stackTrace', self._XML_NAMESPACES).text 133 | }) 134 | 135 | deployment_detail = { 136 | 'total_count': result.find('mt:numberComponentsTotal', self._XML_NAMESPACES).text, 137 | 'failed_count': result.find('mt:numberComponentErrors', self._XML_NAMESPACES).text, 138 | 'deployed_count': result.find('mt:numberComponentsDeployed', self._XML_NAMESPACES).text, 139 | 'errors': deployment_errors 140 | } 141 | unit_test_detail = { 142 | 'total_count': result.find('mt:numberTestsTotal', self._XML_NAMESPACES).text, 143 | 'failed_count': result.find('mt:numberTestErrors', self._XML_NAMESPACES).text, 144 | 'completed_count': result.find('mt:numberTestsCompleted', self._XML_NAMESPACES).text, 145 | 'errors': unit_test_errors 146 | } 147 | 148 | return state, state_detail, deployment_detail, unit_test_detail 149 | 150 | def download_unit_test_logs(self, async_process_id): 151 | """ Downloads Apex logs for unit tests executed during specified deployment """ 152 | result = self._retrieve_deploy_result(async_process_id) 153 | print("Results: %s" % ET.tostring(result, encoding="us-ascii", method="xml")) 154 | 155 | def retrieve(self, options): 156 | """ Submits retrieve request """ 157 | # Compose unpackaged XML 158 | unpackaged = '' 159 | for metadata_type in options['unpackaged']: 160 | members = options['unpackaged'][metadata_type] 161 | unpackaged += '' 162 | for member in members: 163 | unpackaged += '{0}'.format(member) 164 | unpackaged += '{0}'.format(metadata_type) 165 | # Compose retrieve request XML 166 | attributes = { 167 | 'client': 'Metahelper', 168 | 'sessionId': self._session.get_session_id(), 169 | 'apiVersion': self._session.get_api_version(), 170 | 'singlePackage': options['single_package'], 171 | 'unpackaged': unpackaged 172 | } 173 | request = msg.RETRIEVE_MSG.format(**attributes) 174 | # Submit request 175 | headers = {'Content-type': 'text/xml', 'SOAPAction': 'retrieve'} 176 | res = self._session.post(self._get_api_url(), headers=headers, data=request) 177 | if res.status_code != 200: 178 | raise Exception( 179 | "Request failed with %d code and error [%s]" % 180 | (res.status_code, res.text)) 181 | # Parse results to get async Id and status 182 | async_process_id = ET.fromstring(res.text).find( 183 | 'soapenv:Body/mt:retrieveResponse/mt:result/mt:id', 184 | self._XML_NAMESPACES).text 185 | state = ET.fromstring(res.text).find( 186 | 'soapenv:Body/mt:retrieveResponse/mt:result/mt:state', 187 | self._XML_NAMESPACES).text 188 | 189 | return async_process_id, state 190 | 191 | def _retrieve_retrieve_result(self, async_process_id, include_zip): 192 | """ Retrieves status for specified retrieval id """ 193 | attributes = { 194 | 'client': 'Metahelper', 195 | 'sessionId': self._session.get_session_id(), 196 | 'asyncProcessId': async_process_id, 197 | 'includeZip': include_zip 198 | } 199 | mt_request = msg.CHECK_RETRIEVE_STATUS_MSG.format(**attributes) 200 | headers = {'Content-type': 'text/xml', 'SOAPAction': 'checkRetrieveStatus'} 201 | res = self._session.post(self._get_api_url(), headers=headers, data=mt_request) 202 | root = ET.fromstring(res.text) 203 | result = root.find( 204 | 'soapenv:Body/mt:checkRetrieveStatusResponse/mt:result', 205 | self._XML_NAMESPACES) 206 | if result is None: 207 | raise Exception("Result node could not be found: %s" % res.text) 208 | 209 | return result 210 | 211 | def retrieve_zip(self, async_process_id): 212 | """ Retrieves ZIP file """ 213 | result = self._retrieve_retrieve_result(async_process_id, 'true') 214 | state = result.find('mt:status', self._XML_NAMESPACES).text 215 | error_message = result.find('mt:errorMessage', self._XML_NAMESPACES) 216 | if error_message is not None: 217 | error_message = error_message.text 218 | 219 | # Check if there are any messages 220 | messages = [] 221 | message_list = result.findall('mt:details/mt:messages', self._XML_NAMESPACES) 222 | for message in message_list: 223 | messages.append({ 224 | 'file': message.find('mt:fileName', self._XML_NAMESPACES).text, 225 | 'message': message.find('mt:problem', self._XML_NAMESPACES).text 226 | }) 227 | 228 | # Retrieve base64 encoded ZIP file 229 | zipfile_base64 = result.find('mt:zipFile', self._XML_NAMESPACES).text 230 | zipfile = b64decode(zipfile_base64) 231 | 232 | return state, error_message, messages, zipfile 233 | 234 | def check_retrieve_status(self, async_process_id): 235 | """ Checks whether retrieval succeeded """ 236 | result = self._retrieve_retrieve_result(async_process_id, 'false') 237 | state = result.find('mt:status', self._XML_NAMESPACES).text 238 | error_message = result.find('mt:errorMessage', self._XML_NAMESPACES) 239 | if error_message is not None: 240 | error_message = error_message.text 241 | 242 | # Check if there are any messages 243 | messages = [] 244 | message_list = result.findall('mt:details/mt:messages', self._XML_NAMESPACES) 245 | for message in message_list: 246 | messages.append({ 247 | 'file': message.find('mt:fileName', self._XML_NAMESPACES).text, 248 | 'message': message.find('mt:problem', self._XML_NAMESPACES).text 249 | }) 250 | 251 | return state, error_message, messages 252 | --------------------------------------------------------------------------------