├── 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 |
--------------------------------------------------------------------------------