├── .github └── workflows │ ├── pythonpackage.yml │ └── pythonpublish.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGES.txt ├── LICENSE.txt ├── MANIFEST.in ├── Makefile ├── README.md ├── examples ├── demo.py ├── export.py └── soql2atom.py ├── pyforce ├── __init__.py ├── common.py ├── marshall.py ├── pyclient.py ├── xmlclient.py └── xmltramp.py ├── requirements-dev.txt ├── requirements.txt ├── run_tests.sh ├── setup.py ├── sfconfig.py.in ├── tests ├── benchmark_test.py ├── python_client_test.py ├── xmlclient_test.py └── xmltramp_test.py └── tox.ini /.github/workflows/pythonpackage.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python package 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python-version: [3.7] 19 | # Eventually, when running tests this should be done for all versions 20 | # python-version: [2.7, 3.5, 3.6, 3.7, 3.8, pypy2, pypy3] 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | - name: Set up Python ${{ matrix.python-version }} 25 | uses: actions/setup-python@v1 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | - name: Install virtualenv 29 | run: | 30 | python -m pip install --upgrade pip 31 | pip install virtualenv 32 | - name: Run lint with pre-commit hooks 33 | run: | 34 | make check 35 | -------------------------------------------------------------------------------- /.github/workflows/pythonpublish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [published, edited] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 18 | uses: actions/setup-python@v1 19 | with: 20 | python-version: '3.x' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install setuptools wheel twine 25 | - name: Build and publish 26 | env: 27 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 28 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 29 | run: | 30 | python setup.py sdist bdist_wheel 31 | twine upload dist/* 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | env/ 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 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 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *,cover 49 | .hypothesis/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | doc/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # dotenv 85 | .env 86 | 87 | # virtualenv 88 | .venv 89 | venv/ 90 | ENV/ 91 | 92 | # Spyder project settings 93 | .spyderproject 94 | 95 | # Rope project settings 96 | .ropeproject 97 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | - repo: git://github.com/pre-commit/pre-commit-hooks 2 | rev: v0.9.1 3 | hooks: 4 | - id: trailing-whitespace 5 | - id: end-of-file-fixer 6 | - id: autopep8-wrapper 7 | args: 8 | - -i 9 | - --ignore=E265,E309,E501 10 | - id: debug-statements 11 | exclude: examples/* 12 | # - id: flake8 13 | # exclude: examples/* 14 | - id: check-yaml 15 | - id: check-json 16 | - id: check-merge-conflict 17 | - id: name-tests-test 18 | exclude: tests/(common.py|util.py|(helpers)/(.+).py) 19 | - repo: git://github.com/asottile/reorder_python_imports 20 | rev: v0.3.5 21 | hooks: 22 | - id: reorder-python-imports 23 | language_version: python2.7 24 | args: 25 | - --add-import 26 | - from __future__ import absolute_import 27 | - --add-import 28 | - from __future__ import unicode_literals 29 | -------------------------------------------------------------------------------- /CHANGES.txt: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 1.9.1 (2020-04-13) 5 | Fix xmlbomb vuln again #43 6 | 7 | 1.9.0 (2020-03-13) 8 | Add Python 3 support #42 9 | 10 | 1.8.0 (2017-11-21) 11 | Fix external entities vulnerability #35 12 | 13 | 1.7.3 (2016-08-22) 14 | Bugfix: initialize value of reduce in getRecordTypes 15 | 16 | 1.7.2 (2015-12-21) 17 | Upgraded xmltramp to 2.18 with some inline patching. 18 | Experimenting with Travis-CI integration. 19 | 20 | 1.7.1 (2015-12-14) 21 | Corrected regression bug in xmltramp when updating to new style classes. Fixes issue 26. 22 | 23 | 1.7 (2015-12-08) 24 | Address type marshalling to Dict. PEP8 cleanup. New-style exception classes. 25 | 26 | 1.6 (2015-04-27) 27 | Introduce logout functionality. New maintainer. 28 | 29 | 1.5 (2014-10-30) 30 | Introduce sendEmail functionality. 31 | 32 | 1.4 (2014-08-14) 33 | Introduce convertLead functionality. 34 | 35 | 1.3 (2013-11-7) 36 | Bugfix introduced in 1.2 37 | 38 | 1.2 (2013-10-25) 39 | * Two bugfixes for supporting Python 2.7 40 | * Fix some dos encoding issues 41 | Thanks to: @lociii and @gabber7 42 | 43 | 1.01 (2013-04-10) 44 | * Fix MANIFEST.in for releasing to pypi 45 | 46 | 1.0 (2013-04-01) 47 | * Rename beatbox to pyforce 48 | * Support embedded dictionaries in python objects submitted to the python 49 | client 50 | * Rename writeStringElement method to writeElement for more accuracy in name 51 | 52 | Beatbox forked as Pyforce 53 | ------------------------- 54 | 55 | 20.0 (2010-11-30) 56 | ----------------- 57 | 58 | * Add 'encryptedstring' to the list of types marshalled as strings. Thanks 59 | sobyone. 60 | [davisagli] 61 | 62 | * Update to use version 20.0 of the Salesforce.com partner WSDL by default. 63 | [davisagli] 64 | 65 | 19.0 (2010-08-23) 66 | ----------------- 67 | 68 | * Update marshalling of describeGlobal and describeSObjects responses to 69 | include new properties now returned by the API. For backwards 70 | compatibility, we set the types property of the describeGlobal response 71 | to a list of the names of all types (which Salesforce now returns in 72 | separate DescribeGlobalSObjectResult objects). 73 | [davisagli] 74 | 75 | * Update to use version 19.0 of the Salesforce.com partner WSDL by default. 76 | Also, use the new login.salesforce.com login endpoint by default. 77 | [davisagli] 78 | 79 | 16.1 (2010-03-11) 80 | ----------------- 81 | 82 | * Catch and retry on exceptions from the socket library, in addition to ones 83 | from httplib. This fixes a regression introduced in version 16.0. 84 | [davisagli] 85 | 86 | 87 | 16.0 (2009-11-12) 88 | ----------------- 89 | 90 | * Don't strip newlines when marshalling the values of textarea fields. 91 | [davisagli] 92 | 93 | * Make sure to add a field to fieldsToNull if its Python value is None. 94 | [rhettg, davisagli] 95 | 96 | * Fix issue where numbers of type long weren't converted to a string. 97 | [spleeman, davisagli] 98 | 99 | * Only catch HTTP exceptions when retrying a connection. 100 | [spleeman, davisagli] 101 | 102 | 103 | 16.0b1 (2009-09-08) 104 | ------------------- 105 | 106 | * Log beatbox calls at the debug level. 107 | [davisagli] 108 | 109 | * Fixed a string exception for compatibility with Python 2.6. 110 | [davisagli] 111 | 112 | * Added support for SOSL searches via the search method. Thanks to Alex Tokar 113 | of Web Collective. 114 | [davisagli] 115 | 116 | * Added an optional cache for the sObject type descriptions needed for 117 | marshalling query results into Python objects. This can avoid an extra 118 | describeSObjects API call for each query, but means that the information 119 | could become stale if the type metadata is modified in Salesforce.com. 120 | The cache is off by default. Turn it on by passing 121 | cacheTypeDescriptions=True when instantiating a Python client. The cache may 122 | be reset by calling the flushTypeDescriptionsCache method of the Python 123 | client. 124 | [davisagli] 125 | 126 | * Support a full SOQL statement as a parameter to the query method of the 127 | Python client. The old 3-part method signature (fields, sObjectType, 128 | conditionalExpression) should continue to work. 129 | [davisagli] 130 | 131 | * In the Python client, support relationship queries and other queries that may 132 | return multiple types of objects. Object type descriptions (required for 133 | marshalling field values into the correct Python type) are cached for the 134 | duration of the query after the first time they are used. Thanks to 135 | Melnychuk Taras of Quintagroup. 136 | [davisagli] 137 | 138 | * In the Python client, queries now return a list-like QueryRecordSet holding 139 | a sequence of dict-like QueryRecord objects, instead of a dict containing a 140 | list of dicts. This allows for more Pythonic access such as results[0].Id 141 | instead of results['results'][0]['Id']. The old syntax should still work. 142 | Thanks to Melnychuk Taras of Quintagroup. 143 | [davisagli] 144 | 145 | * Update to use version 16.0 of the Salesforce.com partner WSDL. 146 | [davisagli] 147 | 148 | 149 | 0.12 (2009-05-13) 150 | ----------------- 151 | 152 | * Use the default serverUrl value if the passed value evaluates to boolean 153 | False. 154 | [davisagli] 155 | 156 | 0.11 (2009-05-13) 157 | ----------------- 158 | 159 | * Access 'created' instead of 'isCreated' in the upsert result. This closes 160 | http://code.google.com/p/salesforce-beatbox/issues/detail?id=4 161 | [davisagli] 162 | 163 | 10.1 (unreleased) 164 | ----------------- 165 | 166 | 0.10 (2009-05-06) 167 | ----------------- 168 | 169 | * Added optional serverUrl parameter when creating a Client. 170 | [davisagli] 171 | 172 | pre 0.9.1.1 173 | ----------- 174 | 175 | * ancient history 176 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc. 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License 307 | along with this program; if not, write to the Free Software 308 | Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 309 | 310 | 311 | Also add information on how to contact you by electronic and paper mail. 312 | 313 | If the program is interactive, make it output a short notice like this 314 | when it starts in an interactive mode: 315 | 316 | Gnomovision version 69, Copyright (C) year name of author 317 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 318 | This is free software, and you are welcome to redistribute it 319 | under certain conditions; type `show c' for details. 320 | 321 | The hypothetical commands `show w' and `show c' should show the appropriate 322 | parts of the General Public License. Of course, the commands you use may 323 | be called something other than `show w' and `show c'; they could even be 324 | mouse-clicks or menu items--whatever suits your program. 325 | 326 | You should also get your employer (if you work as a programmer) or your 327 | school, if any, to sign a "copyright disclaimer" for the program, if 328 | necessary. Here is a sample; alter the names: 329 | 330 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 331 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 332 | 333 | , 1 April 1989 334 | Ty Coon, President of Vice 335 | 336 | This General Public License does not permit incorporating your program into 337 | proprietary programs. If your program is a subroutine library, you may 338 | consider it more useful to permit linking proprietary applications with the 339 | library. If this is what you want to do, use the GNU Lesser General 340 | Public License instead of this License. 341 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.txt 2 | include *.md 3 | recursive-include examples *.txt *.py 4 | prune src/pyforce/tests/sfconfig.py 5 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VENV ?= venv 2 | PYTHON ?= python3 3 | 4 | .PHONY: default 5 | default: test 6 | 7 | $(VENV): 8 | virtualenv -p $(PYTHON) $(VENV) 9 | $(VENV)/bin/pip install -r ./requirements-dev.txt 10 | 11 | .PHONY: test 12 | test: 13 | tox 14 | 15 | .PHONY: test-py2 16 | test-py2: 17 | tox -e py27 18 | 19 | .PHONY: test-py3 20 | test-py3: 21 | tox -e py3 22 | 23 | .PHONY: install-hooks 24 | install-hooks: $(VENV) 25 | $(VENV)/bin/pre-commit install --install-hooks 26 | 27 | .PHONY: check 28 | check: $(VENV) 29 | $(VENV)/bin/pre-commit run --all-files 30 | 31 | .PHONY: clean 32 | clean: 33 | find . -name '*.pyc' -delete 34 | find . -name '__pycache__' -delete 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Pypi Version](https://img.shields.io/pypi/v/pyforce.svg) ![Pypi Downloads](https://img.shields.io/pypi/dm/pyforce.svg) 2 | 3 | Introduction 4 | ============ 5 | 6 | This is a reluctant fork of the beatbox project originally authored by Simon 7 | Fell, (their version locked at 0.92) later drastically changed by these folks 8 | https://code.google.com/p/salesforce-beatbox/ (versioned at 20.0). 9 | 10 | Renamed to `pyforce` to avoid confusion related to the fractured community 11 | version. (https://github.com/superfell/Beatbox/issues/6) Long story short, 12 | the python client in the fork at version 20.0 is exceptionally useful, so 13 | going back to 0.92 would be a mistake, however the beatbox version at 20.0 is 14 | also no longer maintained (judging by the issues). `pyforce` builds off 15 | the version available there, integrating bug fixes, and new features. 16 | 17 | This module contains 2 versions of the Salesforce.com client: 18 | 19 | XMLClient 20 | An xmltramp wrapper that handles the xml fun. 21 | PythonClient 22 | Marshalls the returned objects into proper Python data types. e.g. integer 23 | fields return integers. 24 | 25 | Compatibility 26 | ============= 27 | 28 | `pyforce` supports versions 16.0 through 20.0 of the Salesforce Partner Web 29 | Services API. However, the following API calls have not been implemented at 30 | this time: 31 | 32 | * emptyRecycleBin 33 | * invalidateSessions 34 | * merge 35 | * process 36 | * queryAll 37 | * undelete 38 | * describeSObject 39 | * describeDataCategoryGroups 40 | * describeDataCategoryGroupStructures 41 | 42 | `pyforce` supports python 2.x for values of x >=7 as well as Python 3.x. 43 | 44 | Basic Usage Examples 45 | ==================== 46 | 47 | Instantiate a Python Salesforce.com client: 48 | >>> svc = pyforce.PythonClient() 49 | >>> svc.login('username', 'passwordTOKEN') 50 | 51 | (Note that interacting with Salesforce.com via the API requires the use of a 52 | 'security token' which must be appended to the password. See sfdc docs for 53 | details) 54 | 55 | The pyforce client allows you to query with sfdc SOQL. 56 | 57 | Here's an example of a query for contacts with last name 'Doe': 58 | 59 | res = svc.query("SELECT Id, FirstName, LastName FROM Contact WHERE LastName='Doe'") 60 | res[0] 61 | {'LastName': 'Doe', 'type': 'Contact', 'Id': '0037000000eRf6vAAC', 'FirstName': 'John'} 62 | res[0].Id 63 | '0037000000eRf6vAAC' 64 | 65 | Add a new Lead: 66 | 67 | contact = { 68 | 'type': 'Lead', 69 | 'LastName': 'Ian', 70 | 'FirstName': 'Bentley', 71 | 'Company': '10gen' 72 | } 73 | res = svc.create(contact) 74 | if not res[0]['errors']: 75 | contact_id = res[0]['id'] 76 | else: 77 | raise Exception('Contact creation failed {0}'.format(res[0]['errors'])) 78 | 79 | Batches work automatically (though sfdc limits the number to 200 maximum): 80 | 81 | contacts = [ 82 | { 83 | 'type': 'Lead', 84 | 'LastName': 'Glick', 85 | 'FirstName': 'David', 86 | 'Company': 'Individual' 87 | }, 88 | { 89 | 'type': 'Lead', 90 | 'LastName': 'Ian', 91 | 'FirstName': 'Bentley', 92 | 'Company': '10gen' 93 | } 94 | ] 95 | res = svc.create(contacts) 96 | 97 | Send a new email, optionally using templates, including attachments and creating activities for associated objects: 98 | 99 | simple_email = { 100 | 'subject': 'Test of Salesforce sendEmail()', 101 | 'plainTextBody': 'This is a simple test message.', 102 | 'toAddresses': 'johndoe@example.com, 103 | } 104 | res = svc.sendEmail( [simple_email] ) 105 | res 106 | [{'errors': [], 'success': True}] 107 | 108 | complex_email = { 109 | 'templateId': '00X80000002h4TV', # Id of an EmailTemplate used for Subject and Body, supports field merge from whatId. 110 | 'targetObjectId':'003808980000GJ', # Id of a Contact, Lead or User which the email will be sent to. 111 | 'whatId':'500800000RuJo', # Id of a SObject to create an Activity in. 112 | 'saveAsActivity': True, 113 | 'useSignature': True, 114 | 'inReplyTo': '<1234567890123456789%example@example.com>', # RFC2822, a previous email thread. 115 | 'references': '<1234567890123456789%example@example.com>', 116 | 'fileAttachments': [{ 117 | 'body': base64_encoded_png, 118 | 'contentType':'image/png', 119 | 'fileName':'salesforce_logo.png', 120 | 'inline':True 121 | }] 122 | } 123 | res = svc.sendEmail( [complex_email] ) 124 | res 125 | [{'errors': [], 'success': True}] 126 | 127 | More Examples 128 | ============= 129 | 130 | The examples folder contains the examples for the xml client. For examples on 131 | how to use the python client see the tests directory. 132 | 133 | Some of these other products that were built on top of beatbox can also provide 134 | example of `pyforce` use, though this project may diverge from the beatbox api. 135 | 136 | * [`Salesforce Base Connector`](http://plone.org/products/salesforcebaseconnector) 137 | * [`Salesforce PFG Adapter`](http://plone.org/products/salesforcepfgadapter) 138 | * [`Salesforce Auth Plugin`](http://plone.org/products/salesforceauthplugin) 139 | * [`RSVP for Salesforce`](http://plone.org/products/collective.salesforce.rsvp) 140 | 141 | 142 | Alternatives 143 | ============ 144 | 145 | David Lanstein has created a `Python Salesforce Toolkit` that is based on the 146 | `suds` SOAP library. That project has not seen any commit since June 2011, so 147 | it is assumed to be abandoned. 148 | 149 | .. `Python Salesforce Toolkit`: http://code.google.com/p/salesforce-python-toolkit/ 150 | 151 | Running Tests 152 | ============= 153 | 154 | At the fork time, all tests are integration tests that require access to a 155 | Salesforce environment. It is my intent to change these tests to be stub 156 | based unit tests. 157 | 158 | From the beatbox documentation: 159 | 160 | First, we need to add some custom fields to the Contacts object in your Salesforce instance: 161 | 162 | *Login to your Salesforce.com instance 163 | * Browse to Setup --> Customize --> Contacts --> Fields --> "New" button 164 | * Add a Picklist (multi-select) labeled "Favorite Fruit", then add 165 | * Apple 166 | * Orange 167 | * Pear 168 | * Leave default of 3 lines and field name should default to "Favorite_Fruit" 169 | * Add a Number labeled "Favorite Integer", with 18 places, 0 decimal places 170 | * Add a Number labeled "Favorite Float", with 13 places, 5 decimal places 171 | 172 | Export environment variables with your Salesforce credentials: 173 | 174 | SF_USERNAME, SF_PASSWORD, SF_SECTOKEN 175 | 176 | Run the tests:: 177 | 178 | make test 179 | -------------------------------------------------------------------------------- /examples/demo.py: -------------------------------------------------------------------------------- 1 | # demonstration of using the BeatBox library to call the sforce API 2 | from __future__ import absolute_import 3 | from __future__ import unicode_literals 4 | 5 | import datetime 6 | import sys 7 | 8 | import pyforce 9 | 10 | sf = pyforce._tPartnerNS 11 | svc = pyforce.Client() 12 | 13 | 14 | class BeatBoxDemo(object): 15 | def login(self, username, password): 16 | self.password = password 17 | loginResult = svc.login(username, password) 18 | print("sid = " + str(loginResult[sf.sessionId])) 19 | print("welcome " + str(loginResult[sf.userInfo][sf.userFullName])) 20 | 21 | def getServerTimestamp(self): 22 | print("\ngetServerTimestamp " + svc.getServerTimestamp()) 23 | 24 | def describeGlobal(self): 25 | print("\ndescribeGlobal") 26 | dg = svc.describeGlobal() 27 | for t in dg[sf.types, ]: 28 | print(str(t)) 29 | 30 | def describeTabs(self): 31 | print("\ndescribeTabs") 32 | dt = svc.describeTabs() 33 | for t in dt: 34 | print(str(t[sf.label])) 35 | 36 | def query(self): 37 | qr = svc.query("select Id, Name from Account") 38 | print("query size = " + str(qr[sf.size])) 39 | 40 | for rec in qr[sf.records, ]: 41 | print(str(rec[0]) + " : " + str(rec[2]) + " : " + str(rec[3])) 42 | 43 | if (str(qr[sf.done]) == 'false'): 44 | print("\nqueryMore") 45 | qr = svc.queryMore(str(qr[sf.queryLocator])) 46 | for rec in qr[sf.records, ]: 47 | print(str(rec[0]) + " : " + str(rec[2]) + " : " + str(rec[3])) 48 | 49 | def upsert(self): 50 | print("\nupsert") 51 | t = {'type': 'Task', 52 | 'ChandlerId__c': '12345', 53 | 'subject': 'BeatBoxTest updated', 54 | 'ActivityDate': datetime.date(2006, 2, 20)} 55 | 56 | ur = svc.upsert('ChandlerId__c', t) 57 | print(str(ur[sf.success]) + " -> " + str(ur[sf.id])) 58 | 59 | t = {'type': 'Event', 60 | 'ChandlerId__c': '67890', 61 | 'durationinminutes': 45, 62 | 'subject': 'BeatBoxTest', 63 | 'ActivityDateTime': datetime.datetime(2006, 2, 20, 13, 30, 30), 64 | 'IsPrivate': False} 65 | ur = svc.upsert('ChandlerId__c', t) 66 | if str(ur[sf.success]) == 'true': 67 | print("id " + str(ur[sf.id])) 68 | else: 69 | print("error " + str(ur[sf.errors][sf.statusCode]) + ":" + str(ur[sf.errors][sf.message])) 70 | 71 | def update(self): 72 | print("\nupdate") 73 | a = {'type': 'Account', 74 | 'Id': '00130000005MSO4', 75 | 'Name': 'BeatBoxBaby', 76 | 'NumberofLocations__c': 123.456} 77 | sr = svc.update(a) 78 | 79 | if str(sr[sf.success]) == 'true': 80 | print("id " + str(sr[sf.id])) 81 | else: 82 | print("error " + str(sr[sf.errors][sf.statusCode]) + ":" + str(sr[sf.errors][sf.message])) 83 | 84 | def create(self): 85 | print("\ncreate") 86 | a = {'type': 'Account', 87 | 'Name': 'New Account', 88 | 'Website': 'http://www.pocketsoap.com/'} 89 | sr = svc.create([a]) 90 | 91 | if str(sr[sf.success]) == 'true': 92 | print("id " + str(sr[sf.id])) 93 | self.__idToDelete = str(sr[sf.id]) 94 | else: 95 | print("error " + str(sr[sf.errors][sf.statusCode]) + ":" + str(sr[sf.errors][sf.message])) 96 | 97 | def getUpdated(self): 98 | print("\ngetUpdated") 99 | updatedIds = svc.getUpdated("Account", datetime.datetime.today() - datetime.timedelta(1), datetime.datetime.today() + datetime.timedelta(1)) 100 | self.__theIds = [] 101 | for id in updatedIds: 102 | print("getUpdated " + str(id)) 103 | self.__theIds.append(str(id)) 104 | 105 | def delete(self): 106 | print("\ndelete") 107 | dr = svc.delete(self.__idToDelete) 108 | if str(dr[sf.success]) == 'true': 109 | print("deleted id " + str(dr[sf.id])) 110 | else: 111 | print("error " + str(dr[sf.errors][sf.statusCode]) + ":" + str(dr[sf.errors][sf.message])) 112 | 113 | def getDeleted(self): 114 | print("\ngetDeleted") 115 | drs = svc.getDeleted("Account", datetime.datetime.today() - datetime.timedelta(1), datetime.datetime.today() + datetime.timedelta(1)) 116 | for dr in drs: 117 | print("getDeleted " + str(dr[sf.id]) + " on " + str(dr[sf.deletedDate])) 118 | 119 | def retrieve(self): 120 | print("\nretrieve") 121 | accounts = svc.retrieve("id, name", "Account", self.__theIds) 122 | for acc in accounts: 123 | if len(acc._dir) > 0: 124 | print(str(acc[pyforce._tSObjectNS.Id]) + " : " + str(acc[pyforce._tSObjectNS.Name])) 125 | else: 126 | print("") 127 | 128 | def getUserInfo(self): 129 | print("\ngetUserInfo") 130 | ui = svc.getUserInfo() 131 | print("hello " + str(ui[sf.userFullName]) + " from " + str(ui[sf.organizationName])) 132 | 133 | def resetPassword(self): 134 | ui = svc.getUserInfo() 135 | print("\nresetPassword") 136 | pr = svc.resetPassword(str(ui[sf.userId])) 137 | print("password reset to " + str(pr[sf.password])) 138 | 139 | print("\nsetPassword") 140 | svc.setPassword(str(ui[sf.userId]), self.password) 141 | print("password set back to original password") 142 | 143 | def describeSObjects(self): 144 | print("\ndescribeSObjects(Account)") 145 | desc = svc.describeSObjects("Account") 146 | for f in desc[sf.fields, ]: 147 | print("\t" + str(f[sf.name])) 148 | 149 | print("\ndescribeSObjects(Lead, Contact)") 150 | desc = svc.describeSObjects(["Lead", "Contact"]) 151 | for d in desc: 152 | print(str(d[sf.name]) + "\n" + ("-" * len(str(d[sf.name])))) 153 | for f in d[sf.fields, ]: 154 | print("\t" + str(f[sf.name])) 155 | 156 | def describeLayout(self): 157 | print("\ndescribeLayout(Account)") 158 | desc = svc.describeLayout("Account") 159 | for layout in desc[sf.layouts, ]: 160 | print("sections in detail layout " + str(layout[sf.id])) 161 | for s in layout[sf.detailLayoutSections, ]: 162 | print("\t" + str(s[sf.heading])) 163 | 164 | 165 | if __name__ == "__main__": 166 | 167 | if len(sys.argv) != 3: 168 | print("usage is demo.py ") 169 | else: 170 | demo = BeatBoxDemo() 171 | demo.login(sys.argv[1], sys.argv[2]) 172 | demo.getServerTimestamp() 173 | demo.getUserInfo() 174 | demo.resetPassword() 175 | demo.describeGlobal() 176 | demo.describeTabs() 177 | demo.describeSObjects() 178 | demo.describeLayout() 179 | demo.query() 180 | demo.upsert() 181 | demo.update() 182 | demo.create() 183 | demo.getUpdated() 184 | demo.delete() 185 | demo.getDeleted() 186 | demo.retrieve() 187 | -------------------------------------------------------------------------------- /examples/export.py: -------------------------------------------------------------------------------- 1 | # runs a sforce SOQL query and saves the results as a csv file. 2 | from __future__ import absolute_import 3 | from __future__ import unicode_literals 4 | 5 | import string 6 | import sys 7 | 8 | import pyforce 9 | 10 | sf = pyforce._tPartnerNS 11 | svc = pyforce.Client() 12 | 13 | 14 | def buildSoql(sobjectName): 15 | dr = svc.describeSObjects(sobjectName) 16 | soql = "" 17 | for f in dr[sf.fields, ]: 18 | if len(soql) > 0: 19 | soql += ',' 20 | soql += str(f[sf.name]) 21 | return "select " + soql + " from " + sobjectName 22 | 23 | 24 | def printColumnHeaders(queryResult): 25 | needsComma = 0 26 | # note that we offset 2 into the records child collection to skip the type and base sObject id elements 27 | for col in queryResult[sf.records][2:]: 28 | if needsComma: 29 | print(',',) 30 | else: 31 | needsComma = 1 32 | print(col._name[1],) 33 | 34 | 35 | def export(username, password, objectOrSoql): 36 | svc.login(username, password) 37 | if string.find(objectOrSoql, ' ') < 0: 38 | soql = buildSoql(objectOrSoql) 39 | else: 40 | soql = objectOrSoql 41 | 42 | qr = svc.query(soql) 43 | printHeaders = 1 44 | while True: 45 | if printHeaders: 46 | printColumnHeaders(qr) 47 | printHeaders = 0 48 | for row in qr[sf.records, ]: 49 | needsComma = False 50 | for col in row[2:]: 51 | if needsComma: 52 | print(',',) 53 | else: 54 | needsComma = True 55 | print(str(col),) 56 | print 57 | if str(qr[sf.done]) == 'true': 58 | break 59 | qr = svc.queryMore(str(qr[sf.queryLocator])) 60 | 61 | 62 | if __name__ == "__main__": 63 | 64 | if len(sys.argv) != 4: 65 | print("usage is export.py [ || ]") 66 | else: 67 | export(sys.argv[1], sys.argv[2], sys.argv[3]) 68 | -------------------------------------------------------------------------------- /examples/soql2atom.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | """soql2atom: a `pyforce` demo that generates an atom 1.0 formatted feed of any SOQL query (adapted from Simon Fell's pyforce example) 3 | 4 | The fields Id, SystemModStamp and CreatedDate are automatically added to the SOQL if needed. 5 | The first field in the select list becomes the title of the entry, so make sure to setup the order of the fields as you need. 6 | The soql should be passed via a 'soql' queryString parameter 7 | Optionally, you can also pass a 'title' queryString parameter to set the title of the feed 8 | 9 | The script forces authentication, but many apache installations are configured to block the AUTHORIZATION header, 10 | so the scirpt looks for X_HTTP_AUTHORIZATION instead, you can use a mod_rewrite rule to manage the mapping, something like this 11 | 12 | Options +FollowSymLinks 13 | RewriteEngine on 14 | RewriteRule ^(.*)$ soql2atom.py [E=X-HTTP_AUTHORIZATION:%{HTTP:Authorization},QSA,L] 15 | 16 | I have this in a .htaccess file in the same directory as soql2atom.py etc. 17 | """ 18 | from __future__ import absolute_import 19 | from __future__ import unicode_literals 20 | 21 | __version__ = "1.0" 22 | __author__ = "Simon Fell" 23 | __copyright__ = "(C) 2006 Simon Fell. GNU GPL 2." 24 | 25 | import base64 26 | import cgi 27 | import cgitb 28 | import datetime 29 | import os 30 | import string 31 | from xml.sax.xmlreader import AttributesNSImpl 32 | 33 | from urlparse import urlparse 34 | 35 | import pyforce 36 | 37 | cgitb.enable() 38 | sf = pyforce._tPartnerNS 39 | svc = pyforce.Client() 40 | _noAttrs = pyforce._noAttrs 41 | 42 | 43 | def addRequiredFieldsToSoql(soql): 44 | findPos = string.find(string.lower(soql), "from") 45 | selectList = [] 46 | for f in string.lower(soql)[:findPos].split(","): 47 | selectList.append(string.strip(f)) 48 | if not "id" in selectList: 49 | selectList.append("Id") 50 | if not "systemmodstamp" in selectList: 51 | selectList.append("systemModStamp") 52 | if not "createddate" in selectList: 53 | selectList.append("createdDate") 54 | return string.join(selectList, ", ") + soql[findPos - 1:] 55 | 56 | 57 | def soql2atom(loginResult, soql, title): 58 | soqlWithFields = addRequiredFieldsToSoql(soql) 59 | userInfo = loginResult[pyforce._tPartnerNS.userInfo] 60 | serverUrl = str(loginResult[pyforce._tPartnerNS.serverUrl]) 61 | (scheme, host, path, params, query, frag) = urlparse(serverUrl) 62 | sfbaseUrl = scheme + "://" + host + "/" 63 | thisUrl = "http://" + os.environ["HTTP_HOST"] + os.environ["REQUEST_URI"] 64 | qr = svc.query(soqlWithFields) 65 | 66 | atom_ns = "http://www.w3.org/2005/Atom" 67 | ent_ns = "urn:sobject.enterprise.soap.sforce.com" 68 | 69 | print("content-type: application/atom+xml") 70 | doGzip = os.environ.has_key("HTTP_ACCEPT_ENCODING") and "gzip" in string.lower(os.environ["HTTP_ACCEPT_ENCODING"]).split(',') 71 | if (doGzip): 72 | print("content-encoding: gzip") 73 | print("") 74 | x = pyforce.XmlWriter(doGzip) 75 | x.startPrefixMapping("a", atom_ns) 76 | x.startPrefixMapping("s", ent_ns) 77 | x.startElement(atom_ns, "feed") 78 | x.writeStringElement(atom_ns, "title", title) 79 | x.characters("\n") 80 | x.startElement(atom_ns, "author") 81 | x.writeStringElement(atom_ns, "name", str(userInfo.userFullName)) 82 | x.endElement() 83 | x.characters("\n") 84 | rel = AttributesNSImpl({(None, "rel"): "self", (None, "href"): thisUrl}, 85 | {(None, "rel"): "rel", (None, "href"): "href"}) 86 | x.startElement(atom_ns, "link", rel) 87 | x.endElement() 88 | x.writeStringElement(atom_ns, "updated", datetime.datetime.utcnow().isoformat() + "Z") 89 | x.writeStringElement(atom_ns, "id", thisUrl + "&userid=" + str(loginResult[ 90 | pyforce._tPartnerNS.userId])) 91 | x.characters("\n") 92 | type = AttributesNSImpl({(None, u"type"): "html"}, {(None, u"type"): u"type"}) 93 | for row in qr[sf.records, ]: 94 | x.startElement(atom_ns, "entry") 95 | desc = "" 96 | x.writeStringElement(atom_ns, "title", str(row[2])) 97 | for col in row[2:]: 98 | if col._name[1] == 'Id': 99 | x.writeStringElement(atom_ns, "id", sfbaseUrl + str(col)) 100 | writeLink(x, atom_ns, "link", "alternate", "text/html", sfbaseUrl + str(col)) 101 | elif col._name[1] == 'SystemModstamp': 102 | x.writeStringElement(atom_ns, "updated", str(col)) 103 | elif col._name[1] == 'CreatedDate': 104 | x.writeStringElement(atom_ns, "published", str(col)) 105 | elif str(col) != "": 106 | desc = desc + "" + col._name[1] + " : " + str(col) + "
" 107 | x.writeStringElement(ent_ns, col._name[1], str(col)) 108 | x.startElement(atom_ns, "content", type) 109 | x.characters(desc) 110 | x.endElement() # content 111 | x.characters("\n") 112 | x.endElement() # entry 113 | x.endElement() # feed 114 | print(x.endDocument()) 115 | 116 | 117 | def writeLink(x, namespace, localname, rel, type, href): 118 | rel = AttributesNSImpl({(None, "rel"): rel, (None, "href"): href, (None, "type"): type}, 119 | {(None, "rel"): "rel", (None, "href"): "href", (None, "type"): "type"}) 120 | x.startElement(namespace, localname, rel) 121 | x.endElement() 122 | 123 | 124 | def authenticationRequired(message="Unauthorized"): 125 | print("status: 401 Unauthorized") 126 | print("WWW-authenticate: Basic realm=""www.salesforce.com""") 127 | print("content-type: text/plain") 128 | print("") 129 | print(message) 130 | 131 | 132 | if not os.environ.has_key('X_HTTP_AUTHORIZATION') or os.environ['X_HTTP_AUTHORIZATION'] == '': 133 | authenticationRequired() 134 | else: 135 | auth = os.environ['X_HTTP_AUTHORIZATION'] 136 | (username, password) = base64.decodestring(auth.split(" ")[1]).split(':') 137 | form = cgi.FieldStorage() 138 | if not form.has_key('soql'): 139 | raise Exception("Must provide the SOQL query to run via the soql queryString parameter") 140 | soql = form.getvalue("soql") 141 | title = "SOQL2ATOM : " + soql 142 | if form.has_key("title"): 143 | title = form.getvalue("title") 144 | try: 145 | lr = svc.login(username, password) 146 | soql2atom(lr, soql, title) 147 | except pyforce.SoapFaultError as sfe: 148 | if (sfe.faultCode == 'INVALID_LOGIN'): 149 | authenticationRequired(sfe.faultString) 150 | else: 151 | raise 152 | -------------------------------------------------------------------------------- /pyforce/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from __future__ import unicode_literals 3 | 4 | import logging 5 | 6 | from pyforce.pyclient import Client as PythonClient 7 | from pyforce.xmlclient import Client as XMLClient 8 | from pyforce.xmlclient import SessionTimeoutError 9 | from pyforce.xmlclient import SoapFaultError 10 | 11 | __all__ = ( 12 | 'PythonClient', 13 | 'SoapFaultError', 14 | 'SessionTimeoutError', 15 | 'XMLClient' 16 | ) 17 | 18 | 19 | class NullHandler(logging.Handler): 20 | def emit(self, record): 21 | pass 22 | 23 | 24 | logging.getLogger("pyforce").addHandler(NullHandler()) 25 | -------------------------------------------------------------------------------- /pyforce/common.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # >> 4 | # pyforce, 2017 5 | # << 6 | from __future__ import absolute_import 7 | from __future__ import unicode_literals 8 | 9 | 10 | def bool_(val): 11 | return str(val) == 'true' 12 | -------------------------------------------------------------------------------- /pyforce/marshall.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from __future__ import unicode_literals 3 | 4 | import datetime 5 | import logging 6 | import re 7 | 8 | from six import text_type 9 | 10 | from pyforce.common import bool_ 11 | from pyforce.xmlclient import _tSObjectNS 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | dateregx = re.compile(r'(\d{4})-(\d{2})-(\d{2})') 16 | datetimeregx = re.compile( 17 | r'(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d+)(.*)' 18 | ) 19 | 20 | doubleregx = re.compile(r'^(\d)+(\.\d+)?$') 21 | 22 | stringtypes = ('string', 'id', 'phone', 'url', 'email', 23 | 'anyType', 'picklist', 'reference', 'encryptedstring') 24 | 25 | texttypes = ('textarea') 26 | 27 | doubletypes = ('double', 'currency', 'percent') 28 | 29 | multitypes = ('combobox', 'multipicklist') 30 | 31 | dicttypes = ('address') 32 | 33 | _marshallers = dict() 34 | 35 | 36 | def marshall(fieldtype, fieldname, xml, ns=_tSObjectNS): 37 | m = _marshallers[fieldtype] 38 | return m(fieldname, xml, ns) 39 | 40 | 41 | def register(fieldtypes, func): 42 | if not isinstance(fieldtypes, (list, tuple, dict)): 43 | fieldtypes = [fieldtypes] 44 | for t in fieldtypes: 45 | _marshallers[t] = func 46 | 47 | 48 | def stringMarshaller(fieldname, xml, ns): 49 | return text_type(xml[getattr(ns, fieldname)]) 50 | 51 | 52 | register(stringtypes, stringMarshaller) 53 | 54 | 55 | def textMarshaller(fieldname, xml, ns): 56 | # Avoid removal of newlines. 57 | return stringMarshaller(fieldname, xml, ns) 58 | 59 | 60 | register(texttypes, textMarshaller) 61 | 62 | 63 | def multiMarshaller(fieldname, xml, ns): 64 | asString = text_type(xml[getattr(ns, fieldname), ][0]) 65 | if not asString: 66 | return [] 67 | return asString.split(';') 68 | 69 | 70 | register(multitypes, multiMarshaller) 71 | 72 | 73 | def booleanMarshaller(fieldname, xml, ns): 74 | return bool_(xml[getattr(ns, fieldname)]) 75 | 76 | 77 | register('boolean', booleanMarshaller) 78 | 79 | 80 | def integerMarshaller(fieldname, xml, ns): 81 | strVal = text_type(xml[getattr(ns, fieldname)]) 82 | try: 83 | i = int(strVal) 84 | return i 85 | except: 86 | return None 87 | 88 | 89 | register('int', integerMarshaller) 90 | 91 | 92 | def doubleMarshaller(fieldname, xml, ns): 93 | strVal = text_type(xml[getattr(ns, fieldname)]) 94 | try: 95 | i = float(strVal) 96 | return i 97 | except: 98 | return None 99 | 100 | 101 | register(doubletypes, doubleMarshaller) 102 | 103 | 104 | def dateMarshaller(fieldname, xml, ns): 105 | datestr = text_type(xml[getattr(ns, fieldname)]) 106 | match = dateregx.match(datestr) 107 | if match: 108 | grps = match.groups() 109 | year = int(grps[0]) 110 | month = int(grps[1]) 111 | day = int(grps[2]) 112 | return datetime.date(year, month, day) 113 | return None 114 | 115 | 116 | register('date', dateMarshaller) 117 | 118 | 119 | def dateTimeMarshaller(fieldname, xml, ns): 120 | datetimestr = text_type(xml[getattr(ns, fieldname)]) 121 | match = datetimeregx.match(datetimestr) 122 | if match: 123 | grps = match.groups() 124 | year = int(grps[0]) 125 | month = int(grps[1]) 126 | day = int(grps[2]) 127 | hour = int(grps[3]) 128 | minute = int(grps[4]) 129 | second = int(grps[5]) 130 | secfrac = float(grps[6]) 131 | microsecond = int(secfrac * (10**6)) 132 | tz = grps[7] # XXX not sure if I need to do anything with this. sofar 133 | # times appear to be UTC 134 | return datetime.datetime( 135 | year, month, day, hour, minute, second, microsecond 136 | ) 137 | return None 138 | 139 | 140 | register('datetime', dateTimeMarshaller) 141 | 142 | 143 | def base64Marshaller(fieldname, xml, ns): 144 | return text_type(xml[getattr(ns, fieldname)]) 145 | 146 | 147 | register('base64', base64Marshaller) 148 | 149 | 150 | def dictMarshaller(fieldname, xml, ns): 151 | mydict = {} 152 | for key in xml[getattr(ns, fieldname)]: 153 | mydict[key._name[1]] = key.__str__() 154 | return mydict 155 | 156 | 157 | register(dicttypes, dictMarshaller) 158 | -------------------------------------------------------------------------------- /pyforce/pyclient.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from __future__ import unicode_literals 3 | 4 | import copy 5 | import logging 6 | from functools import reduce 7 | 8 | from six import string_types 9 | from six import text_type 10 | 11 | from pyforce.common import bool_ 12 | from pyforce.marshall import marshall 13 | from pyforce.xmlclient import _tPartnerNS 14 | from pyforce.xmlclient import _tSchemaInstanceNS 15 | from pyforce.xmlclient import _tSObjectNS 16 | from pyforce.xmlclient import Client as BaseClient 17 | from pyforce.xmltramp import Namespace 18 | 19 | 20 | DEFAULT_FIELD_TYPE = "string" 21 | 22 | _tSchemaNS = Namespace('http://www.w3.org/2001/XMLSchema') 23 | _logger = logging.getLogger("pyforce.{0}".format(__name__)) 24 | 25 | 26 | class QueryRecord(dict): 27 | def __getattr__(self, n): 28 | return self[n] 29 | 30 | def __setattr__(self, n, v): 31 | self[n] = v 32 | 33 | 34 | class QueryRecordSet(list): 35 | def __init__(self, records, done, size, **kw): 36 | super(QueryRecordSet, self).__init__(records) 37 | self.done = done 38 | self.size = size 39 | for k, v in kw.items(): 40 | setattr(self, k, v) 41 | 42 | @property 43 | def records(self): 44 | return self 45 | 46 | def __getitem__(self, n): 47 | # If string, we can try to return a result attribute 48 | if isinstance(n, string_types): 49 | try: 50 | return getattr(self, n) 51 | except AttributeError: 52 | raise KeyError("Unknown key/attribute: {}".format(n)) 53 | # Otherwise, return a list item 54 | return super(QueryRecordSet, self).__getitem__(n) 55 | 56 | 57 | class SObject(object): 58 | def __init__(self, **kw): 59 | self.fields = {} 60 | 61 | for k, v in kw.items(): 62 | setattr(self, k, v) 63 | 64 | def marshall(self, fieldname, xml): 65 | if fieldname in self.fields.keys(): 66 | field = self.fields[fieldname] 67 | else: 68 | return marshall(DEFAULT_FIELD_TYPE, fieldname, xml) 69 | return field.marshall(xml) 70 | 71 | 72 | class Client(BaseClient): 73 | 74 | def __init__(self, serverUrl=None, cacheTypeDescriptions=False): 75 | BaseClient.__init__(self, serverUrl=serverUrl) 76 | self._typeDescs = None 77 | self.cacheTypeDescriptions = cacheTypeDescriptions 78 | 79 | @property 80 | def cacheTypeDescriptions(self): 81 | """ 82 | Property that returns whether or not caching type 83 | descriptions is enabled 84 | """ 85 | return self._typeDescs is not None 86 | 87 | @cacheTypeDescriptions.setter 88 | def cacheTypeDescriptions(self, value): 89 | """ 90 | Setter for caching type descriptions 91 | 92 | This property can only be set to a boolean and will initialize a dict 93 | tied to the instance if enabled. When disabled, it will clear the dict 94 | """ 95 | if value is True: 96 | if self._typeDescs is None: 97 | self._typeDescs = {} 98 | elif value is False: 99 | self._typeDescs = None 100 | else: 101 | raise TypeError( 102 | "cacheTypeDescriptions must be set to either True or False" 103 | ) 104 | 105 | def flushTypeDescriptionsCache(self): 106 | """Clears the type descriptions cache, if it's enabled""" 107 | if self.cacheTypeDescriptions: 108 | self._typeDescs = {} 109 | 110 | @property 111 | def typeDescs(self): 112 | """ 113 | Return a dict for type descriptions 114 | 115 | If caching is enabled, it will return a version tied to the instance 116 | otherwise it will return a new instance 117 | """ 118 | return self._typeDescs if self._typeDescs is not None else {} 119 | 120 | def login(self, username, passwd): 121 | res = BaseClient.login(self, username, passwd) 122 | data = dict() 123 | data['passwordExpired'] = bool_(res[_tPartnerNS.passwordExpired]) 124 | data['serverUrl'] = text_type(res[_tPartnerNS.serverUrl]) 125 | data['sessionId'] = text_type(res[_tPartnerNS.sessionId]) 126 | data['userId'] = text_type(res[_tPartnerNS.userId]) 127 | data['userInfo'] = _extractUserInfo(res[_tPartnerNS.userInfo]) 128 | return data 129 | 130 | def logout(self): 131 | res = BaseClient.logout(self) 132 | return res._name == _tPartnerNS.logoutResponse 133 | 134 | def isConnected(self): 135 | """ First pass at a method to check if we're connected or not """ 136 | return self.conn is not None 137 | 138 | def describeGlobal(self): 139 | res = BaseClient.describeGlobal(self) 140 | data = dict() 141 | data['encoding'] = text_type(res[_tPartnerNS.encoding]) 142 | data['maxBatchSize'] = int(text_type(res[_tPartnerNS.maxBatchSize])) 143 | sobjects = list() 144 | for r in res[_tPartnerNS.sobjects, ]: 145 | d = dict() 146 | d['activateable'] = bool_(r[_tPartnerNS.activateable]) 147 | d['createable'] = bool_(r[_tPartnerNS.createable]) 148 | d['custom'] = bool_(r[_tPartnerNS.custom]) 149 | try: 150 | d['customSetting'] = bool_(r[_tPartnerNS.customSetting]) 151 | except KeyError: 152 | pass 153 | d['deletable'] = bool_(r[_tPartnerNS.deletable]) 154 | d['deprecatedAndHidden'] = bool_( 155 | r[_tPartnerNS.deprecatedAndHidden] 156 | ) 157 | try: 158 | d['feedEnabled'] = bool_(r[_tPartnerNS.feedEnabled]) 159 | except KeyError: 160 | pass 161 | d['keyPrefix'] = text_type(r[_tPartnerNS.keyPrefix]) 162 | d['label'] = text_type(r[_tPartnerNS.label]) 163 | d['labelPlural'] = text_type(r[_tPartnerNS.labelPlural]) 164 | d['layoutable'] = bool_(r[_tPartnerNS.layoutable]) 165 | d['mergeable'] = bool_(r[_tPartnerNS.mergeable]) 166 | d['name'] = text_type(r[_tPartnerNS.name]) 167 | d['queryable'] = bool_(r[_tPartnerNS.queryable]) 168 | d['replicateable'] = bool_(r[_tPartnerNS.replicateable]) 169 | d['retrieveable'] = bool_(r[_tPartnerNS.retrieveable]) 170 | d['searchable'] = bool_(r[_tPartnerNS.searchable]) 171 | d['triggerable'] = bool_(r[_tPartnerNS.triggerable]) 172 | d['undeletable'] = bool_(r[_tPartnerNS.undeletable]) 173 | d['updateable'] = bool_(r[_tPartnerNS.updateable]) 174 | sobjects.append(SObject(**d)) 175 | data['sobjects'] = sobjects 176 | data['types'] = [text_type(t) for t in res[_tPartnerNS.types, ]] 177 | if not data['types']: 178 | # BBB for code written against API < 17.0 179 | data['types'] = [s.name for s in data['sobjects']] 180 | return data 181 | 182 | def describeSObjects(self, sObjectTypes): 183 | res = BaseClient.describeSObjects(self, sObjectTypes) 184 | if not isinstance(res, (tuple, list)): 185 | res = [res] 186 | data = list() 187 | for r in res: 188 | d = dict() 189 | d['activateable'] = bool_(r[_tPartnerNS.activateable]) 190 | rawreldata = r[_tPartnerNS.ChildRelationships, ] 191 | relinfo = [_extractChildRelInfo(cr) for cr in rawreldata] 192 | d['ChildRelationships'] = relinfo 193 | d['createable'] = bool_(r[_tPartnerNS.createable]) 194 | d['custom'] = bool_(r[_tPartnerNS.custom]) 195 | try: 196 | d['customSetting'] = bool_(r[_tPartnerNS.customSetting]) 197 | except KeyError: 198 | pass 199 | d['deletable'] = bool_(r[_tPartnerNS.deletable]) 200 | d['deprecatedAndHidden'] = bool_( 201 | r[_tPartnerNS.deprecatedAndHidden] 202 | ) 203 | try: 204 | d['feedEnabled'] = bool_(r[_tPartnerNS.feedEnabled]) 205 | except KeyError: 206 | pass 207 | fields = r[_tPartnerNS.fields, ] 208 | fields = [_extractFieldInfo(f) for f in fields] 209 | field_map = dict() 210 | for f in fields: 211 | field_map[f.name] = f 212 | d['fields'] = field_map 213 | d['keyPrefix'] = text_type(r[_tPartnerNS.keyPrefix]) 214 | d['label'] = text_type(r[_tPartnerNS.label]) 215 | d['labelPlural'] = text_type(r[_tPartnerNS.labelPlural]) 216 | d['layoutable'] = bool_(r[_tPartnerNS.layoutable]) 217 | d['mergeable'] = bool_(r[_tPartnerNS.mergeable]) 218 | d['name'] = text_type(r[_tPartnerNS.name]) 219 | d['queryable'] = bool_(r[_tPartnerNS.queryable]) 220 | d['recordTypeInfos'] = ([_extractRecordTypeInfo(rti) for rti in 221 | r[_tPartnerNS.recordTypeInfos, ]]) 222 | d['replicateable'] = bool_(r[_tPartnerNS.replicateable]) 223 | d['retrieveable'] = bool_(r[_tPartnerNS.retrieveable]) 224 | d['searchable'] = bool_(r[_tPartnerNS.searchable]) 225 | try: 226 | d['triggerable'] = bool_(r[_tPartnerNS.triggerable]) 227 | except KeyError: 228 | pass 229 | d['undeletable'] = bool_(r[_tPartnerNS.undeletable]) 230 | d['updateable'] = bool_(r[_tPartnerNS.updateable]) 231 | d['urlDetail'] = text_type(r[_tPartnerNS.urlDetail]) 232 | d['urlEdit'] = text_type(r[_tPartnerNS.urlEdit]) 233 | d['urlNew'] = text_type(r[_tPartnerNS.urlNew]) 234 | data.append(SObject(**d)) 235 | return data 236 | 237 | def create(self, sObjects): 238 | preparedObjects = _prepareSObjects(sObjects) 239 | res = BaseClient.create(self, preparedObjects) 240 | if not isinstance(res, (tuple, list)): 241 | res = [res] 242 | data = list() 243 | for r in res: 244 | d = dict() 245 | data.append(d) 246 | d['id'] = text_type(r[_tPartnerNS.id]) 247 | d['success'] = success = bool_(r[_tPartnerNS.success]) 248 | if not success: 249 | d['errors'] = [ 250 | _extractError(e) 251 | for e in r[_tPartnerNS.errors, ] 252 | ] 253 | else: 254 | d['errors'] = list() 255 | return data 256 | 257 | def convert_leads(self, lead_converts): 258 | preparedLeadConverts = _prepareSObjects(lead_converts) 259 | del preparedLeadConverts['fieldsToNull'] 260 | res = BaseClient.convertLeads(self, preparedLeadConverts) 261 | if not isinstance(res, (tuple, list)): 262 | res = [res] 263 | data = list() 264 | for resu in res: 265 | d = dict() 266 | data.append(d) 267 | d['success'] = success = bool_(resu[_tPartnerNS.success]) 268 | if not success: 269 | d['errors'] = [ 270 | _extractError(e) 271 | for e in resu[_tPartnerNS.errors, ] 272 | ] 273 | else: 274 | d['errors'] = list() 275 | d['account_id'] = text_type(resu[_tPartnerNS.accountId]) 276 | d['contact_id'] = text_type(resu[_tPartnerNS.contactId]) 277 | d['lead_id'] = text_type(resu[_tPartnerNS.leadId]) 278 | d['opportunity_id'] = text_type(resu[_tPartnerNS.opportunityId]) 279 | return data 280 | 281 | def sendEmail(self, emails, mass_type='SingleEmailMessage'): 282 | """ 283 | Send one or more emails from Salesforce. 284 | 285 | Parameters: 286 | emails - a dictionary or list of dictionaries, each representing 287 | a single email as described by https://www.salesforce.com 288 | /us/developer/docs/api/Content/sforce_api_calls_sendemail 289 | .htm 290 | massType - 'SingleEmailMessage' or 'MassEmailMessage'. 291 | MassEmailMessage is used for mailmerge of up to 250 292 | recepients in a single pass. 293 | 294 | Note: 295 | Newly created Salesforce Sandboxes default to System email only. 296 | In this situation, sendEmail() will fail with 297 | NO_MASS_MAIL_PERMISSION. 298 | """ 299 | preparedEmails = _prepareSObjects(emails) 300 | if isinstance(preparedEmails, dict): 301 | # If root element is a dict, then this is a single object not an 302 | # array 303 | del preparedEmails['fieldsToNull'] 304 | else: 305 | # else this is an array, and each elelment should be prepped. 306 | for listitems in preparedEmails: 307 | del listitems['fieldsToNull'] 308 | res = BaseClient.sendEmail(self, preparedEmails, mass_type) 309 | if not isinstance(res, (tuple, list)): 310 | res = [res] 311 | data = list() 312 | for resu in res: 313 | d = dict() 314 | data.append(d) 315 | d['success'] = success = bool_(resu[_tPartnerNS.success]) 316 | if not success: 317 | d['errors'] = [ 318 | _extractError(e) 319 | for e in resu[_tPartnerNS.errors, ] 320 | ] 321 | else: 322 | d['errors'] = list() 323 | return data 324 | 325 | def retrieve(self, fields, sObjectType, ids): 326 | resultSet = BaseClient.retrieve(self, fields, sObjectType, ids) 327 | type_data = self.describeSObjects(sObjectType)[0] 328 | 329 | if not isinstance(resultSet, (tuple, list)): 330 | if isnil(resultSet): 331 | resultSet = list() 332 | else: 333 | resultSet = [resultSet] 334 | fields = [f.strip() for f in fields.split(',')] 335 | data = list() 336 | for result in resultSet: 337 | d = dict() 338 | data.append(d) 339 | for fname in fields: 340 | d[fname] = type_data.marshall(fname, result) 341 | return data 342 | 343 | def update(self, sObjects): 344 | preparedObjects = _prepareSObjects(sObjects) 345 | res = BaseClient.update(self, preparedObjects) 346 | if not isinstance(res, (tuple, list)): 347 | res = [res] 348 | data = list() 349 | for r in res: 350 | d = dict() 351 | data.append(d) 352 | d['id'] = text_type(r[_tPartnerNS.id]) 353 | d['success'] = success = bool_(r[_tPartnerNS.success]) 354 | if not success: 355 | d['errors'] = [ 356 | _extractError(e) 357 | for e in r[_tPartnerNS.errors, ] 358 | ] 359 | else: 360 | d['errors'] = list() 361 | return data 362 | 363 | def queryTypesDescriptions(self, types): 364 | """ 365 | Given a list of types, construct a dictionary such that 366 | each key is a type, and each value is the corresponding sObject 367 | for that type. 368 | """ 369 | types = list(types) 370 | if types: 371 | types_descs = self.describeSObjects(types) 372 | else: 373 | types_descs = [] 374 | return dict(map(lambda t, d: (t, d), types, types_descs)) 375 | 376 | def _extractRecord(self, r, typeDescs): 377 | record = QueryRecord() 378 | if r: 379 | row_type = text_type(r[_tSObjectNS.type]) 380 | _logger.debug("row type: {0}".format(row_type)) 381 | type_data = typeDescs[row_type] 382 | _logger.debug("type data: {0}".format(type_data)) 383 | for field in r: 384 | fname = text_type(field._name[1]) 385 | if isObject(field): 386 | record[fname] = self._extractRecord( 387 | r[field._name, ][0], typeDescs 388 | ) 389 | elif isQueryResult(field): 390 | record[fname] = QueryRecordSet( 391 | records=[self._extractRecord(rec, typeDescs) for rec 392 | in field[_tPartnerNS.records, ]], 393 | done=field[_tPartnerNS.done], 394 | size=int(text_type(field[_tPartnerNS.size])) 395 | ) 396 | else: 397 | record[fname] = type_data.marshall(fname, r) 398 | return record 399 | 400 | def query(self, *args, **kw): 401 | typeDescs = self.typeDescs 402 | 403 | if len(args) == 1: # full query string 404 | queryString = args[0] 405 | elif len(args) == 2: # BBB: fields, sObjectType 406 | queryString = 'select %s from %s' % (args[0], args[1]) 407 | if 'conditionalExpression' in kw: # BBB: fields, sObjectType, 408 | # conditionExpression as kwarg 409 | queryString += ' where %s' % (kw['conditionalExpression']) 410 | elif len(args) == 3: # BBB: fields, sObjectType, conditionExpression 411 | # as positional arg 412 | whereClause = args[2] and (' where %s' % args[2]) or '' 413 | queryString = 'select %s from %s%s' % ( 414 | args[0], 415 | args[1], 416 | whereClause, 417 | ) 418 | else: 419 | raise RuntimeError("Wrong number of arguments to query method.") 420 | 421 | res = BaseClient.query(self, queryString) 422 | # calculate the union of the sets of record types from each record 423 | types = reduce( 424 | lambda a, b: a | b, 425 | [ 426 | getRecordTypes(r) 427 | for r in res[_tPartnerNS.records, ] 428 | ], 429 | set(), 430 | ) 431 | new_types = types - set(typeDescs.keys()) 432 | if new_types: 433 | typeDescs.update(self.queryTypesDescriptions(new_types)) 434 | data = QueryRecordSet( 435 | records=[ 436 | self._extractRecord(r, typeDescs) 437 | for r in res[_tPartnerNS.records, ] 438 | ], 439 | done=bool_(res[_tPartnerNS.done]), 440 | size=int(text_type(res[_tPartnerNS.size])), 441 | queryLocator=text_type(res[_tPartnerNS.queryLocator]), 442 | ) 443 | return data 444 | 445 | def queryMore(self, queryLocator): 446 | typeDescs = self.typeDescs 447 | 448 | locator = queryLocator 449 | res = BaseClient.queryMore(self, locator) 450 | # calculate the union of the sets of record types from each record 451 | types = reduce(lambda a, b: a | b, [getRecordTypes(r) for r in 452 | res[_tPartnerNS.records, ]], set()) 453 | new_types = types - set(typeDescs.keys()) 454 | if new_types: 455 | typeDescs.update(self.queryTypesDescriptions(new_types)) 456 | data = QueryRecordSet( 457 | records=[self._extractRecord(r, typeDescs) for r in 458 | res[_tPartnerNS.records, ]], 459 | done=bool_(res[_tPartnerNS.done]), 460 | size=int(text_type(res[_tPartnerNS.size])), 461 | queryLocator=text_type(res[_tPartnerNS.queryLocator]) 462 | ) 463 | return data 464 | 465 | def search(self, sosl): 466 | typeDescs = self.typeDescs 467 | res = BaseClient.search(self, sosl) 468 | 469 | # calculate the union of the sets of record types from each record 470 | if len(res): 471 | types = reduce( 472 | lambda a, b: a | b, 473 | [ 474 | getRecordTypes(r) 475 | for r in res[_tPartnerNS.searchRecords] 476 | ], 477 | set(), 478 | ) 479 | new_types = types - set(typeDescs.keys()) 480 | if new_types: 481 | typeDescs.update(self.queryTypesDescriptions(new_types)) 482 | return [ 483 | self._extractRecord(r, typeDescs) 484 | for r in res[_tPartnerNS.searchRecords] 485 | ] 486 | else: 487 | return [] 488 | 489 | def delete(self, ids): 490 | res = BaseClient.delete(self, ids) 491 | if not isinstance(res, (tuple, list)): 492 | res = [res] 493 | data = list() 494 | for r in res: 495 | d = dict() 496 | data.append(d) 497 | d['id'] = text_type(r[_tPartnerNS.id]) 498 | d['success'] = success = bool_(r[_tPartnerNS.success]) 499 | if not success: 500 | d['errors'] = [ 501 | _extractError(e) 502 | for e in r[_tPartnerNS.errors, ] 503 | ] 504 | else: 505 | d['errors'] = list() 506 | return data 507 | 508 | def upsert(self, externalIdName, sObjects): 509 | preparedObjects = _prepareSObjects(sObjects) 510 | res = BaseClient.upsert(self, externalIdName, preparedObjects) 511 | if not isinstance(res, (tuple, list)): 512 | res = [res] 513 | data = list() 514 | for r in res: 515 | d = dict() 516 | data.append(d) 517 | d['id'] = text_type(r[_tPartnerNS.id]) 518 | d['success'] = success = bool_(r[_tPartnerNS.success]) 519 | if not success: 520 | d['errors'] = [_extractError(e) 521 | for e in r[_tPartnerNS.errors, ]] 522 | else: 523 | d['errors'] = list() 524 | d['isCreated'] = d['created'] = bool_(r[_tPartnerNS.created]) 525 | return data 526 | 527 | def getDeleted(self, sObjectType, start, end): 528 | res = BaseClient.getDeleted(self, sObjectType, start, end) 529 | res = res[_tPartnerNS.deletedRecords, ] 530 | if not isinstance(res, (tuple, list)): 531 | res = [res] 532 | data = list() 533 | for r in res: 534 | d = dict( 535 | id=text_type(r[_tPartnerNS.id]), 536 | deletedDate=marshall( 537 | 'datetime', 'deletedDate', r, 538 | ns=_tPartnerNS, 539 | ) 540 | ) 541 | data.append(d) 542 | return data 543 | 544 | def getUpdated(self, sObjectType, start, end): 545 | res = BaseClient.getUpdated(self, sObjectType, start, end) 546 | res = res[_tPartnerNS.ids, ] 547 | if not isinstance(res, (tuple, list)): 548 | res = [res] 549 | return [text_type(r) for r in res] 550 | 551 | def getUserInfo(self): 552 | res = BaseClient.getUserInfo(self) 553 | data = _extractUserInfo(res) 554 | return data 555 | 556 | def describeTabs(self): 557 | res = BaseClient.describeTabs(self) 558 | data = list() 559 | for r in res: 560 | tabs = [_extractTab(t) for t in r[_tPartnerNS.tabs, ]] 561 | d = dict( 562 | label=text_type(r[_tPartnerNS.label]), 563 | logoUrl=text_type(r[_tPartnerNS.logoUrl]), 564 | selected=bool_(r[_tPartnerNS.selected]), 565 | tabs=tabs, 566 | ) 567 | data.append(d) 568 | return data 569 | 570 | def describeLayout(self, sObjectType): 571 | raise NotImplementedError 572 | 573 | 574 | class Field(object): 575 | 576 | def __init__(self, **kw): 577 | self.type = None 578 | self.name = None 579 | 580 | for key, value in kw.items(): 581 | setattr(self, key, value) 582 | 583 | def marshall(self, xml): 584 | return marshall(self.type, self.name, xml) 585 | 586 | 587 | def _doPrep(field_dict): 588 | """ 589 | _doPrep is makes changes in-place. 590 | Do some prep work converting python types into formats that 591 | Salesforce will accept. 592 | This includes converting lists of strings to "apple;orange;pear". 593 | Dicts will be converted to embedded objects 594 | None or empty list values will be Null-ed 595 | """ 596 | fieldsToNull = [] 597 | for key, value in field_dict.items(): 598 | if value is None: 599 | fieldsToNull.append(key) 600 | field_dict[key] = [] 601 | elif not isinstance(value, string_types) and hasattr(value, '__iter__'): 602 | if len(value) == 0: 603 | fieldsToNull.append(key) 604 | elif isinstance(value, dict): 605 | innerCopy = copy.deepcopy(value) 606 | _doPrep(innerCopy) 607 | field_dict[key] = innerCopy 608 | else: 609 | try: 610 | field_dict[key] = ";".join(value) 611 | except TypeError: 612 | pass 613 | if 'fieldsToNull' in field_dict: 614 | raise ValueError( 615 | "fieldsToNull should be populated by the client, not the caller." 616 | ) 617 | field_dict['fieldsToNull'] = fieldsToNull 618 | 619 | # sObjects can be 1 or a list. If values are python lists or tuples, we 620 | # convert these to strings: 621 | # ['one','two','three'] becomes 'one;two;three' 622 | 623 | 624 | def _prepareSObjects(sObjects): 625 | '''Prepare a SObject''' 626 | sObjectsCopy = copy.deepcopy(sObjects) 627 | if isinstance(sObjectsCopy, dict): 628 | # If root element is a dict, then this is a single object not an array 629 | _doPrep(sObjectsCopy) 630 | else: 631 | # else this is an array, and each elelment should be prepped. 632 | for listitems in sObjectsCopy: 633 | _doPrep(listitems) 634 | return sObjectsCopy 635 | 636 | 637 | def _extractFieldInfo(fdata): 638 | data = dict() 639 | data['autoNumber'] = bool_(fdata[_tPartnerNS.autoNumber]) 640 | data['byteLength'] = int(text_type(fdata[_tPartnerNS.byteLength])) 641 | data['calculated'] = bool_(fdata[_tPartnerNS.calculated]) 642 | data['createable'] = bool_(fdata[_tPartnerNS.createable]) 643 | data['nillable'] = bool_(fdata[_tPartnerNS.nillable]) 644 | data['custom'] = bool_(fdata[_tPartnerNS.custom]) 645 | data['defaultedOnCreate'] = bool_(fdata[_tPartnerNS.defaultedOnCreate]) 646 | data['digits'] = int(text_type(fdata[_tPartnerNS.digits])) 647 | data['filterable'] = bool_(fdata[_tPartnerNS.filterable]) 648 | try: 649 | data['htmlFormatted'] = bool_(fdata[_tPartnerNS.htmlFormatted]) 650 | except KeyError: 651 | data['htmlFormatted'] = False 652 | data['label'] = text_type(fdata[_tPartnerNS.label]) 653 | data['length'] = int(text_type(fdata[_tPartnerNS.length])) 654 | data['name'] = text_type(fdata[_tPartnerNS.name]) 655 | data['nameField'] = bool_(fdata[_tPartnerNS.nameField]) 656 | plValues = fdata[_tPartnerNS.picklistValues, ] 657 | data['picklistValues'] = [_extractPicklistEntry(p) for p in plValues] 658 | data['precision'] = int(text_type(fdata[_tPartnerNS.precision])) 659 | data['referenceTo'] = [text_type(r) for r in fdata[_tPartnerNS.referenceTo, ]] 660 | data['restrictedPicklist'] = bool_(fdata[_tPartnerNS.restrictedPicklist]) 661 | data['scale'] = int(text_type(fdata[_tPartnerNS.scale])) 662 | data['soapType'] = text_type(fdata[_tPartnerNS.soapType]) 663 | data['type'] = text_type(fdata[_tPartnerNS.type]) 664 | data['updateable'] = bool_(fdata[_tPartnerNS.updateable]) 665 | try: 666 | data['dependentPicklist'] = bool_(fdata[_tPartnerNS.dependentPicklist]) 667 | data['controllerName'] = text_type(fdata[_tPartnerNS.controllerName]) 668 | except KeyError: 669 | data['dependentPicklist'] = False 670 | data['controllerName'] = '' 671 | return Field(**data) 672 | 673 | 674 | def _extractPicklistEntry(pldata): 675 | data = dict() 676 | data['active'] = bool_(pldata[_tPartnerNS.active]) 677 | data['validFor'] = [text_type(v) for v in pldata[_tPartnerNS.validFor, ]] 678 | data['defaultValue'] = bool_(pldata[_tPartnerNS.defaultValue]) 679 | data['label'] = text_type(pldata[_tPartnerNS.label]) 680 | data['value'] = text_type(pldata[_tPartnerNS.value]) 681 | return data 682 | 683 | 684 | def _extractChildRelInfo(crdata): 685 | data = dict() 686 | data['cascadeDelete'] = bool_(crdata[_tPartnerNS.cascadeDelete]) 687 | data['childSObject'] = text_type(crdata[_tPartnerNS.childSObject]) 688 | data['field'] = text_type(crdata[_tPartnerNS.field]) 689 | return data 690 | 691 | 692 | def _extractRecordTypeInfo(rtidata): 693 | data = dict() 694 | data['available'] = bool_(rtidata[_tPartnerNS.available]) 695 | data['defaultRecordTypeMapping'] = bool_( 696 | rtidata[_tPartnerNS.defaultRecordTypeMapping] 697 | ) 698 | data['name'] = text_type(rtidata[_tPartnerNS.name]) 699 | data['recordTypeId'] = text_type(rtidata[_tPartnerNS.recordTypeId]) 700 | return data 701 | 702 | 703 | def _extractError(edata): 704 | data = dict() 705 | data['statusCode'] = text_type(edata[_tPartnerNS.statusCode]) 706 | data['message'] = text_type(edata[_tPartnerNS.message]) 707 | data['fields'] = [text_type(f) for f in edata[_tPartnerNS.fields, ]] 708 | return data 709 | 710 | 711 | def _extractTab(tdata): 712 | data = dict( 713 | custom=bool_(tdata[_tPartnerNS.custom]), 714 | label=text_type(tdata[_tPartnerNS.label]), 715 | sObjectName=text_type(tdata[_tPartnerNS.sobjectName]), 716 | url=text_type(tdata[_tPartnerNS.url])) 717 | return data 718 | 719 | 720 | def _extractUserInfo(res): 721 | data = dict( 722 | accessibilityMode=bool_(res[_tPartnerNS.accessibilityMode]), 723 | currencySymbol=text_type(res[_tPartnerNS.currencySymbol]), 724 | organizationId=text_type(res[_tPartnerNS.organizationId]), 725 | organizationMultiCurrency=bool_( 726 | res[_tPartnerNS.organizationMultiCurrency] 727 | ), 728 | organizationName=text_type(res[_tPartnerNS.organizationName]), 729 | userDefaultCurrencyIsoCode=text_type( 730 | res[_tPartnerNS.userDefaultCurrencyIsoCode] 731 | ), 732 | userEmail=text_type(res[_tPartnerNS.userEmail]), 733 | userFullName=text_type(res[_tPartnerNS.userFullName]), 734 | userId=text_type(res[_tPartnerNS.userId]), 735 | userLanguage=text_type(res[_tPartnerNS.userLanguage]), 736 | userLocale=text_type(res[_tPartnerNS.userLocale]), 737 | userTimeZone=text_type(res[_tPartnerNS.userTimeZone]), 738 | userUiSkin=text_type(res[_tPartnerNS.userUiSkin])) 739 | return data 740 | 741 | 742 | def isObject(xml): 743 | try: 744 | return xml(_tSchemaInstanceNS.type) == 'sf:sObject' 745 | except KeyError: 746 | return False 747 | 748 | 749 | def isQueryResult(xml): 750 | try: 751 | return xml(_tSchemaInstanceNS.type) == 'QueryResult' 752 | except KeyError: 753 | return False 754 | 755 | 756 | def isnil(xml): 757 | try: 758 | return xml(_tSchemaInstanceNS.nil) == 'true' 759 | except KeyError: 760 | return False 761 | 762 | 763 | def getRecordTypes(xml): 764 | record_types = set() 765 | if xml: 766 | record_types.add(text_type(xml[_tSObjectNS.type])) 767 | for field in xml: 768 | if isObject(field): 769 | record_types.update(getRecordTypes(field)) 770 | elif isQueryResult(field): 771 | record_types.update( 772 | reduce( 773 | lambda x, y: x | y, 774 | [ 775 | getRecordTypes(r) for r in 776 | field[_tPartnerNS.records, ] 777 | ], 778 | set(), 779 | ) 780 | ) 781 | return record_types 782 | -------------------------------------------------------------------------------- /pyforce/xmlclient.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from __future__ import unicode_literals 3 | 4 | import datetime 5 | import gzip 6 | import logging 7 | from numbers import Real 8 | from xml.sax.saxutils import quoteattr 9 | from xml.sax.saxutils import XMLGenerator 10 | from xml.sax.xmlreader import AttributesNSImpl 11 | 12 | import requests 13 | from six import binary_type 14 | from six import BytesIO 15 | from six import text_type 16 | from six.moves.urllib.parse import urlparse 17 | 18 | from pyforce import xmltramp 19 | 20 | __version__ = '1.4' 21 | __author__ = "Simon Fell et al. reluctantly forked by idbentley" 22 | __copyright__ = "GNU GPL 2." 23 | 24 | # global constants for namespace strings, used during serialization 25 | _partnerNs = "urn:partner.soap.sforce.com" 26 | _sobjectNs = "urn:sobject.partner.soap.sforce.com" 27 | _envNs = "http://schemas.xmlsoap.org/soap/envelope/" 28 | _schemaInstanceNs = "http://www.w3.org/2001/XMLSchema-instance" 29 | _noAttrs = AttributesNSImpl({}, {}) 30 | 31 | DEFAULT_SERVER_URL = 'https://login.salesforce.com/services/Soap/u/20.0' 32 | 33 | # global constants for xmltramp namespaces, used to access response data 34 | _tPartnerNS = xmltramp.Namespace(_partnerNs) 35 | _tSObjectNS = xmltramp.Namespace(_sobjectNs) 36 | _tSoapNS = xmltramp.Namespace(_envNs) 37 | _tSchemaInstanceNS = xmltramp.Namespace(_schemaInstanceNs) 38 | 39 | # global config 40 | # TODO: Re-enable this before shipping 41 | gzipRequest = True # are we going to gzip the request ? 42 | gzipResponse = True # are we going to tell teh server to gzip the response ? 43 | 44 | _logger = logging.getLogger(__name__) 45 | 46 | 47 | # the main sforce client proxy class 48 | class Client(object): 49 | def __init__(self, serverUrl=None): 50 | self.batchSize = 500 51 | self.serverUrl = serverUrl or DEFAULT_SERVER_URL 52 | self.__conn = None 53 | 54 | def __del__(self): 55 | if callable(getattr(self.__conn, 'close', None)): 56 | self.__conn.close() 57 | 58 | @property 59 | def conn(self): 60 | return self.__conn 61 | 62 | # login, the serverUrl and sessionId are automatically handled, returns the 63 | # loginResult structure 64 | def login(self, username, password): 65 | lr = LoginRequest(self.serverUrl, username, password).post() 66 | self.useSession(str(lr[_tPartnerNS.sessionId]), str( 67 | lr[_tPartnerNS.serverUrl]) 68 | ) 69 | return lr 70 | 71 | # initialize from an existing sessionId & serverUrl, useful if we're being 72 | # launched via a custom link 73 | def useSession(self, sessionId, serverUrl): 74 | self.sessionId = sessionId 75 | self.__serverUrl = serverUrl 76 | (scheme, host, path, params, query, frag) = urlparse(self.__serverUrl) 77 | self.__conn = requests.Session() 78 | 79 | def logout(self): 80 | return LogoutRequest( 81 | self.__serverUrl, 82 | self.sessionId 83 | ).post(self.__conn) 84 | 85 | # set the batchSize property on the Client instance to change the batchsize 86 | # for query/queryMore 87 | def query(self, soql): 88 | return QueryRequest( 89 | self.__serverUrl, 90 | self.sessionId, 91 | self.batchSize, 92 | soql 93 | ).post(self.__conn) 94 | 95 | def queryMore(self, queryLocator): 96 | return QueryMoreRequest( 97 | self.__serverUrl, 98 | self.sessionId, 99 | self.batchSize, 100 | queryLocator 101 | ).post(self.__conn) 102 | 103 | def search(self, sosl): 104 | return SearchRequest( 105 | self.__serverUrl, 106 | self.sessionId, 107 | self.batchSize, 108 | sosl 109 | ).post(self.__conn) 110 | 111 | def getUpdated(self, sObjectType, start, end): 112 | return GetUpdatedRequest( 113 | self.__serverUrl, 114 | self.sessionId, 115 | sObjectType, 116 | start, 117 | end 118 | ).post(self.__conn) 119 | 120 | def getDeleted(self, sObjectType, start, end): 121 | return GetDeletedRequest( 122 | self.__serverUrl, 123 | self.sessionId, 124 | sObjectType, 125 | start, 126 | end 127 | ).post(self.__conn) 128 | 129 | def retrieve(self, fields, sObjectType, ids): 130 | return RetrieveRequest( 131 | self.__serverUrl, 132 | self.sessionId, 133 | fields, 134 | sObjectType, 135 | ids 136 | ).post(self.__conn) 137 | 138 | # sObjects can be 1 or a list, returns a single save result or a list 139 | def create(self, sObjects): 140 | return CreateRequest( 141 | self.__serverUrl, 142 | self.sessionId, 143 | sObjects 144 | ).post(self.__conn) 145 | 146 | # sObjects can be 1 or a list, returns a single save result or a list 147 | def update(self, sObjects): 148 | return UpdateRequest( 149 | self.__serverUrl, 150 | self.sessionId, 151 | sObjects 152 | ).post(self.__conn) 153 | 154 | # sObjects can be 1 or a list, returns a single upsert result or a list 155 | def upsert(self, externalIdName, sObjects): 156 | return UpsertRequest( 157 | self.__serverUrl, 158 | self.sessionId, 159 | externalIdName, 160 | sObjects 161 | ).post(self.__conn) 162 | 163 | # ids can be 1 or a list, returns a single delete result or a list 164 | def delete(self, ids): 165 | return DeleteRequest( 166 | self.__serverUrl, 167 | self.sessionId, 168 | ids 169 | ).post(self.__conn) 170 | 171 | # sObjectTypes can be 1 or a list, returns a single describe result or a 172 | # list of them 173 | def describeSObjects(self, sObjectTypes): 174 | return DescribeSObjectsRequest( 175 | self.__serverUrl, 176 | self.sessionId, 177 | sObjectTypes 178 | ).post(self.__conn) 179 | 180 | def describeGlobal(self): 181 | return AuthenticatedRequest( 182 | self.__serverUrl, 183 | self.sessionId, 184 | "describeGlobal" 185 | ).post(self.__conn) 186 | 187 | def describeLayout(self, sObjectType): 188 | return DescribeLayoutRequest( 189 | self.__serverUrl, 190 | self.sessionId, 191 | sObjectType 192 | ).post(self.__conn) 193 | 194 | def describeTabs(self): 195 | return AuthenticatedRequest( 196 | self.__serverUrl, 197 | self.sessionId, 198 | "describeTabs" 199 | ).post(self.__conn, True) 200 | 201 | def getServerTimestamp(self): 202 | return str(AuthenticatedRequest( 203 | self.__serverUrl, 204 | self.sessionId, 205 | "getServerTimestamp" 206 | ).post(self.__conn)[_tPartnerNS.timestamp]) 207 | 208 | def resetPassword(self, userId): 209 | return ResetPasswordRequest( 210 | self.__serverUrl, 211 | self.sessionId, 212 | userId 213 | ).post(self.__conn) 214 | 215 | def setPassword(self, userId, password): 216 | SetPasswordRequest( 217 | self.__serverUrl, 218 | self.sessionId, 219 | userId, 220 | password 221 | ).post(self.__conn) 222 | 223 | def getUserInfo(self): 224 | return AuthenticatedRequest( 225 | self.__serverUrl, 226 | self.sessionId, 227 | "getUserInfo" 228 | ).post(self.__conn) 229 | 230 | def convertLeads(self, convertLeads): 231 | return ConvertLeadsRequest( 232 | self.__serverUrl, 233 | self.sessionId, 234 | convertLeads 235 | ).post(self.__conn) 236 | 237 | def sendEmail(self, emails, massType='SingleEmailMessage'): 238 | """ 239 | Send one or more emails from Salesforce. 240 | 241 | Parameters: 242 | emails - a dictionary or list of dictionaries, each representing a 243 | single email as described by https://www.salesforce.com/us 244 | /developer/docs/api/Content/sforce_api_calls_sendemail.htm 245 | massType - 'SingleEmailMessage' or 'MassEmailMessage'. 246 | MassEmailMessage is used for mailmerge of up to 250 247 | recepients in a single pass. 248 | 249 | Note: 250 | Newly created Salesforce Sandboxes default to System email only. In 251 | this situation, sendEmail() will fail with NO_MASS_MAIL_PERMISSION. 252 | """ 253 | return SendEmailRequest( 254 | self.__serverUrl, 255 | self.sessionId, 256 | emails, 257 | massType 258 | ).post(self.__conn) 259 | 260 | 261 | # fixed version of XmlGenerator, handles unqualified attributes correctly 262 | class BeatBoxXmlGenerator(XMLGenerator): 263 | def __init__(self, destination, encoding): 264 | XMLGenerator.__init__(self, destination, encoding) 265 | 266 | if hasattr(self, '_out') and self._out: 267 | self._write = self._out.write 268 | self._flush = self._out.flush 269 | 270 | def makeName(self, name): 271 | if name[0] is None: 272 | # if the name was not namespace-scoped, use the qualified part 273 | return name[1] 274 | # else try to restore the original prefix from the namespace 275 | return self._current_context[name[0]] + ":" + name[1] 276 | 277 | def startElementNS(self, name, qname, attrs): 278 | self._write('<' + self.makeName(name)) 279 | 280 | for pair in self._undeclared_ns_maps: 281 | self._write(' xmlns:%s="%s"' % pair) 282 | self._undeclared_ns_maps = [] 283 | 284 | for (name, value) in attrs.items(): 285 | self._write(' %s=%s' % ( 286 | self.makeName(name), 287 | quoteattr(value))) 288 | self._write('>') 289 | 290 | 291 | # General purpose xml writer. 292 | # Does a bunch of useful stuff above & beyond XmlGenerator 293 | # TODO: What does it do, beyond XMLGenerator? 294 | class XmlWriter(object): 295 | def __init__(self, doGzip): 296 | self.__buf = BytesIO(binary_type(b'')) 297 | if doGzip: 298 | self.__gzip = gzip.GzipFile(mode='wb', fileobj=self.__buf) 299 | stm = self.__gzip 300 | else: 301 | stm = self.__buf 302 | self.__gzip = None 303 | self.xg = BeatBoxXmlGenerator(stm, "utf-8") 304 | self.xg.startDocument() 305 | self.__elems = [] 306 | 307 | def startPrefixMapping(self, prefix, namespace): 308 | self.xg.startPrefixMapping(prefix, namespace) 309 | 310 | def endPrefixMapping(self, prefix): 311 | self.xg.endPrefixMapping(prefix) 312 | 313 | def startElement(self, namespace, name, attrs=_noAttrs): 314 | self.xg.startElementNS((namespace, name), name, attrs) 315 | self.__elems.append((namespace, name)) 316 | 317 | # General Function for writing an XML Element. 318 | # Detects the type of the element, and handles each type appropriately. 319 | # i.e. If a list, then it encodes each element, if a dict, it writes an 320 | # embedded element. 321 | def writeElement(self, namespace, name, value, attrs=_noAttrs): 322 | if xmltramp.islst(value): 323 | for v in value: 324 | self.writeElement(namespace, name, v, attrs) 325 | elif isinstance(value, dict): 326 | self.startElement(namespace, name, attrs) 327 | if 'type' in value: 328 | # Type must always come first, even in embedded objects. 329 | type_entry = value['type'] 330 | self.writeElement(namespace, 'type', type_entry, attrs) 331 | del value['type'] 332 | for k, v in value.items(): 333 | self.writeElement(namespace, k, v, attrs) 334 | self.endElement() 335 | else: 336 | self.startElement(namespace, name, attrs) 337 | self.characters(value) 338 | self.endElement() 339 | 340 | def endElement(self): 341 | e = self.__elems[-1] 342 | self.xg.endElementNS(e, e[1]) 343 | del self.__elems[-1] 344 | 345 | def characters(self, s): 346 | # todo base64 ? 347 | if isinstance(s, datetime.datetime) or isinstance(s, datetime.date): 348 | s = s.isoformat() 349 | elif isinstance(s, Real): 350 | s = str(s) 351 | self.xg.characters(s) 352 | 353 | def endDocument(self): 354 | # from ipdb import set_trace; set_trace() 355 | self.xg.endDocument() 356 | if self.__gzip is not None: 357 | self.__gzip.close() 358 | return self.__buf.getvalue() 359 | 360 | 361 | # exception class for soap faults 362 | class SoapFaultError(Exception): 363 | def __init__(self, faultCode, faultString): 364 | self.faultCode = faultCode 365 | self.faultString = faultString 366 | 367 | def __str__(self): 368 | return repr(self.faultCode) + " " + repr(self.faultString) 369 | 370 | 371 | class SessionTimeoutError(Exception): 372 | """ 373 | SessionTimeouts are recoverable errors, merely needing the creation 374 | of a new connection, we create a new exception type, so these can 375 | be identified and handled seperately from SoapFaultErrors 376 | """ 377 | 378 | def __init__(self, faultCode, faultString): 379 | self.faultCode = faultCode 380 | self.faultString = faultString 381 | 382 | def __str__(self): 383 | return repr(self.faultCode) + " " + repr(self.faultString) 384 | 385 | 386 | # soap specific stuff ontop of XmlWriter 387 | class SoapWriter(XmlWriter): 388 | def __init__(self): 389 | XmlWriter.__init__(self, gzipRequest) 390 | self.startPrefixMapping("s", _envNs) 391 | self.startPrefixMapping("p", _partnerNs) 392 | self.startPrefixMapping("o", _sobjectNs) 393 | self.startPrefixMapping("x", _schemaInstanceNs) 394 | self.startElement(_envNs, "Envelope") 395 | 396 | def endDocument(self): 397 | self.endElement() # envelope 398 | self.endPrefixMapping("x") 399 | self.endPrefixMapping("o") 400 | self.endPrefixMapping("p") 401 | self.endPrefixMapping("s") 402 | return XmlWriter.endDocument(self) 403 | 404 | 405 | # processing for a single soap request / response 406 | class SoapEnvelope(object): 407 | def __init__(self, serverUrl, operationName, 408 | clientId="Pyforce/" + __version__): 409 | self.serverUrl = serverUrl 410 | self.operationName = operationName 411 | self.clientId = clientId 412 | 413 | def writeHeaders(self, writer): 414 | pass 415 | 416 | def writeBody(self, writer): 417 | pass 418 | 419 | def makeEnvelope(self): 420 | s = SoapWriter() 421 | s.startElement(_envNs, "Header") 422 | s.characters("\n") 423 | s.startElement(_partnerNs, "CallOptions") 424 | s.writeElement(_partnerNs, "client", self.clientId) 425 | s.endElement() 426 | s.characters("\n") 427 | self.writeHeaders(s) 428 | s.endElement() # Header 429 | s.startElement(_envNs, "Body") 430 | s.characters("\n") 431 | s.startElement(_partnerNs, self.operationName) 432 | self.writeBody(s) 433 | s.endElement() # operation 434 | s.endElement() # body 435 | return s.endDocument() 436 | 437 | # does all the grunt work: 438 | # * serializes the request 439 | # * makes a http request 440 | # * passes the response to tramp 441 | # * checks for soap fault 442 | # returns the relevant result from the body child 443 | # TODO: check for mU='1' headers 444 | def post(self, conn=None, alwaysReturnList=False): 445 | headers = { 446 | "User-Agent": "Pyforce/{0}".format(__version__), 447 | "SOAPAction": '""', 448 | "Content-Type": "text/xml; charset=utf-8" 449 | } 450 | if gzipResponse: 451 | headers['accept-encoding'] = 'gzip' 452 | if gzipRequest: 453 | headers['content-encoding'] = 'gzip' 454 | max_attempts = 3 455 | response = None 456 | attempt = 1 457 | if conn is None: 458 | # Use a stateless connection 459 | conn = requests 460 | conn_error = None 461 | while response is None and attempt <= max_attempts: 462 | try: 463 | envelope = self.makeEnvelope() 464 | # TODO: Can we just use the URL here? 465 | # response = conn.post(self.serverUrl, data=binary_type(envelope), headers=headers) 466 | response = conn.post( 467 | self.serverUrl, 468 | data=envelope, 469 | headers=headers, 470 | ) 471 | except requests.exceptions.ConnectionError as ex: 472 | attempt += 1 473 | conn_error = ex 474 | if response is None: 475 | if conn_error: 476 | raise conn_error 477 | raise RuntimeError('No response from Salesforce') 478 | tramp = xmltramp.parse(response.text) 479 | try: 480 | faultString = text_type( 481 | tramp[_tSoapNS.Body][_tSoapNS.Fault].faultstring) 482 | faultCode = text_type( 483 | tramp[_tSoapNS.Body][_tSoapNS.Fault].faultcode).split(':')[-1] 484 | if faultCode == 'INVALID_SESSION_ID': 485 | raise SessionTimeoutError(faultCode, faultString) 486 | else: 487 | raise SoapFaultError(faultCode, faultString) 488 | except KeyError: 489 | pass 490 | # first child of body is XXXXResponse 491 | result = tramp[_tSoapNS.Body][0] 492 | # it contains either a single child, or for a batch call multiple 493 | # children 494 | if alwaysReturnList or len(result) > 1: 495 | return result[:] 496 | elif len(result) == 1: 497 | return result[0] 498 | else: 499 | return result 500 | 501 | 502 | class LoginRequest(SoapEnvelope): 503 | def __init__(self, serverUrl, username, password): 504 | SoapEnvelope.__init__(self, serverUrl, "login") 505 | self.__username = username 506 | self.__password = password 507 | 508 | def writeBody(self, s): 509 | s.writeElement(_partnerNs, "username", self.__username) 510 | s.writeElement(_partnerNs, "password", self.__password) 511 | 512 | 513 | # base class for all methods that require a sessionId 514 | class AuthenticatedRequest(SoapEnvelope): 515 | def __init__(self, serverUrl, sessionId, operationName): 516 | SoapEnvelope.__init__(self, serverUrl, operationName) 517 | self.sessionId = sessionId 518 | 519 | def writeHeaders(self, s): 520 | s.startElement(_partnerNs, "SessionHeader") 521 | s.writeElement(_partnerNs, "sessionId", self.sessionId) 522 | s.endElement() 523 | 524 | def writeSObjects(self, s, sObjects, elemName="sObjects"): 525 | if xmltramp.islst(sObjects): 526 | for o in sObjects: 527 | self.writeSObjects(s, o, elemName) 528 | else: 529 | s.startElement(_partnerNs, elemName) 530 | # type has to go first 531 | s.writeElement(_sobjectNs, "type", sObjects['type']) 532 | for fn in sObjects.keys(): 533 | if (fn != 'type'): 534 | s.writeElement(_sobjectNs, fn, sObjects[fn]) 535 | s.endElement() 536 | 537 | 538 | class LogoutRequest(AuthenticatedRequest): 539 | def __init__(self, serverUrl, sessionId, operationName='logout'): 540 | AuthenticatedRequest.__init__(self, serverUrl, sessionId, 'logout') 541 | 542 | 543 | class QueryOptionsRequest(AuthenticatedRequest): 544 | def __init__(self, serverUrl, sessionId, batchSize, operationName): 545 | AuthenticatedRequest.__init__(self, serverUrl, sessionId, 546 | operationName) 547 | self.batchSize = batchSize 548 | 549 | def writeHeaders(self, s): 550 | AuthenticatedRequest.writeHeaders(self, s) 551 | s.startElement(_partnerNs, "QueryOptions") 552 | s.writeElement(_partnerNs, "batchSize", self.batchSize) 553 | s.endElement() 554 | 555 | 556 | class QueryRequest(QueryOptionsRequest): 557 | def __init__(self, serverUrl, sessionId, batchSize, soql): 558 | QueryOptionsRequest.__init__(self, serverUrl, sessionId, batchSize, 559 | "query") 560 | self.__query = soql 561 | 562 | def writeBody(self, s): 563 | s.writeElement(_partnerNs, "queryString", self.__query) 564 | 565 | 566 | class QueryMoreRequest(QueryOptionsRequest): 567 | def __init__(self, serverUrl, sessionId, batchSize, queryLocator): 568 | QueryOptionsRequest.__init__(self, serverUrl, sessionId, batchSize, 569 | "queryMore") 570 | self.__queryLocator = queryLocator 571 | 572 | def writeBody(self, s): 573 | s.writeElement(_partnerNs, "queryLocator", self.__queryLocator) 574 | 575 | 576 | class SearchRequest(QueryOptionsRequest): 577 | def __init__(self, serverUrl, sessionId, batchSize, sosl): 578 | QueryOptionsRequest.__init__(self, serverUrl, sessionId, batchSize, 579 | "search") 580 | self.__search = sosl 581 | 582 | def writeBody(self, s): 583 | s.writeElement(_partnerNs, "searchString", self.__search) 584 | 585 | 586 | class GetUpdatedRequest(AuthenticatedRequest): 587 | def __init__(self, serverUrl, sessionId, sObjectType, start, end, 588 | operationName="getUpdated"): 589 | AuthenticatedRequest.__init__(self, serverUrl, sessionId, 590 | operationName) 591 | self.__sObjectType = sObjectType 592 | self.__start = start 593 | self.__end = end 594 | 595 | def writeBody(self, s): 596 | s.writeElement(_partnerNs, "sObjectType", self.__sObjectType) 597 | s.writeElement(_partnerNs, "startDate", self.__start) 598 | s.writeElement(_partnerNs, "endDate", self.__end) 599 | 600 | 601 | class GetDeletedRequest(GetUpdatedRequest): 602 | def __init__(self, serverUrl, sessionId, sObjectType, start, end): 603 | GetUpdatedRequest.__init__(self, serverUrl, sessionId, sObjectType, 604 | start, end, "getDeleted") 605 | 606 | 607 | class UpsertRequest(AuthenticatedRequest): 608 | def __init__(self, serverUrl, sessionId, externalIdName, sObjects): 609 | AuthenticatedRequest.__init__(self, serverUrl, sessionId, "upsert") 610 | self.__externalIdName = externalIdName 611 | self.__sObjects = sObjects 612 | 613 | def writeBody(self, s): 614 | s.writeElement(_partnerNs, "externalIDFieldName", 615 | self.__externalIdName) 616 | self.writeSObjects(s, self.__sObjects) 617 | 618 | 619 | class UpdateRequest(AuthenticatedRequest): 620 | def __init__(self, serverUrl, sessionId, sObjects, operationName="update"): 621 | AuthenticatedRequest.__init__(self, serverUrl, sessionId, 622 | operationName) 623 | self.__sObjects = sObjects 624 | 625 | def writeBody(self, s): 626 | self.writeSObjects(s, self.__sObjects) 627 | 628 | 629 | class CreateRequest(UpdateRequest): 630 | def __init__(self, serverUrl, sessionId, sObjects): 631 | UpdateRequest.__init__(self, serverUrl, sessionId, sObjects, "create") 632 | 633 | 634 | class DeleteRequest(AuthenticatedRequest): 635 | def __init__(self, serverUrl, sessionId, ids): 636 | AuthenticatedRequest.__init__(self, serverUrl, sessionId, "delete") 637 | self.__ids = ids 638 | 639 | def writeBody(self, s): 640 | s.writeElement(_partnerNs, "id", self.__ids) 641 | 642 | 643 | class RetrieveRequest(AuthenticatedRequest): 644 | def __init__(self, serverUrl, sessionId, fields, sObjectType, ids): 645 | AuthenticatedRequest.__init__(self, serverUrl, sessionId, "retrieve") 646 | self.__fields = fields 647 | self.__sObjectType = sObjectType 648 | self.__ids = ids 649 | 650 | def writeBody(self, s): 651 | s.writeElement(_partnerNs, "fieldList", self.__fields) 652 | s.writeElement(_partnerNs, "sObjectType", self.__sObjectType) 653 | s.writeElement(_partnerNs, "ids", self.__ids) 654 | 655 | 656 | class ConvertLeadsRequest(AuthenticatedRequest): 657 | def __init__(self, serverUrl, sessionId, sLeads): 658 | AuthenticatedRequest.__init__(self, serverUrl, sessionId, 659 | "convertLead") 660 | self.__sLeads = sLeads 661 | 662 | def writeBody(self, s): 663 | s.writeElement(_partnerNs, "leadConverts", self.__sLeads) 664 | 665 | 666 | class SendEmailRequest(AuthenticatedRequest): 667 | def __init__(self, serverUrl, sessionId, emails, 668 | massType="SingleEmailMessage"): 669 | super(SendEmailRequest, self).__init__( 670 | serverUrl, sessionId, "sendEmail") 671 | self.__emails = emails 672 | self.__massType = massType 673 | 674 | def writeBody(self, s): 675 | s.writeElement( 676 | _partnerNs, 677 | "messages", 678 | self.__emails, 679 | attrs={(_schemaInstanceNs, 'type'): 'p:' + self.__massType} 680 | ) 681 | 682 | 683 | class ResetPasswordRequest(AuthenticatedRequest): 684 | def __init__(self, serverUrl, sessionId, userId): 685 | super(ResetPasswordRequest, self).__init__( 686 | serverUrl, sessionId, "resetPassword") 687 | self.__userId = userId 688 | 689 | def writeBody(self, s): 690 | s.writeElement(_partnerNs, "userId", self.__userId) 691 | 692 | 693 | class SetPasswordRequest(AuthenticatedRequest): 694 | def __init__(self, serverUrl, sessionId, userId, password): 695 | super(SetPasswordRequest, self).__init__( 696 | serverUrl, sessionId, "setPassword") 697 | self.__userId = userId 698 | self.__password = password 699 | 700 | def writeBody(self, s): 701 | s.writeElement(_partnerNs, "userId", self.__userId) 702 | s.writeElement(_partnerNs, "password", self.__password) 703 | 704 | 705 | class DescribeSObjectsRequest(AuthenticatedRequest): 706 | def __init__(self, serverUrl, sessionId, sObjectTypes): 707 | super(DescribeSObjectsRequest, self).__init__( 708 | serverUrl, sessionId, "describeSObjects") 709 | self.__sObjectTypes = sObjectTypes 710 | 711 | def writeBody(self, s): 712 | s.writeElement(_partnerNs, "sObjectType", self.__sObjectTypes) 713 | 714 | 715 | class DescribeLayoutRequest(AuthenticatedRequest): 716 | def __init__(self, serverUrl, sessionId, sObjectType): 717 | super(DescribeLayoutRequest, self).__init__( 718 | serverUrl, sessionId, "describeLayout") 719 | self.__sObjectType = sObjectType 720 | 721 | def writeBody(self, s): 722 | s.writeElement(_partnerNs, "sObjectType", self.__sObjectType) 723 | -------------------------------------------------------------------------------- /pyforce/xmltramp.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from __future__ import unicode_literals 3 | 4 | from xml.sax.handler import ContentHandler 5 | from xml.sax.handler import DTDHandler 6 | from xml.sax.handler import EntityResolver 7 | from xml.sax.handler import ErrorHandler 8 | from xml.sax.handler import feature_namespaces 9 | 10 | import defusedxml 11 | from defusedxml.sax import make_parser 12 | from six import ensure_str 13 | from six import string_types 14 | from six import StringIO 15 | from six import text_type 16 | 17 | __version__ = "2.18-pyforce" 18 | __author__ = "Aaron Swartz" 19 | __credits__ = "Many thanks to pjz, bitsko, and DanC. And many changes since pyforce covered in git" 20 | __copyright__ = "(C) 2003-2006 Aaron Swartz. GNU GPL 2." 21 | 22 | 23 | def isstr(mystring): 24 | '''Check if string is a string or unicode''' 25 | return isinstance(mystring, string_types) 26 | 27 | 28 | def islst(myitem): 29 | '''Check if item is a tuple or list''' 30 | return isinstance(myitem, tuple) or isinstance(myitem, list) 31 | 32 | 33 | EMPTY = { 34 | 'http://www.w3.org/1999/xhtml': [ 35 | 'img', 'br', 'hr', 'meta', 'link', 'base', 'param', 'input', 'col', 36 | 'area' 37 | ] 38 | } 39 | 40 | 41 | def quote(myitem, elt=True): 42 | '''URL encode string''' 43 | if elt and '<' in myitem and len(myitem) > 24 and myitem.find(']]>') == -1: 44 | return '' % (myitem) 45 | else: 46 | myitem = myitem.replace('&', '&').\ 47 | replace('<', '<').replace(']]>', ']]>') 48 | if not elt: 49 | myitem = myitem.replace('"', '"') 50 | return myitem 51 | 52 | 53 | class Element(object): 54 | def __init__(self, name, attrs=None, children=None, prefixes=None): 55 | if islst(name) and name[0] is None: 56 | name = name[1] 57 | if attrs: 58 | na = {} 59 | for k in attrs.keys(): 60 | if islst(k) and k[0] is None: 61 | na[k[1]] = attrs[k] 62 | else: 63 | na[k] = attrs[k] 64 | attrs = na 65 | 66 | self._name = name 67 | self._attrs = attrs or {} 68 | self._dir = children or [] 69 | 70 | prefixes = prefixes or {} 71 | self._prefixes = dict(zip(prefixes.values(), prefixes.keys())) 72 | 73 | if prefixes: 74 | self._dNS = prefixes.get(None, None) 75 | else: 76 | self._dNS = None 77 | 78 | def __repr__(self, recursive=0, multiline=0, inprefixes=None): 79 | def qname(name, inprefixes): 80 | if islst(name): 81 | if inprefixes[name[0]] is not None: 82 | return inprefixes[name[0]] + ':' + name[1] 83 | else: 84 | return name[1] 85 | else: 86 | return name 87 | 88 | def arep(a, inprefixes, addns=1): 89 | out = '' 90 | 91 | for p in sorted(self._prefixes.keys()): 92 | if not p in inprefixes.keys(): 93 | if addns: 94 | out += ' xmlns' 95 | if addns and self._prefixes[p]: 96 | out += ':' + self._prefixes[p] 97 | if addns: 98 | out += '="' + quote(p, False) + '"' 99 | inprefixes[p] = self._prefixes[p] 100 | 101 | for k in sorted(a.keys()): 102 | out += ' %s="%s"' % (qname(k, inprefixes), quote(a[k], False)) 103 | return out 104 | 105 | inprefixes = inprefixes or { 106 | u'http://www.w3.org/XML/1998/namespace': 'xml' 107 | } 108 | 109 | # need to call first to set inprefixes: 110 | attributes = arep(self._attrs, inprefixes, recursive) 111 | out = '<' + qname(self._name, inprefixes) + attributes 112 | 113 | if not self._dir and (self._name[0] in EMPTY.keys() 114 | and self._name[1] in EMPTY[self._name[0]]): 115 | out += ' />' 116 | return out 117 | 118 | out += '>' 119 | 120 | if recursive: 121 | content = 0 122 | for x in self._dir: 123 | if isinstance(x, Element): 124 | content = 1 125 | 126 | pad = '\n' + ('\t' * recursive) 127 | for x in self._dir: 128 | if multiline and content: 129 | out += pad 130 | if isstr(x): 131 | out += quote(x) 132 | elif isinstance(x, Element): 133 | out += x.__repr__( 134 | recursive + 1, 135 | multiline, 136 | inprefixes.copy() 137 | ) 138 | else: 139 | raise TypeError("I wasn't expecting " + repr(x) + ".") 140 | if multiline and content: 141 | out += '\n' + ('\t' * (recursive - 1)) 142 | else: 143 | if self._dir: 144 | out += '...' 145 | 146 | out += '' 147 | 148 | return out 149 | 150 | def __unicode__(self): 151 | text = '' 152 | for x in self._dir: 153 | text += text_type(x) 154 | return text 155 | 156 | def __str__(self): 157 | return self.__unicode__() 158 | 159 | def __bytes__(self): 160 | return self.__unicode__().encode('utf-8') 161 | 162 | def __getattr__(self, n): 163 | if n[0] == '_': 164 | raise AttributeError( 165 | "Use foo['" + n + "'] to access the child element." 166 | ) 167 | if self._dNS: 168 | n = (self._dNS, n) 169 | for x in self._dir: 170 | if isinstance(x, Element) and x._name == n: 171 | return x 172 | raise AttributeError('No child element named %s' % repr(n)) 173 | 174 | def __hasattr__(self, n): 175 | for x in self._dir: 176 | if isinstance(x, Element) and x._name == n: 177 | return True 178 | return False 179 | 180 | def __setattr__(self, n, v): 181 | if n[0] == '_': 182 | self.__dict__[n] = v 183 | else: 184 | self[n] = v 185 | 186 | def __getitem__(self, n): 187 | if isinstance(n, int): # d[1] == d._dir[1] 188 | return self._dir[n] 189 | elif isinstance(n, slice) and ( 190 | isinstance(n.start, int) or n.start is None 191 | ): 192 | return self._dir[n] 193 | elif isinstance(n, tuple) and len(n) == 1: # d['foo',] == all s 194 | n = n[0] 195 | if self._dNS and not islst(n): 196 | n = (self._dNS, n) 197 | out = [] 198 | for x in self._dir: 199 | if isinstance(x, Element) and x._name == n: 200 | out.append(x) 201 | return out 202 | elif n is None: 203 | return self._dir 204 | else: # d['foo'] == first 205 | if self._dNS and not islst(n): 206 | n = (self._dNS, n) 207 | for x in self._dir: 208 | if isinstance(x, Element) and x._name == n: 209 | return x 210 | raise KeyError(n) 211 | 212 | def __setitem__(self, n, v): 213 | if isinstance(n, type(0)): # d[1] 214 | self._dir[n] = v 215 | elif isinstance(n, tuple) and len(n) == 1: 216 | # d['foo',] adds a new foo 217 | n = n[0] 218 | if self._dNS and not islst(n): 219 | n = (self._dNS, n) 220 | 221 | nv = Element(n) 222 | self._dir.append(nv) 223 | 224 | else: # d["foo"] replaces first and dels rest 225 | if self._dNS and not islst(n): 226 | n = (self._dNS, n) 227 | 228 | nv = Element(n) 229 | nv._dir.append(v) 230 | replaced = False 231 | 232 | todel = [] 233 | for i in range(len(self)): 234 | if self[i]._name == n: 235 | if replaced: 236 | todel.append(i) 237 | else: 238 | self[i] = nv 239 | replaced = True 240 | if not replaced: 241 | self._dir.append(nv) 242 | for i in todel: 243 | del self[i] 244 | 245 | def __delitem__(self, n): 246 | if isinstance(n, type(0)): 247 | del self._dir[n] 248 | elif isinstance(n, slice(0).__class__): 249 | # delete all s 250 | n = n.start 251 | if self._dNS and not islst(n): 252 | n = (self._dNS, n) 253 | 254 | for i in range(len(self)): 255 | if self[i]._name == n: 256 | del self[i] 257 | else: 258 | # delete first foo 259 | for i in range(len(self)): 260 | if self[i]._name == n: 261 | del self[i] 262 | break 263 | 264 | def __call__(self, *_pos, **_set): 265 | if _set: 266 | for k in _set.keys(): 267 | self._attrs[k] = _set[k] 268 | if len(_pos) > 1: 269 | for i in range(0, len(_pos), 2): 270 | self._attrs[_pos[i]] = _pos[i + 1] 271 | if len(_pos) == 1: 272 | return self._attrs[_pos[0]] 273 | if len(_pos) == 0: 274 | return self._attrs 275 | 276 | def __len__(self): 277 | return len(self._dir) 278 | 279 | 280 | class Namespace(object): 281 | def __init__(self, uri): 282 | self.__uri = uri 283 | 284 | def __getattr__(self, n): 285 | return (self.__uri, n) 286 | 287 | def __getitem__(self, n): 288 | return (self.__uri, n) 289 | 290 | 291 | class Seeder(EntityResolver, DTDHandler, ContentHandler, ErrorHandler): 292 | def __init__(self): 293 | self.stack = [] 294 | self.ch = '' 295 | self.prefixes = {} 296 | ContentHandler.__init__(self) 297 | 298 | def startPrefixMapping(self, prefix, uri): 299 | if not prefix in self.prefixes.keys(): 300 | self.prefixes[prefix] = [] 301 | self.prefixes[prefix].append(uri) 302 | 303 | def endPrefixMapping(self, prefix): 304 | self.prefixes[prefix].pop() 305 | # szf: 5/15/5 306 | if len(self.prefixes[prefix]) == 0: 307 | del self.prefixes[prefix] 308 | 309 | def startElementNS(self, name, qname, attrs): 310 | ch = self.ch 311 | self.ch = '' 312 | if ch and not ch.isspace(): 313 | self.stack[-1]._dir.append(ch) 314 | 315 | attrs = dict(attrs) 316 | newprefixes = {} 317 | for k in self.prefixes.keys(): 318 | newprefixes[k] = self.prefixes[k][-1] 319 | 320 | self.stack.append(Element(name, attrs, prefixes=newprefixes.copy())) 321 | 322 | def characters(self, ch): 323 | self.ch += ch 324 | 325 | def endElementNS(self, name, qname): 326 | ch = self.ch 327 | self.ch = '' 328 | if ch and not ch.isspace(): 329 | self.stack[-1]._dir.append(ch) 330 | 331 | element = self.stack.pop() 332 | if self.stack: 333 | self.stack[-1]._dir.append(element) 334 | else: 335 | self.result = element 336 | 337 | 338 | def seed(fileobj): 339 | seeder = Seeder() 340 | parser = make_parser() 341 | parser.setFeature(feature_namespaces, 1) 342 | parser.setContentHandler(seeder) 343 | parser.parse(fileobj) 344 | return seeder.result 345 | 346 | 347 | def parse(text): 348 | return seed(StringIO(ensure_str(text))) 349 | 350 | 351 | def load(url): 352 | import urllib 353 | return seed(urllib.urlopen(url)) 354 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r ./requirements.txt 2 | coverage 3 | flake8 4 | mock 5 | pre-commit>=1.7.0 6 | pytest 7 | tox 8 | # debugging 9 | ipdb 10 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -e . 2 | -------------------------------------------------------------------------------- /run_tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | PYTHON=python 3 | export PYTHONPATH=.:./src 4 | 5 | $PYTHON pyforce/tests/test_xmlclient.py 6 | $PYTHON pyforce/tests/test_python_client.py 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from __future__ import unicode_literals 3 | 4 | from setuptools import setup 5 | 6 | setup( 7 | name='pyforce', 8 | version='1.9.1', 9 | install_requires=['defusedxml>=0.5.0', 'requests>=2.0.0', 'six>=1.10.0', ], 10 | packages=['pyforce'], 11 | author="Simon Fell et al. reluctantly Forked by idbentley", 12 | author_email='ian.bentley@gmail.com, alanjcastonguay@gmail.com', 13 | description="A Python client wrapping the Salesforce.com SOAP API", 14 | long_description=open('README.md').read() + "\n" + open('CHANGES.txt').read(), 15 | long_description_content_type="text/markdown", 16 | license="GNU GENERAL PUBLIC LICENSE Version 2", 17 | keywords="python salesforce salesforce.com", 18 | url="https://github.com/alanjcastonguay/pyforce", 19 | classifiers=[ 20 | 'Development Status :: 5 - Production/Stable', 21 | 'Intended Audience :: Developers', 22 | 'Programming Language :: Python :: 2', 23 | 'Programming Language :: Python :: 2.7', 24 | 'Programming Language :: Python :: 3', 25 | 'Programming Language :: Python :: 3.5', 26 | 'Programming Language :: Python :: 3.6', 27 | 'Programming Language :: Python :: 3.7', 28 | ], 29 | ) 30 | -------------------------------------------------------------------------------- /sfconfig.py.in: -------------------------------------------------------------------------------- 1 | USERNAME = '' 2 | PASSWORD = '' 3 | -------------------------------------------------------------------------------- /tests/benchmark_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from __future__ import unicode_literals 3 | 4 | import datetime 5 | import gc 6 | import os 7 | import unittest 8 | from time import time 9 | 10 | import six 11 | 12 | import pyforce 13 | 14 | BENCHMARK_REPS = 1 15 | 16 | SERVER_URL = "https://test.salesforce.com/services/Soap/u/20.0" 17 | 18 | 19 | def benchmark(func): 20 | def benchmarked_func(self): 21 | # temporarily disable garbage collection 22 | gc.disable() 23 | t0 = time() 24 | for i in six.moves.xrange(0, BENCHMARK_REPS): 25 | func(self) 26 | t1 = time() 27 | gc.enable() 28 | elapsed = t1 - t0 29 | print("\n%s: %s\n" % (func.__name__, elapsed)) 30 | return benchmarked_func 31 | 32 | 33 | class TestUtils(unittest.TestCase): 34 | 35 | def setUp(self): 36 | self.svc = svc = pyforce.PythonClient(serverUrl=SERVER_URL) 37 | svc.login(os.getenv('SF_USERNAME'), os.getenv('SF_PASSWORD')) 38 | self._todelete = list() 39 | 40 | def tearDown(self): 41 | svc = self.svc 42 | ids = self._todelete 43 | if ids: 44 | while len(ids) > 200: 45 | svc.delete(ids[:200]) 46 | ids = ids[200:] 47 | if ids: 48 | svc.delete(ids) 49 | self._todelete = list() 50 | 51 | @benchmark 52 | def testDescribeSObjects(self): 53 | svc = self.svc 54 | globalres = svc.describeGlobal() 55 | types = globalres['types'] 56 | res = svc.describeSObjects(types[0]) 57 | self.assertEqual(type(res), list) 58 | self.assertEqual(len(res), 1) 59 | res = svc.describeSObjects(types[:100]) 60 | self.assertEqual(len(types[:100]), len(res)) 61 | 62 | @benchmark 63 | def testCreate(self): 64 | svc = self.svc 65 | data = dict(type='Contact', 66 | LastName='Doe', 67 | FirstName='John', 68 | Phone='123-456-7890', 69 | Email='john@doe.com', 70 | Birthdate=datetime.date(1970, 1, 4) 71 | ) 72 | res = svc.create([data]) 73 | self.assertTrue(type(res) in (list, tuple)) 74 | self.assertTrue(len(res) == 1) 75 | self.assertTrue(res[0]['success']) 76 | id = res[0]['id'] 77 | self._todelete.append(id) 78 | contacts = svc.retrieve('LastName, FirstName, Phone, Email, Birthdate', 79 | 'Contact', [id]) 80 | self.assertEqual(len(contacts), 1) 81 | contact = contacts[0] 82 | for k in ['LastName', 'FirstName', 'Phone', 'Email', 'Birthdate']: 83 | self.assertEqual( 84 | data[k], contact[k]) 85 | 86 | @benchmark 87 | def testQuery(self): 88 | svc = self.svc 89 | data = dict(type='Contact', 90 | LastName='Doe', 91 | FirstName='John', 92 | Phone='123-456-7890', 93 | Email='john@doe.com', 94 | Birthdate=datetime.date(1970, 1, 4) 95 | ) 96 | res = svc.create([data]) 97 | self._todelete.append(res[0]['id']) 98 | data2 = dict(type='Contact', 99 | LastName='Doe', 100 | FirstName='Jane', 101 | Phone='123-456-7890', 102 | Email='jane@doe.com', 103 | Birthdate=datetime.date(1972, 10, 15) 104 | ) 105 | res = svc.create([data2]) 106 | janeid = res[0]['id'] 107 | self._todelete.append(janeid) 108 | res = svc.query('LastName, FirstName, Phone, Email, Birthdate', 109 | 'Contact', "LastName = 'Doe'") 110 | self.assertEqual(len(res), 2) 111 | self.assertEqual(res['size'], 2) 112 | self.assertEqual(res.size, 2) 113 | res = svc.query('Id, LastName, FirstName, Phone, Email, Birthdate', 114 | 'Contact', "LastName = 'Doe' and FirstName = 'Jane'") 115 | self.assertEqual(len(res), 1) 116 | self.assertEqual(res[0]['Id'], janeid) 117 | self.tearDown() 118 | 119 | 120 | def test_suite(): 121 | return unittest.TestSuite(( 122 | unittest.makeSuite(TestUtils), 123 | )) 124 | 125 | 126 | if __name__ == '__main__': 127 | unittest.main(defaultTest='test_suite') 128 | -------------------------------------------------------------------------------- /tests/python_client_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from __future__ import unicode_literals 3 | 4 | import datetime 5 | import os 6 | import unittest 7 | from time import sleep 8 | 9 | from six import string_types 10 | 11 | import pyforce 12 | from pyforce import SoapFaultError 13 | from pyforce.pyclient import _prepareSObjects 14 | 15 | 16 | SERVER_URL = "https://test.salesforce.com/services/Soap/u/20.0" 17 | DELAY_RETRY = 10 18 | DELAY_SEC = 2 19 | 20 | 21 | class TestUtils(unittest.TestCase): 22 | 23 | def setUp(self): 24 | self.svc = svc = pyforce.PythonClient(serverUrl=SERVER_URL) 25 | svc.login(os.getenv('SF_USERNAME'), os.getenv('SF_PASSWORD')) 26 | self._todelete = list() 27 | 28 | def tearDown(self): 29 | svc = self.svc 30 | ids = self._todelete 31 | if ids: 32 | while len(ids) > 200: 33 | svc.delete(ids[:200]) 34 | ids = ids[200:] 35 | if ids: 36 | svc.delete(ids) 37 | 38 | def testDescribeGlobal(self): 39 | svc = self.svc 40 | res = svc.describeGlobal() 41 | self.assertEqual(type(res), dict) 42 | self.assertTrue(isinstance(res['encoding'], string_types)) 43 | self.assertTrue(isinstance(res['maxBatchSize'], int)) 44 | self.assertTrue(isinstance(res['types'], list)) 45 | self.assertTrue(len(res['sobjects']) > 0) 46 | # BBB for API < 17.0 47 | self.assertTrue(len(res['types']) > 0) 48 | 49 | def testDescribeSObjects(self): 50 | svc = self.svc 51 | globalres = svc.describeGlobal() 52 | types = globalres['types'][:100] 53 | res = svc.describeSObjects(types[0]) 54 | self.assertEqual(type(res), list) 55 | self.assertEqual(len(res), 1) 56 | res = svc.describeSObjects(types) 57 | self.assertEqual(len(types), len(res)) 58 | 59 | def testCreate(self): 60 | svc = self.svc 61 | data = dict(type='Contact', 62 | LastName='Doe', 63 | FirstName='John', 64 | Phone='123-456-7890', 65 | Email='john@doe.com', 66 | Birthdate=datetime.date(1970, 1, 4) 67 | ) 68 | res = svc.create([data]) 69 | self.assertTrue(type(res) in (list, tuple)) 70 | self.assertTrue(len(res) == 1) 71 | self.assertTrue(res[0]['success']) 72 | id = res[0]['id'] 73 | self._todelete.append(id) 74 | contacts = svc.retrieve('LastName, FirstName, Phone, Email, Birthdate', 75 | 'Contact', [id]) 76 | self.assertEqual(len(contacts), 1) 77 | contact = contacts[0] 78 | for k in ['LastName', 'FirstName', 'Phone', 'Email', 'Birthdate']: 79 | self.assertEqual( 80 | data[k], contact[k]) 81 | 82 | def testSetIntegerField(self): 83 | # Passes when you feed it floats, even if salesforce field is defined 84 | # for 0 decimal places. Lack of data validation in SF? 85 | svc = self.svc 86 | testField = 'Favorite_Integer__c' 87 | data = dict( 88 | type='Contact', 89 | LastName='Doe', 90 | FirstName='John', 91 | Favorite_Integer__c=-25, 92 | ) 93 | res = svc.create([data]) 94 | self.assertTrue(type(res) in (list, tuple)) 95 | self.assertTrue(len(res) == 1) 96 | self.assertTrue(res[0]['success']) 97 | id = res[0]['id'] 98 | self._todelete.append(id) 99 | contacts = svc.retrieve( 100 | 'LastName, FirstName, Favorite_Integer__c', 101 | 'Contact', 102 | [id], 103 | ) 104 | self.assertEqual(len(contacts), 1) 105 | contact = contacts[0] 106 | self.assertEqual(data[testField], contact[testField]) 107 | 108 | def testSetFloatField(self): 109 | # this fails when you have a large amount (I didn't test the #) of decimal places. 110 | svc = self.svc 111 | testField = 'Favorite_Float__c' 112 | data = dict(type='Contact', 113 | LastName='Doe', 114 | FirstName='John', 115 | Favorite_Float__c=-1.999888777 116 | ) 117 | res = svc.create([data]) 118 | self.assertTrue(type(res) in (list, tuple)) 119 | self.assertTrue(len(res) == 1) 120 | self.assertTrue(res[0]['success']) 121 | id = res[0]['id'] 122 | self._todelete.append(id) 123 | contacts = svc.retrieve('LastName, FirstName, Favorite_Float__c', 'Contact', [id]) 124 | self.assertEqual(len(contacts), 1) 125 | contact = contacts[0] 126 | self.assertEqual(data[testField], contact[testField]) 127 | 128 | def testCreatePickListMultiple(self): 129 | svc = self.svc 130 | 131 | data = dict( 132 | type='Contact', 133 | LastName='Doe', 134 | FirstName='John', 135 | Phone='123-456-7890', 136 | Email='john@doe.com', 137 | Birthdate=datetime.date(1970, 1, 4), 138 | Favorite_Fruit__c=["Apple", "Orange", "Pear"], 139 | ) 140 | res = svc.create([data]) 141 | self.assertTrue(type(res) in (list, tuple)) 142 | self.assertTrue(len(res) == 1) 143 | self.assertTrue(res[0]['success']) 144 | id = res[0]['id'] 145 | self._todelete.append(id) 146 | contacts = svc.retrieve( 147 | 'LastName, FirstName, Phone, Email, Birthdate, Favorite_Fruit__c', 148 | 'Contact', 149 | [id], 150 | ) 151 | self.assertEqual(len(contacts), 1) 152 | contact = contacts[0] 153 | for k in [ 154 | 'LastName', 155 | 'FirstName', 156 | 'Phone', 157 | 'Email', 158 | 'Birthdate', 159 | 'Favorite_Fruit__c', 160 | ]: 161 | self.assertEqual(data[k], contact[k]) 162 | 163 | #def testCreatePickListMultipleWithInvalid(self): 164 | #""" This fails, and I guess it should(?) 165 | # SF doesn't enforce vocabularies, appearently """ 166 | #svc = self.svc 167 | 168 | #data = dict(type='Contact', 169 | #LastName='Doe', 170 | #FirstName='John', 171 | #Phone='123-456-7890', 172 | #Email='john@doe.com', 173 | #Birthdate = datetime.date(1970, 1, 4), 174 | #Favorite_Fruit__c = ["Apple","Orange","Pear","RottenFruit"] 175 | #) 176 | #res = svc.create([data]) 177 | #self.assertTrue(type(res) in (list, tuple)) 178 | #self.assertTrue(len(res) == 1) 179 | #self.assertTrue(res[0]['success']) 180 | #id = res[0]['id'] 181 | #self._todelete.append(id) 182 | #contacts = svc.retrieve('LastName, FirstName, Phone, Email, Birthdate, \ 183 | #Favorite_Fruit__c', 'Contact', [id]) 184 | #self.assertEqual(len(contacts), 1) 185 | #contact = contacts[0] 186 | #self.assertNotEqual(data['Favorite_Fruit__c'], contact['Favorite_Fruit__c']) 187 | #self.assertEqual(len(contact['Favorite_Fruit__c']),3) 188 | #for k in ['LastName', 'FirstName', 'Phone', 'Email', 'Birthdate', 'Favorite_Fruit__c']: 189 | #self.assertEqual( 190 | #data[k], contact[k]) 191 | 192 | def testFailedCreate(self): 193 | svc = self.svc 194 | data = dict( 195 | type='Contact', 196 | LastName='Doe', 197 | FirstName='John', 198 | Phone='123-456-7890', 199 | Email='john@doe.com', 200 | Birthdate='foo', 201 | ) 202 | self.assertRaises(SoapFaultError, svc.create, data) 203 | 204 | def testRetrieve(self): 205 | svc = self.svc 206 | data = dict( 207 | type='Contact', 208 | LastName='Doe', 209 | FirstName='John', 210 | Phone='123-456-7890', 211 | Email='john@doe.com', 212 | Birthdate=datetime.date(1970, 1, 4), 213 | ) 214 | res = svc.create([data]) 215 | id = res[0]['id'] 216 | self._todelete.append(id) 217 | typedesc = svc.describeSObjects('Contact')[0] 218 | fieldnames = list() 219 | fields = typedesc.fields.values() 220 | fieldnames = [f.name for f in fields] 221 | fieldnames = ', '.join(fieldnames) 222 | contacts = svc.retrieve(fieldnames, 'Contact', [id]) 223 | self.assertEqual(len(contacts), 1) 224 | 225 | def testRetrieveDeleted(self): 226 | svc = self.svc 227 | data = dict(type='Contact', 228 | LastName='Doe', 229 | FirstName='John', 230 | Phone='123-456-7890', 231 | Email='john@doe.com', 232 | Birthdate=datetime.date(1970, 1, 4) 233 | ) 234 | res = svc.create(data) 235 | id = res[0]['id'] 236 | svc.delete(id) 237 | typedesc = svc.describeSObjects('Contact')[0] 238 | fieldnames = list() 239 | fields = typedesc.fields.values() 240 | fieldnames = [f.name for f in fields] 241 | fieldnames = ', '.join(fieldnames) 242 | contacts = svc.retrieve(fieldnames, 'Contact', [id]) 243 | self.assertEqual(len(contacts), 0) 244 | 245 | def testDelete(self): 246 | svc = self.svc 247 | data = dict(type='Contact', 248 | LastName='Doe', 249 | FirstName='John', 250 | Phone='123-456-7890', 251 | Email='john@doe.com', 252 | Birthdate=datetime.date(1970, 1, 4) 253 | ) 254 | res = svc.create([data]) 255 | id = res[0]['id'] 256 | res = svc.delete([id]) 257 | self.assertTrue(res[0]['success']) 258 | contacts = svc.retrieve('LastName', 'Contact', [id]) 259 | self.assertEqual(len(contacts), 0) 260 | 261 | def testUpdate(self): 262 | svc = self.svc 263 | originaldate = datetime.date(1970, 1, 4) 264 | newdate = datetime.date(1970, 1, 5) 265 | lastname = 'Doe' 266 | data = dict(type='Contact', 267 | LastName=lastname, 268 | FirstName='John', 269 | Phone='123-456-7890', 270 | Email='john@doe.com', 271 | Birthdate=originaldate 272 | ) 273 | res = svc.create([data]) 274 | id = res[0]['id'] 275 | self._todelete.append(id) 276 | contacts = svc.retrieve('LastName, Birthdate', 'Contact', [id]) 277 | self.assertEqual(contacts[0]['Birthdate'], originaldate) 278 | self.assertEqual(contacts[0]['LastName'], lastname) 279 | data = dict(type='Contact', 280 | Id=id, 281 | Birthdate=newdate) 282 | svc.update(data) 283 | contacts = svc.retrieve('LastName, Birthdate', 'Contact', [id]) 284 | self.assertEqual(contacts[0]['Birthdate'], newdate) 285 | self.assertEqual(contacts[0]['LastName'], lastname) 286 | 287 | def testShrinkMultiPicklist(self): 288 | svc = self.svc 289 | originalList = ["Pear", "Apple"] 290 | newList = ["Pear", ] 291 | lastname = 'Doe' 292 | data = dict(type='Contact', 293 | LastName=lastname, 294 | FirstName='John', 295 | Phone='123-456-7890', 296 | Email='john@doe.com', 297 | Favorite_Fruit__c=originalList 298 | ) 299 | res = svc.create([data]) 300 | id = res[0]['id'] 301 | self._todelete.append(id) 302 | contacts = svc.retrieve('LastName, Favorite_Fruit__c', 'Contact', [id]) 303 | self.assertEqual(len(contacts[0]['Favorite_Fruit__c']), 2) 304 | data = dict(type='Contact', 305 | Id=id, 306 | Favorite_Fruit__c=newList) 307 | svc.update(data) 308 | contacts = svc.retrieve('LastName, Favorite_Fruit__c', 'Contact', [id]) 309 | self.assertEqual(len(contacts[0]['Favorite_Fruit__c']), 1) 310 | 311 | def testGrowMultiPicklist(self): 312 | svc = self.svc 313 | originalList = ["Pear", "Apple"] 314 | newList = ["Pear", "Apple", "Orange"] 315 | lastname = 'Doe' 316 | data = dict(type='Contact', 317 | LastName=lastname, 318 | FirstName='John', 319 | Phone='123-456-7890', 320 | Email='john@doe.com', 321 | Favorite_Fruit__c=originalList 322 | ) 323 | res = svc.create([data]) 324 | id = res[0]['id'] 325 | self._todelete.append(id) 326 | contacts = svc.retrieve('LastName, Favorite_Fruit__c', 'Contact', [id]) 327 | self.assertEqual(len(contacts[0]['Favorite_Fruit__c']), 2) 328 | data = dict(type='Contact', 329 | Id=id, 330 | Favorite_Fruit__c=newList) 331 | svc.update(data) 332 | contacts = svc.retrieve('LastName, Favorite_Fruit__c', 'Contact', [id]) 333 | self.assertEqual(len(contacts[0]['Favorite_Fruit__c']), 3) 334 | 335 | def testUpdateDeleted(self): 336 | svc = self.svc 337 | originaldate = datetime.date(1970, 1, 4) 338 | newdate = datetime.date(1970, 1, 5) 339 | lastname = 'Doe' 340 | data = dict(type='Contact', 341 | LastName=lastname, 342 | FirstName='John', 343 | Phone='123-456-7890', 344 | Email='john@doe.com', 345 | Birthdate=originaldate 346 | ) 347 | res = svc.create(data) 348 | id = res[0]['id'] 349 | svc.delete(id) 350 | contacts = svc.retrieve('LastName, Birthdate', 'Contact', [id]) 351 | self.assertEqual(len(contacts), 0) 352 | data = dict(type='Contact', 353 | Id=id, 354 | Birthdate=newdate) 355 | res = svc.update(data) 356 | self.assertTrue(not res[0]['success']) 357 | self.assertTrue(len(res[0]['errors']) > 0) 358 | 359 | def testQuery(self): 360 | svc = self.svc 361 | data = dict(type='Contact', 362 | LastName='Doe', 363 | FirstName='John', 364 | Phone='123-456-7890', 365 | Email='john@doe.com', 366 | Birthdate=datetime.date(1970, 1, 4) 367 | ) 368 | res = svc.create([data]) 369 | self._todelete.append(res[0]['id']) 370 | data2 = dict(type='Contact', 371 | LastName='Doe', 372 | FirstName='Jane', 373 | Phone='123-456-7890', 374 | Email='jane@doe.com', 375 | Birthdate=datetime.date(1972, 10, 15) 376 | ) 377 | res = svc.create([data2]) 378 | janeid = res[0]['id'] 379 | self._todelete.append(janeid) 380 | res = svc.query("SELECT LastName, FirstName, Phone, Email, Birthdate FROM Contact WHERE LastName = 'Doe'") 381 | self.assertEqual(len(res), 2) 382 | res = svc.query("SELECT Id, LastName, FirstName, Phone, Email, Birthdate FROM Contact WHERE LastName = 'Doe' and FirstName = 'Jane'") 383 | self.assertEqual(len(res), 1) 384 | self.assertEqual(res[0]['Id'], janeid) 385 | 386 | def testBackwardsCompatibleQuery(self): 387 | svc = self.svc 388 | data = dict(type='Contact', 389 | LastName='Doe', 390 | FirstName='John', 391 | Phone='123-456-7890', 392 | Email='john@doe.com', 393 | Birthdate=datetime.date(1970, 1, 4) 394 | ) 395 | res = svc.create([data]) 396 | self._todelete.append(res[0]['id']) 397 | data2 = dict(type='Contact', 398 | LastName='Doe', 399 | FirstName='Jane', 400 | Phone='123-456-7890', 401 | Email='jane@doe.com', 402 | Birthdate=datetime.date(1972, 10, 15) 403 | ) 404 | res = svc.create([data2]) 405 | janeid = res[0]['id'] 406 | self._todelete.append(janeid) 407 | # conditional expression as positional arg 408 | res = svc.query('LastName, FirstName, Phone, Email, Birthdate', 409 | 'Contact', "LastName = 'Doe'") 410 | self.assertEqual(len(res), 2) 411 | # conditional expression as *empty* positional arg 412 | res = svc.query('LastName', 'Contact', '') 413 | self.assertTrue(len(res) > 0) 414 | # conditional expression as kwarg 415 | res = svc.query('Id, LastName, FirstName, Phone, Email, Birthdate', 416 | 'Contact', conditionalExpression="LastName = 'Doe' and FirstName = 'Jane'") 417 | self.assertEqual(len(res), 1) 418 | self.assertEqual(res[0]['Id'], janeid) 419 | 420 | def testTypeDescriptionsCache(self): 421 | # patch describeSObjects to make a record when it is called 422 | calls = [] 423 | standard_describeSObjects = pyforce.PythonClient.describeSObjects 424 | 425 | def patched_describeSObjects(self, sObjectTypes): 426 | calls.append(sObjectTypes) 427 | return standard_describeSObjects(self, sObjectTypes) 428 | pyforce.PythonClient.describeSObjects = patched_describeSObjects 429 | 430 | self.svc.cacheTypeDescriptions = True 431 | 432 | # should get called the first time 433 | self.svc.query('SELECT Id FROM Contact') 434 | self.assertEqual(calls, [['Contact']]) 435 | # but not the second time 436 | self.svc.query('SELECT Id FROM Contact') 437 | self.assertEqual(calls, [['Contact']]) 438 | 439 | # if we flush the cache, it should get called again 440 | self.svc.flushTypeDescriptionsCache() 441 | self.svc.query('SELECT Id FROM Contact') 442 | self.assertEqual(calls, [['Contact'], ['Contact']]) 443 | 444 | # clean up 445 | self.svc.cacheTypeDescriptions = False 446 | 447 | def testChildToParentMultiQuery(self): 448 | svc = self.svc 449 | account_data = dict(type='Account', 450 | Name='ChildTestAccount', 451 | AccountNumber='987654321', 452 | Site='www.testsite.com', 453 | ) 454 | account = svc.create([account_data]) 455 | self._todelete.append(account[0]['id']) 456 | 457 | contact_data = dict(type='Contact', 458 | LastName='TestLastName', 459 | FirstName='TestFirstName', 460 | Phone='123-456-7890', 461 | AccountID=account[0]['id'], 462 | Email='testfirstname@testlastname.com', 463 | Birthdate=datetime.date(1965, 1, 5) 464 | ) 465 | contact = svc.create([contact_data]) 466 | self._todelete.append(contact[0]['id']) 467 | 468 | query_res = svc.query("Id, LastName, FirstName, Account.Site, Account.AccountNumber", 469 | "Contact", 470 | "Phone='123-456-7890'" 471 | ) 472 | 473 | self.assertEqual(query_res.size, 1) 474 | rr = query_res.records[0] 475 | self.assertEqual(rr.type, 'Contact') 476 | map(self.assertEqual, 477 | [rr.Id, rr.LastName, rr.FirstName, rr.Account.Site, rr.Account.AccountNumber], 478 | [contact[0]['id'], contact_data['LastName'], contact_data['FirstName'], account_data['Site'], account_data['AccountNumber']]) 479 | 480 | def testChildToParentMultiQuery2(self): 481 | svc = self.svc 482 | paccount_data = dict(type='Account', 483 | Name='ParentTestAccount', 484 | AccountNumber='123456789', 485 | Site='www.testsite.com', 486 | ) 487 | paccount = svc.create([paccount_data]) 488 | self._todelete.append(paccount[0]['id']) 489 | 490 | caccount_data = dict(type='Account', 491 | Name='ChildTestAccount', 492 | AccountNumber='987654321', 493 | Site='www.testsite.com', 494 | ParentID=paccount[0]['id'] 495 | ) 496 | caccount = svc.create([caccount_data]) 497 | self._todelete.append(caccount[0]['id']) 498 | 499 | contact_data = dict(type='Contact', 500 | LastName='TestLastName', 501 | FirstName='TestFirstName', 502 | Phone='123-456-7890', 503 | AccountID=caccount[0]['id'], 504 | Email='testfirstname@testlastname.com', 505 | Birthdate=datetime.date(1965, 1, 5) 506 | ) 507 | contact = svc.create([contact_data]) 508 | self._todelete.append(contact[0]['id']) 509 | 510 | query_res = svc.query("Id, LastName, FirstName, Account.Site, Account.Parent.AccountNumber", 511 | "Contact", 512 | "Account.AccountNumber='987654321'" 513 | ) 514 | 515 | rr = query_res.records[0] 516 | self.assertEqual(query_res.size, 1) 517 | self.assertEqual(rr.type, 'Contact') 518 | map(self.assertEqual, 519 | [rr.Id, rr.LastName, rr.FirstName, rr.Account.Site, rr.Account.Parent.AccountNumber], 520 | [contact[0]['id'], contact_data['LastName'], contact_data['FirstName'], caccount_data['Site'], paccount_data['AccountNumber']]) 521 | 522 | def testParentToChildMultiQuery(self): 523 | svc = self.svc 524 | caccount_data = dict(type='Account', 525 | Name='ChildTestAccount', 526 | AccountNumber='987654321', 527 | Site='www.testsite.com', 528 | ) 529 | caccount = svc.create([caccount_data]) 530 | self._todelete.append(caccount[0]['id']) 531 | 532 | contact_data = dict(type='Contact', 533 | LastName='TestLastName', 534 | FirstName='TestFirstName', 535 | Phone='123-456-7890', 536 | AccountID=caccount[0]['id'], 537 | Email='testfirstname@testlastname.com', 538 | Birthdate=datetime.date(1965, 1, 5) 539 | ) 540 | contact = svc.create([contact_data]) 541 | self._todelete.append(contact[0]['id']) 542 | 543 | contact_data2 = dict(type='Contact', 544 | LastName='TestLastName2', 545 | FirstName='TestFirstName2', 546 | Phone='123-456-7890', 547 | AccountID=caccount[0]['id'], 548 | Email='testfirstname2@testlastname2.com', 549 | Birthdate=datetime.date(1965, 1, 5) 550 | ) 551 | contact2 = svc.create([contact_data2]) 552 | self._todelete.append(contact2[0]['id']) 553 | 554 | query_res = svc.query("Id, Name, (select FirstName from Contacts)", 555 | "Account", 556 | "AccountNumber='987654321'" 557 | ) 558 | 559 | rr = query_res.records[0] 560 | self.assertEqual(query_res.size, 1) 561 | self.assertEqual(rr.type, 'Account') 562 | 563 | map(self.assertEqual, 564 | [rr.Id, rr.Name], 565 | [caccount[0]['id'], caccount_data['Name']]) 566 | 567 | def testParentToChildMultiQuery2(self): 568 | svc = self.svc 569 | caccount_data = dict(type='Account', 570 | Name='ChildTestAccount', 571 | AccountNumber='987654321', 572 | Site='www.testsite.com', 573 | ) 574 | caccount = svc.create([caccount_data]) 575 | self._todelete.append(caccount[0]['id']) 576 | 577 | contact_data = dict(type='Contact', 578 | LastName='TestLastName', 579 | FirstName='TestFirstName', 580 | Phone='123-456-7890', 581 | AccountID=caccount[0]['id'], 582 | Email='testfirstname@testlastname.com', 583 | Birthdate=datetime.date(1965, 1, 5) 584 | ) 585 | contact = svc.create([contact_data]) 586 | self._todelete.append(contact[0]['id']) 587 | 588 | contact_data2 = dict(type='Contact', 589 | LastName='TestLastName2', 590 | FirstName='TestFirstName2', 591 | Phone='123-456-7890', 592 | AccountID=caccount[0]['id'], 593 | Email='testfirstname2@testlastname2.com', 594 | Birthdate=datetime.date(1965, 1, 5) 595 | ) 596 | contact2 = svc.create([contact_data2]) 597 | self._todelete.append(contact2[0]['id']) 598 | 599 | query_res = svc.query("Id, Name, (select FirstName, Account.Site from Contacts), (select Name from Assets)", 600 | "Account", 601 | "AccountNumber='987654321'" 602 | ) 603 | 604 | rr = query_res.records[0] 605 | self.assertEqual(query_res.size, 1) 606 | self.assertEqual(rr.type, 'Account') 607 | 608 | map(self.assertEqual, 609 | [rr.Id, rr.Name], 610 | [caccount[0]['id'], caccount_data['Name']]) 611 | 612 | result = 0 613 | for name in [contact_data2['FirstName'], 614 | contact_data['FirstName']]: 615 | if name in [rr.Contacts.records[i].FirstName for i in range(len(rr.Contacts.records))]: 616 | result += 1 617 | self.assertEqual(result, rr.Contacts.size) 618 | 619 | def testMultiQueryCount(self): 620 | svc = self.svc 621 | contact_data = dict(type='Contact', 622 | LastName='TestLastName', 623 | FirstName='TestFirstName', 624 | Phone='123-456-7890', 625 | Email='testfirstname@testlastname.com', 626 | Birthdate=datetime.date(1965, 1, 5) 627 | ) 628 | contact = svc.create([contact_data]) 629 | self._todelete.append(contact[0]['id']) 630 | 631 | contact_data2 = dict(type='Contact', 632 | LastName='TestLastName2', 633 | FirstName='TestFirstName2', 634 | Phone='123-456-7890', 635 | Email='testfirstname2@testlastname2.com', 636 | Birthdate=datetime.date(1965, 1, 5) 637 | ) 638 | contact2 = svc.create([contact_data2]) 639 | self._todelete.append(contact2[0]['id']) 640 | 641 | query_res = svc.query("count()", 642 | "Contact", 643 | "Phone='123-456-7890'" 644 | ) 645 | 646 | self.assertEqual(query_res.size, 2) 647 | 648 | def testAggregateQuery(self): 649 | svc = self.svc 650 | contact_data = dict(type='Contact', 651 | LastName='TestLastName', 652 | FirstName='TestFirstName', 653 | Phone='123-456-7890', 654 | Email='testfirstname@testlastname.com', 655 | Birthdate=datetime.date(1900, 1, 5) 656 | ) 657 | contact = svc.create([contact_data]) 658 | self._todelete.append(contact[0]['id']) 659 | 660 | res = svc.query("SELECT MAX(CreatedDate) FROM Contact GROUP BY LastName") 661 | # the aggregate result is in the 'expr0' attribute of the result 662 | self.assertTrue(hasattr(res[0], 'expr0')) 663 | # (unfortunately no field type info is returned as part of the 664 | # AggregateResult object, so we can't automatically marshall to the 665 | # correct Python type) 666 | 667 | def testQueryDoesNotExist(self): 668 | res = self.svc.query('LastName, FirstName, Phone, Email, Birthdate', 669 | 'Contact', "LastName = 'Doe'") 670 | self.assertEqual(len(res), 0) 671 | 672 | def testQueryMore(self): 673 | svc = self.svc 674 | svc.batchSize = 100 675 | data = list() 676 | for x in range(250): 677 | data.append(dict(type='Contact', 678 | LastName='Doe', 679 | FirstName='John', 680 | Phone='123-456-7890', 681 | Email='john@doe.com', 682 | Birthdate=datetime.date(1970, 1, 4) 683 | )) 684 | res = svc.create(data[:200]) 685 | ids = [x['id'] for x in res] 686 | self._todelete.extend(ids) 687 | res = svc.create(data[200:]) 688 | ids = [x['id'] for x in res] 689 | self._todelete.extend(ids) 690 | res = svc.query('LastName, FirstName, Phone, Email, Birthdate', 691 | 'Contact', "LastName = 'Doe'") 692 | self.assertTrue(not res['done']) 693 | self.assertEqual(len(res), 200) 694 | res = svc.queryMore(res['queryLocator']) 695 | self.assertTrue(res['done']) 696 | self.assertEqual(len(res), 50) 697 | 698 | def testSearch(self): 699 | data = dict(type='Contact', 700 | LastName='LongLastName', 701 | FirstName='John', 702 | Phone='123-456-7890', 703 | Email='john@doe.com', 704 | Birthdate=datetime.date(1970, 1, 4) 705 | ) 706 | res = self.svc.create([data]) 707 | self._todelete.append(res[0]['id']) 708 | 709 | # Requires some delay for indexing 710 | for attempt in range(DELAY_RETRY): 711 | sleep(DELAY_SEC) 712 | res = self.svc.search("FIND {Long} in ALL FIELDS RETURNING Contact(Id, Birthdate)") 713 | if len(res) > 0: 714 | break 715 | self.assertEqual(len(res), 1) 716 | self.assertEqual(res[0].type, 'Contact') 717 | self.assertEqual(type(res[0].Birthdate), datetime.date) 718 | res = self.svc.search("FIND {khgkshgsuhalsf} in ALL FIELDS RETURNING Contact(Id)") 719 | self.assertEqual(len(res), 0) 720 | 721 | def testGetDeleted(self): 722 | svc = self.svc 723 | startdate = datetime.datetime.utcnow() 724 | enddate = startdate + datetime.timedelta(seconds=61) 725 | data = dict(type='Contact', 726 | LastName='Doe', 727 | FirstName='John', 728 | Phone='123-456-7890', 729 | Email='john@doe.com', 730 | Birthdate=datetime.date(1970, 1, 4) 731 | ) 732 | res = svc.create(data) 733 | id = res[0]['id'] 734 | svc.delete(id) 735 | res = svc.getDeleted('Contact', startdate, enddate) 736 | self.assertTrue(len(res) != 0) 737 | ids = [r['id'] for r in res] 738 | self.assertTrue(id in ids) 739 | 740 | def testGetUpdated(self): 741 | svc = self.svc 742 | startdate = datetime.datetime.utcnow() 743 | enddate = startdate + datetime.timedelta(seconds=61) 744 | data = dict(type='Contact', 745 | LastName='Doe', 746 | FirstName='John', 747 | Phone='123-456-7890', 748 | Email='john@doe.com', 749 | Birthdate=datetime.date(1970, 1, 4) 750 | ) 751 | res = svc.create(data) 752 | id = res[0]['id'] 753 | self._todelete.append(id) 754 | data = dict(type='Contact', 755 | Id=id, 756 | FirstName='Jane') 757 | svc.update(data) 758 | res = svc.getUpdated('Contact', startdate, enddate) 759 | self.assertTrue(id in res) 760 | 761 | def testGetUserInfo(self): 762 | svc = self.svc 763 | userinfo = svc.getUserInfo() 764 | self.assertTrue('accessibilityMode' in userinfo) 765 | self.assertTrue('currencySymbol' in userinfo) 766 | self.assertTrue('organizationId' in userinfo) 767 | self.assertTrue('organizationMultiCurrency' in userinfo) 768 | self.assertTrue('organizationName' in userinfo) 769 | self.assertTrue('userDefaultCurrencyIsoCode' in userinfo) 770 | self.assertTrue('userEmail' in userinfo) 771 | self.assertTrue('userFullName' in userinfo) 772 | self.assertTrue('userId' in userinfo) 773 | self.assertTrue('userLanguage' in userinfo) 774 | self.assertTrue('userLocale' in userinfo) 775 | self.assertTrue('userTimeZone' in userinfo) 776 | self.assertTrue('userUiSkin' in userinfo) 777 | 778 | def testDescribeTabs(self): 779 | tabinfo = self.svc.describeTabs() 780 | for info in tabinfo: 781 | self.assertTrue('label' in info) 782 | self.assertTrue('logoUrl' in info) 783 | self.assertTrue('selected' in info) 784 | self.assertTrue('tabs' in info) 785 | for tab in info['tabs']: 786 | self.assertTrue('custom' in tab) 787 | self.assertTrue('label' in tab) 788 | self.assertTrue('sObjectName' in tab) 789 | self.assertTrue('url' in tab) 790 | 791 | def testDescribeLayout(self): 792 | svc = self.svc 793 | self.assertRaises(NotImplementedError, svc.describeLayout, 794 | 'Contact') 795 | 796 | def testSetMultiPicklistToEmpty(self): 797 | svc = self.svc 798 | originalList = ["Pear", "Apple"] 799 | newList = [] 800 | lastname = 'Doe' 801 | data = dict(type='Contact', 802 | LastName=lastname, 803 | FirstName='John', 804 | Favorite_Fruit__c=originalList 805 | ) 806 | res = svc.create([data]) 807 | id = res[0]['id'] 808 | self._todelete.append(id) 809 | contacts = svc.retrieve('LastName, Favorite_Fruit__c', 'Contact', [id]) 810 | self.assertEqual(len(contacts[0]['Favorite_Fruit__c']), 2) 811 | data = dict(type='Contact', 812 | Id=id, 813 | Favorite_Fruit__c=newList) 814 | svc.update(data) 815 | contacts = svc.retrieve('LastName, Favorite_Fruit__c', 'Contact', [id]) 816 | self.assertTrue(isinstance(contacts[0]['Favorite_Fruit__c'], list)) 817 | self.assertEqual(len(contacts[0]['Favorite_Fruit__c']), 0) 818 | 819 | def testAddToEmptyMultiPicklist(self): 820 | svc = self.svc 821 | originalList = [] 822 | newList = ["Pear", "Apple"] 823 | lastname = 'Doe' 824 | data = dict(type='Contact', 825 | LastName=lastname, 826 | FirstName='John', 827 | Favorite_Fruit__c=originalList 828 | ) 829 | res = svc.create([data]) 830 | id = res[0]['id'] 831 | self._todelete.append(id) 832 | contacts = svc.retrieve('LastName, Favorite_Fruit__c', 'Contact', [id]) 833 | self.assertTrue(isinstance(contacts[0]['Favorite_Fruit__c'], list)) 834 | self.assertEqual(len(contacts[0]['Favorite_Fruit__c']), 0) 835 | data = dict(type='Contact', 836 | Id=id, 837 | Favorite_Fruit__c=newList) 838 | svc.update(data) 839 | contacts = svc.retrieve('LastName, Favorite_Fruit__c', 'Contact', [id]) 840 | self.assertTrue(isinstance(contacts[0]['Favorite_Fruit__c'], list)) 841 | self.assertEqual(len(contacts[0]['Favorite_Fruit__c']), 2) 842 | 843 | def testIsNillableField(self): 844 | svc = self.svc 845 | res = svc.describeSObjects('Contact') 846 | self.assertFalse(res[0].fields['LastName'].nillable) 847 | self.assertTrue(res[0].fields['FirstName'].nillable) 848 | self.assertTrue(res[0].fields['Favorite_Fruit__c'].nillable) 849 | 850 | def testUpsert(self): 851 | svc = self.svc 852 | data = dict(type='Contact', 853 | LastName='Doe', 854 | FirstName='John', 855 | Phone='123-456-7890', 856 | Email='john@doe.com', 857 | Birthdate=datetime.date(1970, 1, 4) 858 | ) 859 | res = svc.upsert('Email', [data]) 860 | self.assertTrue(type(res) in (list, tuple)) 861 | self.assertTrue(len(res) == 1) 862 | self.assertTrue(res[0]['success']) 863 | id = res[0]['id'] 864 | self._todelete.append(id) 865 | contacts = svc.retrieve('LastName, FirstName, Phone, Email, Birthdate', 866 | 'Contact', [id]) 867 | self.assertEqual(len(contacts), 1) 868 | contact = contacts[0] 869 | for k in ['LastName', 'FirstName', 'Phone', 'Email', 'Birthdate']: 870 | self.assertEqual( 871 | data[k], contact[k]) 872 | 873 | def testPrepareSObjectsWithNone(self): 874 | obj = { 875 | 'val': None, 876 | } 877 | prepped_obj = _prepareSObjects([obj]) 878 | self.assertEqual(prepped_obj, 879 | [{'val': [], 880 | 'fieldsToNull': ['val'], 881 | }]) 882 | 883 | def testRetrieveTextWithNewlines(self): 884 | data = dict(type='Contact', 885 | LastName='Doe', 886 | FirstName='John', 887 | Description='This is a\nmultiline description.', 888 | ) 889 | res = self.svc.create([data]) 890 | self.assertTrue(type(res) in (list, tuple)) 891 | self.assertTrue(len(res) == 1) 892 | self.assertTrue(res[0]['success']) 893 | id = res[0]['id'] 894 | self._todelete.append(id) 895 | contacts = self.svc.retrieve('FirstName, Description', 'Contact', [id]) 896 | self.assertEqual(len(contacts), 1) 897 | contact = contacts[0] 898 | self.assertEqual(data['FirstName'], contact['FirstName']) 899 | self.assertEqual(data['Description'], contact['Description']) 900 | 901 | @unittest.skip("Email is not working...") 902 | def testSendSimpleEmail(self): 903 | testemail = { 904 | 'subject': 'pyforce test_xmlclient.py testSendSimpleEmail of Salesforce sendEmail()', 905 | 'saveAsActivity': False, 906 | 'toAddresses': str(self.svc.getUserInfo()['userEmail']), 907 | 'plainTextBody': 'This is a test email message with HTML markup.\n\nYou are currently looking at the plain-text body, but the message is sent in both forms.', 908 | } 909 | res = self.svc.sendEmail([testemail]) 910 | print(res) 911 | self.assertTrue(res[0]['success']) 912 | 913 | @unittest.skip("Email is not working...") 914 | def testSendEmailMissingFields(self): 915 | testemail = { 916 | 'toAddresses': str(self.svc.getUserInfo()['userEmail']), 917 | } 918 | res = self.svc.sendEmail([testemail]) 919 | self.assertFalse(res[0]['success']) 920 | self.assertEqual(res[0]['errors'][0]['statusCode'], 'REQUIRED_FIELD_MISSING') 921 | 922 | @unittest.skip("Email is not working...") 923 | def testSendHTMLEmailWithAttachment(self): 924 | solid_logo = { 925 | 'body': 'iVBORw0KGgoAAAANSUhEUgAAAGMAAAA/CAYAAAD0d3YZAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAK6wAACusBgosNWgAAABV0RVh0Q3JlYXRpb24gVGltZQA5LzI0LzE0ZyNW9gAAABx0RVh0U29mdHdhcmUAQWRvYmUgRmlyZXdvcmtzIENTNui8sowAAAlWSURBVHic7ZxNTxtJGsd/OBDLSTAOYEVZo+AwirXsxc4FTgkeKYeVcgjJaeaEucyeYshtbphPgON8AMxt9rJxDpFWWqRpJtJqySX2iZGjJc0q3gmCJI1h4hgSs4dyN37pttv4BbP4L7UE3VVPPa5/PVXPU29dh4eHlKKrq6vs3bEQSfoB9XEDwwVfd4A4IAESQY/UmELbH3p1DtDVcDIiSQcwCwQorvxq2AHCQJSgRz6+Au2P1pARSYYQRPQdTwBwREqYoEcxUaYDmMw/bsBbkmIF1QKDnlgdejUMzSUjknQDMcoroh4kgABBT7xCmSFgqgaZtRHdJDSPjEjSh+j367EGI+wAk0XjibCEEDBTp9wQQU+4DhnHRnPIaC4RhbhP0BPLlxelcRb4DGF9LbWSxpMhuok4zScCREueRXQxjS4vAfhbSYgRGZY6ZMZoDRHky1lsUnleQMp3fyeK7mPliiRnaexgfdLwIqwuUPZFxEogYiUVcv6JN9Kiau+mRAuSaZ1VtBL3EV2v6ipPmMiTQIybYbPxUePGDBFLzJkp9BRih/oa2QrCS5MqJWokGTK1RdZnESsIL03W+9iYAVy4lh0iqmMCiBNJTtaSqVZvyl9j+rOMPuBp3tkxhQ4ZzccCkaSpSL9WMk7cFz+lmCGSDFRLVCsZ7mOp0gHAYn7MNUStZHQG7/pQcQq/VjLk4+vRATCcj9N0USsZG/Xp0gEw2/Xkte7Y27GM1qMPvTkwaidDf9WtzeGw1jM53RToxh61ainVr0c5QuMDHD68QWh8oKFyw7edHD68wccfvkF6MNRQ2XViuOvJ6zLPqjYyxHr0qbCO0PgAM14HG7sHPE4ohOMntuRtBH/pi9rWM8T0+QZQ0V9uBwRGewGYfP4b8a3sCWujCz9iDUWDeTJEwBKjQqwRGLUTGLUDoGRzxNb3iK6lcVgtBEbtTI5c0tJKqQzh+EeUbE5fU5eNwKgdt70HJZsjupYmtr5XtSy/y4Z/6ALDvT0ATI5cwjdoJbqWZnLkEoFROw6rpUymmk9OH+B32XDbewjHFWLre0W6AMS3s4RW36Nkc9rvclgtyOkDwgnFLPllHpU5MsTsY5QKc/3h205mvEL+zn6OvvMW7o1cREpl8LtsLNxyFqWfcNlw93YTWN4skxUYtbN450rRu3sjF5le3iS6liY0PsDcWL9+WUMXtG8Ac2P9LK2lAXRlzr/8QGj1fVk+gOhaWlcXn9PK7C9bRb9Z/U1To3Zu/vQfM4SULVxVJ+NoR0bFRRffoBWA60sycvoAt70Hv8uGnD4glv0KyxBb30PJ5nDbe3gz5WZq1K5LRvi2IE6tfL/Lxs8Phgjfdmr/A9qPLiwrtPoeOX3A4p0rPE4ozP6yhcNq4eMP3+jKnBvrJxz/WFT+9PKmpqvyF5Hv0YstbdxxWC34nFZmvA529nP4//aW+FaWWZ+DhVtOQmMDTD7/b9WqLUVlMsQYIVHD6pf0wEVo9YPWbYDoRuT0AbO+y/hdNpRsTmvRpfC7bPSdt7CzL0hTPSw1faGbGrt7lXBcIbqW1soC0XIBrXWqDSWxndXSSakMK6kMEy6b9h1gqUCWqktiO1vkACjZnNblKtmvTI5cKuqC/UM2s9VVhGqWEcUkEbMvtojd/QPDvT0s3rnCIle0LkDP1Kuh77ylrNsAcFjP5cu6ynBvDwu3nCzccmpWAEeVL6cPivIajk9DF7S/5d0vZd+N8gEM9/aU6anXyMzAmAyxK+KeWUHxrSzu6Jt8K7nI1KidubF+pLefCI0LZdUuAkB6MMSEy7gFbeweEPhHeRemVrA7KheVNeN1IL3NEFvf0yxDSmVM667m0UOloPHZ+u9l3Zyyb0xeYbGlLypRGDIjUYXqbcTW9wgsb2qDptveg8N6DjBXOfFt0bUM94p8UiqjPeo3o7J8Tituew995y1s7B5ZhdraJ1w2rdJ9TqvWGFS5Rrp4B62a56ZC88KGbCj7uSIdTXpTO6Uv9C1D7BY0s01Fg9oNJbazKNmc9kOlVIbYv/eYGrUT//7aUT9u0BKVbI75lx+YG+vn6d2rbOweIKe/MOGysbSWJrC8aVhWdC2Nu1f8pPjWviZTTovAb8br4NV311hJZbTyHyeUsu5MT5fFO1cIjfdrDog7+kYbc159d41EAXGFg30FSKUvzoVCobJU8y8/BIA/V5NWiMT2PrZui1DU3sPqu89ML28S384ipTJcvdDN56+HTLhsbOx+4d2nryz9uouUyuC293A5bwVq+sT2PlcvduOwWjR5sfXfiW9nWUlluGw9h9vejXfQykoqw6MX2/zr3WcCf+rD77Lx19d7RZb4941P7OznsHVbmHDZWH33maVfd/nxn9sAZTpoNZbKsLH7JV+eiHniW/v5OEmhq6uLLuCP/ee1evgpuas79pRWc2h8QC58ob9V58nrGDWMF+2E+PfX8A5auf/8t6Igsc2gHD68cbn0pdEA7m6uLo2Fw2rBN2jF57TiHbSys59rZyIAlvReGpFxqvbRTo5cKnKdVRe3jaG7W+R4G5/bDFIqw/zLD4Dwctp0YlDF0uHDG7LeB6MxQ3//YQf1QgGuHz68oetqGcUZctPUOdt4VOkIgREZnY0HjUeUoCdaKYERGW1xRPf/CDGCnulqiTpkNB9RoCoRUOl8RiT5M52NzvUiTNDzqPTlcc5n6AYmHZiCjDguXUZEJVQ+udSxjlqhIAK6x5W8JiPLqBb0TQOv6BwFqIZY/nlWz+nX6mf6xLmCxeMWcMoRQ0x1OxDzdW5E61cXhiSCnpVahdZ3wPJsEqIA15txc0J9ByxFsHIfoeBZwbetvlPE/Mq5uKvpJmcjBpk2vFqpiTjeRS5is8IUBlvbTzEUhEU0lYhmXXHkQKyV+/KP6nWpg1yA07NQFaXKRF6j0Lo7CgshzkAvNEaYaSiIRhBA3P9RLW0MmG/lvYgnRYYDeENr45R5gp5QgQ6q5RbqoAArJzEuwEmRAeqm6aeNE1gRMnDzJO8fNINmXP5lDsILiza9HIHpdieiElp12O0RzT/xNH3aLypuDRmitX5L8wiZrraKdhrQumOgR4REGyhVQUxVN1LmiaH5A7geIskpxFRzPV6WhLAIuREqtRIn500ZQbi9M9QeGMYQ6wVSw3VqEdqPjEJEkvcQi1g+jqaqVcQLnmen0RJKYUTG/wD1zD3TE+BLQQAAAABJRU5ErkJggg==', 926 | 'contentType': 'image/png', 927 | 'fileName': 'salesforce_logo.png', 928 | 'inline': True 929 | } 930 | testemail = { 931 | 'subject': 'pyforce test_xmlclient.py testSendHTMLEmailWithAttachment of Salesforce sendEmail()', 932 | 'useSignature': True, 933 | 'saveAsActivity': False, 934 | 'toAddresses': str(self.svc.getUserInfo()['userEmail']), 935 | 'plainTextBody': 'This is a test email message with HTML markup.\n\nYou are currently looking at the plain-text body, but the message is sent in both forms.', 936 | 'htmlBody': '

This is a test email message with HTML markup.

\n\n

You are currently looking at the HTML body, but the message is sent in both forms.

', 937 | 'inReplyTo': '<1234567890123456789%example@example.com>', 938 | 'references': '<1234567890123456789%example@example.com>', 939 | 'fileAttachments': [solid_logo] 940 | } 941 | res = self.svc.sendEmail([testemail]) 942 | self.assertTrue(res[0]['success']) 943 | 944 | def testLogout(self): 945 | """Logout and verify that the previous sessionId longer works.""" 946 | result = self.svc.getUserInfo() 947 | self.assertIn('userId', result) 948 | response = self.svc.logout() 949 | self.assertTrue(response, "Logout didn't return _tPartnerNS.logoutResponse") 950 | with self.assertRaises(pyforce.SessionTimeoutError) as cm: 951 | # Sometimes this doesn't work on the first try. Flakey... 952 | for _ in range(DELAY_RETRY): 953 | sleep(DELAY_SEC) 954 | result = self.svc.getUserInfo() 955 | self.assertEqual(cm.exception.faultCode, 'INVALID_SESSION_ID', "Session didn't fail with INVALID_SESSION_ID.") 956 | 957 | 958 | def test_suite(): 959 | return unittest.TestSuite(( 960 | unittest.makeSuite(TestUtils), 961 | )) 962 | 963 | 964 | if __name__ == '__main__': 965 | unittest.main(defaultTest='test_suite') 966 | -------------------------------------------------------------------------------- /tests/xmlclient_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from __future__ import unicode_literals 3 | 4 | import datetime 5 | import os 6 | import unittest 7 | from time import sleep 8 | 9 | import pyforce 10 | 11 | partnerns = pyforce.pyclient._tPartnerNS 12 | sobjectns = pyforce.pyclient._tSObjectNS 13 | SERVER_URL = "https://test.salesforce.com/services/Soap/u/20.0" 14 | svc = pyforce.XMLClient(SERVER_URL) 15 | 16 | DELAY_RETRY = 10 17 | DELAY_SEC = 2 18 | 19 | 20 | class TestBeatbox(unittest.TestCase): 21 | 22 | def setUp(self): 23 | svc.login(os.getenv('SF_USERNAME'), os.getenv('SF_PASSWORD')) 24 | self._todelete = list() 25 | 26 | def tearDown(self): 27 | for id in self._todelete: 28 | svc.delete(id) 29 | 30 | def testCreate(self): 31 | data = dict( 32 | type='Contact', 33 | LastName='Doe', 34 | FirstName='John', 35 | Phone='123-456-7890', 36 | Email='john@doe.com', 37 | Birthdate=datetime.date(1970, 1, 4), 38 | ) 39 | res = svc.create([data]) 40 | self.assertEqual(str(res[partnerns.success]), 'true') 41 | id = str(res[partnerns.id]) 42 | self._todelete.append(id) 43 | contact = svc.retrieve( 44 | 'LastName, FirstName, Phone, Email', 45 | 'Contact', 46 | [id], 47 | ) 48 | for k in ['LastName', 'FirstName', 'Phone', 'Email']: 49 | self.assertEqual(data[k], str(contact[getattr(sobjectns, k)])) 50 | 51 | def testUpdate(self): 52 | data = dict( 53 | type='Contact', 54 | LastName='Doe', 55 | FirstName='John', 56 | Email='john@doe.com', 57 | ) 58 | res = svc.create([data]) 59 | self.assertEqual(str(res[partnerns.success]), 'true') 60 | id = str(res[partnerns.id]) 61 | self._todelete.append(id) 62 | contact = svc.retrieve('Email', 'Contact', [id]) 63 | self.assertEqual( 64 | str(contact[sobjectns.Email]), data['Email']) 65 | updata = dict( 66 | type='Contact', 67 | Id=id, 68 | Email='jd@doe.com', 69 | ) 70 | res = svc.update(updata) 71 | self.assertEqual(str(res[partnerns.success]), 'true') 72 | contact = svc.retrieve( 73 | 'LastName, FirstName, Email', 74 | 'Contact', 75 | [id], 76 | ) 77 | for k in ['LastName', 'FirstName', ]: 78 | self.assertEqual( 79 | data[k], 80 | str(contact[getattr(sobjectns, k)]), 81 | ) 82 | self.assertEqual( 83 | str(contact[sobjectns.Email]), updata['Email']) 84 | 85 | def testQuery(self): 86 | data = dict( 87 | type='Contact', 88 | LastName='Doe', 89 | FirstName='John', 90 | Phone='123-456-7890', 91 | Email='john@doe.com', 92 | Birthdate=datetime.date(1970, 1, 4), 93 | ) 94 | res = svc.create([data]) 95 | self.assertEqual(str(res[partnerns.success]), 'true') 96 | self._todelete.append(str(res[partnerns.id])) 97 | data2 = dict( 98 | type='Contact', 99 | LastName='Doe', 100 | FirstName='Jane', 101 | Phone='123-456-7890', 102 | Email='jane@doe.com', 103 | Birthdate=datetime.date(1972, 10, 15), 104 | ) 105 | res = svc.create([data2]) 106 | self.assertEqual(str(res[partnerns.success]), 'true') 107 | janeid = str(res[partnerns.id]) 108 | self._todelete.append(janeid) 109 | query = ("select LastName, FirstName, Phone, Email, Birthdate " 110 | "from Contact where LastName = 'Doe'") 111 | res = svc.query(query) 112 | self.assertEqual(int(str(res[partnerns.size])), 2) 113 | query = ("select Id, LastName, FirstName, Phone, Email, Birthdate " 114 | "from Contact where LastName = 'Doe' and FirstName = 'Jane'") 115 | res = svc.query(query) 116 | self.assertEqual(int(str(res[partnerns.size])), 1) 117 | records = res[partnerns.records, ] 118 | self.assertEqual( 119 | janeid, str(records[0][sobjectns.Id])) 120 | 121 | def testSearch(self): 122 | data = dict( 123 | type='Contact', 124 | LastName='LongLastName', 125 | FirstName='John', 126 | Phone='123-456-7890', 127 | Email='john@doe.com', 128 | Birthdate=datetime.date(1970, 1, 4), 129 | ) 130 | res = svc.create([data]) 131 | self.assertEqual(str(res[partnerns.success]), 'true') 132 | self._todelete.append(str(res[partnerns.id])) 133 | 134 | # Requires some delay for indexing 135 | for _ in range(DELAY_RETRY): 136 | sleep(DELAY_SEC) 137 | res = svc.search( 138 | "FIND {Long} in ALL FIELDS RETURNING " 139 | "Contact(Id, LastName, FirstName, Phone, Email, Birthdate)" 140 | ) 141 | if len(res) > 0: 142 | break 143 | self.assertEqual(len(res), 1) 144 | 145 | @unittest.skip("Email is not working...") 146 | def testSendSimpleEmail(self): 147 | testemail = { 148 | 'subject': ( 149 | 'pyforce test_xmlclient.py testSendSimpleEmail of ' 150 | 'Salesforce sendEmail()' 151 | ), 152 | 'saveAsActivity': False, 153 | 'toAddresses': str(svc.getUserInfo()['userEmail']), 154 | 'plainTextBody': ( 155 | 'This is a test email message with HTML markup.\n\n' 156 | 'You are currently looking at the plain-text body, ' 157 | 'but the message is sent in both forms.' 158 | ), 159 | } 160 | res = svc.sendEmail([testemail]) 161 | self.assertEqual(str(res[partnerns.success]), 'true') 162 | 163 | @unittest.skip("Email is not working...") 164 | def testSendEmailMissingFields(self): 165 | testemail = { 166 | 'toAddresses': str(svc.getUserInfo()['userEmail']), 167 | } 168 | res = svc.sendEmail([testemail]) 169 | self.assertEqual(str(res[partnerns.success]), 'false') 170 | self.assertEqual( 171 | str(res[partnerns.errors][partnerns.statusCode]), 172 | 'REQUIRED_FIELD_MISSING', 173 | ) 174 | 175 | @unittest.skip("Email is not working...") 176 | def testSendHTMLEmailWithAttachment(self): 177 | solid_logo = { 178 | 'body': 'iVBORw0KGgoAAAANSUhEUgAAAGMAAAA/CAYAAAD0d3YZAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAK6wAACusBgosNWgAAABV0RVh0Q3JlYXRpb24gVGltZQA5LzI0LzE0ZyNW9gAAABx0RVh0U29mdHdhcmUAQWRvYmUgRmlyZXdvcmtzIENTNui8sowAAAlWSURBVHic7ZxNTxtJGsd/OBDLSTAOYEVZo+AwirXsxc4FTgkeKYeVcgjJaeaEucyeYshtbphPgON8AMxt9rJxDpFWWqRpJtJqySX2iZGjJc0q3gmCJI1h4hgSs4dyN37pttv4BbP4L7UE3VVPPa5/PVXPU29dh4eHlKKrq6vs3bEQSfoB9XEDwwVfd4A4IAESQY/UmELbH3p1DtDVcDIiSQcwCwQorvxq2AHCQJSgRz6+Au2P1pARSYYQRPQdTwBwREqYoEcxUaYDmMw/bsBbkmIF1QKDnlgdejUMzSUjknQDMcoroh4kgABBT7xCmSFgqgaZtRHdJDSPjEjSh+j367EGI+wAk0XjibCEEDBTp9wQQU+4DhnHRnPIaC4RhbhP0BPLlxelcRb4DGF9LbWSxpMhuok4zScCREueRXQxjS4vAfhbSYgRGZY6ZMZoDRHky1lsUnleQMp3fyeK7mPliiRnaexgfdLwIqwuUPZFxEogYiUVcv6JN9Kiau+mRAuSaZ1VtBL3EV2v6ipPmMiTQIybYbPxUePGDBFLzJkp9BRih/oa2QrCS5MqJWokGTK1RdZnESsIL03W+9iYAVy4lh0iqmMCiBNJTtaSqVZvyl9j+rOMPuBp3tkxhQ4ZzccCkaSpSL9WMk7cFz+lmCGSDFRLVCsZ7mOp0gHAYn7MNUStZHQG7/pQcQq/VjLk4+vRATCcj9N0USsZG/Xp0gEw2/Xkte7Y27GM1qMPvTkwaidDf9WtzeGw1jM53RToxh61ainVr0c5QuMDHD68QWh8oKFyw7edHD68wccfvkF6MNRQ2XViuOvJ6zLPqjYyxHr0qbCO0PgAM14HG7sHPE4ohOMntuRtBH/pi9rWM8T0+QZQ0V9uBwRGewGYfP4b8a3sCWujCz9iDUWDeTJEwBKjQqwRGLUTGLUDoGRzxNb3iK6lcVgtBEbtTI5c0tJKqQzh+EeUbE5fU5eNwKgdt70HJZsjupYmtr5XtSy/y4Z/6ALDvT0ATI5cwjdoJbqWZnLkEoFROw6rpUymmk9OH+B32XDbewjHFWLre0W6AMS3s4RW36Nkc9rvclgtyOkDwgnFLPllHpU5MsTsY5QKc/3h205mvEL+zn6OvvMW7o1cREpl8LtsLNxyFqWfcNlw93YTWN4skxUYtbN450rRu3sjF5le3iS6liY0PsDcWL9+WUMXtG8Ac2P9LK2lAXRlzr/8QGj1fVk+gOhaWlcXn9PK7C9bRb9Z/U1To3Zu/vQfM4SULVxVJ+NoR0bFRRffoBWA60sycvoAt70Hv8uGnD4glv0KyxBb30PJ5nDbe3gz5WZq1K5LRvi2IE6tfL/Lxs8Phgjfdmr/A9qPLiwrtPoeOX3A4p0rPE4ozP6yhcNq4eMP3+jKnBvrJxz/WFT+9PKmpqvyF5Hv0YstbdxxWC34nFZmvA529nP4//aW+FaWWZ+DhVtOQmMDTD7/b9WqLUVlMsQYIVHD6pf0wEVo9YPWbYDoRuT0AbO+y/hdNpRsTmvRpfC7bPSdt7CzL0hTPSw1faGbGrt7lXBcIbqW1soC0XIBrXWqDSWxndXSSakMK6kMEy6b9h1gqUCWqktiO1vkACjZnNblKtmvTI5cKuqC/UM2s9VVhGqWEcUkEbMvtojd/QPDvT0s3rnCIle0LkDP1Kuh77ylrNsAcFjP5cu6ynBvDwu3nCzccmpWAEeVL6cPivIajk9DF7S/5d0vZd+N8gEM9/aU6anXyMzAmAyxK+KeWUHxrSzu6Jt8K7nI1KidubF+pLefCI0LZdUuAkB6MMSEy7gFbeweEPhHeRemVrA7KheVNeN1IL3NEFvf0yxDSmVM667m0UOloPHZ+u9l3Zyyb0xeYbGlLypRGDIjUYXqbcTW9wgsb2qDptveg8N6DjBXOfFt0bUM94p8UiqjPeo3o7J8Tituew995y1s7B5ZhdraJ1w2rdJ9TqvWGFS5Rrp4B62a56ZC88KGbCj7uSIdTXpTO6Uv9C1D7BY0s01Fg9oNJbazKNmc9kOlVIbYv/eYGrUT//7aUT9u0BKVbI75lx+YG+vn6d2rbOweIKe/MOGysbSWJrC8aVhWdC2Nu1f8pPjWviZTTovAb8br4NV311hJZbTyHyeUsu5MT5fFO1cIjfdrDog7+kYbc159d41EAXGFg30FSKUvzoVCobJU8y8/BIA/V5NWiMT2PrZui1DU3sPqu89ML28S384ipTJcvdDN56+HTLhsbOx+4d2nryz9uouUyuC293A5bwVq+sT2PlcvduOwWjR5sfXfiW9nWUlluGw9h9vejXfQykoqw6MX2/zr3WcCf+rD77Lx19d7RZb4941P7OznsHVbmHDZWH33maVfd/nxn9sAZTpoNZbKsLH7JV+eiHniW/v5OEmhq6uLLuCP/ee1evgpuas79pRWc2h8QC58ob9V58nrGDWMF+2E+PfX8A5auf/8t6Igsc2gHD68cbn0pdEA7m6uLo2Fw2rBN2jF57TiHbSys59rZyIAlvReGpFxqvbRTo5cKnKdVRe3jaG7W+R4G5/bDFIqw/zLD4Dwctp0YlDF0uHDG7LeB6MxQ3//YQf1QgGuHz68oetqGcUZctPUOdt4VOkIgREZnY0HjUeUoCdaKYERGW1xRPf/CDGCnulqiTpkNB9RoCoRUOl8RiT5M52NzvUiTNDzqPTlcc5n6AYmHZiCjDguXUZEJVQ+udSxjlqhIAK6x5W8JiPLqBb0TQOv6BwFqIZY/nlWz+nX6mf6xLmCxeMWcMoRQ0x1OxDzdW5E61cXhiSCnpVahdZ3wPJsEqIA15txc0J9ByxFsHIfoeBZwbetvlPE/Mq5uKvpJmcjBpk2vFqpiTjeRS5is8IUBlvbTzEUhEU0lYhmXXHkQKyV+/KP6nWpg1yA07NQFaXKRF6j0Lo7CgshzkAvNEaYaSiIRhBA3P9RLW0MmG/lvYgnRYYDeENr45R5gp5QgQ6q5RbqoAArJzEuwEmRAeqm6aeNE1gRMnDzJO8fNINmXP5lDsILiza9HIHpdieiElp12O0RzT/xNH3aLypuDRmitX5L8wiZrraKdhrQumOgR4REGyhVQUxVN1LmiaH5A7geIskpxFRzPV6WhLAIuREqtRIn500ZQbi9M9QeGMYQ6wVSw3VqEdqPjEJEkvcQi1g+jqaqVcQLnmen0RJKYUTG/wD1zD3TE+BLQQAAAABJRU5ErkJggg==', # NOQA 179 | 'contentType': 'image/png', 180 | 'fileName': 'salesforce_logo.png', 181 | 'inline': True 182 | } 183 | testemail = { 184 | 'subject': ( 185 | 'pyforce test_xmlclient.py testSendHTMLEmailWithAttachment ' 186 | 'of Salesforce sendEmail()' 187 | ), 188 | 'useSignature': True, 189 | 'saveAsActivity': False, 190 | 'toAddresses': str(svc.getUserInfo()['userEmail']), 191 | 'plainTextBody': ( 192 | 'This is a test email message with HTML markup.\n\n' 193 | 'You are currently looking at the plain-text body, ' 194 | 'but the message is sent in both forms.' 195 | ), 196 | 'htmlBody': '

This is a test email message with HTML markup.

\n\n

You are currently looking at the HTML body, but the message is sent in both forms.

', # NOQA 197 | 'inReplyTo': '<1234567890123456789%example@example.com>', 198 | 'references': '<1234567890123456789%example@example.com>', 199 | 'fileAttachments': [solid_logo] 200 | } 201 | res = svc.sendEmail([testemail]) 202 | self.assertEqual(str(res[partnerns.success]), 'true') 203 | 204 | def testLogout(self): 205 | """Logout and verify that the previous sessionId longer works.""" 206 | # from ipdb import set_trace; set_trace() 207 | result = svc.getUserInfo() 208 | self.assertTrue(hasattr(result, 'userId')) 209 | response = svc.logout() 210 | self.assertEqual( 211 | response._name, 212 | partnerns.logoutResponse, 213 | "Response {} was not {}".format( 214 | response._name, 215 | partnerns.logoutResponse, 216 | ), 217 | ) 218 | with self.assertRaises(pyforce.xmlclient.SessionTimeoutError) as cm: 219 | # Sometimes this doesn't work on the first try. Flakey... 220 | for _ in range(DELAY_RETRY): 221 | sleep(DELAY_SEC) 222 | result = svc.getUserInfo() 223 | self.assertEqual( 224 | cm.exception.faultCode, 225 | 'INVALID_SESSION_ID', 226 | "Session didn't fail with INVALID_SESSION_ID.", 227 | ) 228 | 229 | 230 | def test_suite(): 231 | return unittest.TestSuite(( 232 | unittest.makeSuite(TestBeatbox), 233 | )) 234 | 235 | 236 | if __name__ == '__main__': 237 | unittest.main(defaultTest='test_suite') 238 | -------------------------------------------------------------------------------- /tests/xmltramp_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from __future__ import unicode_literals 3 | 4 | import unittest 5 | 6 | from six import text_type 7 | 8 | from pyforce.xmltramp import * 9 | 10 | 11 | class TestXMLTramp(unittest.TestCase): 12 | def testXMLTramp(self): 13 | parse('afoobara').__repr__(1, 1) == \ 14 | '\n\ta\n\t\tfoobar\n\ta\n' 15 | 16 | assert text_type(parse("")) == "" 17 | assert text_type(parse("I love you.")) == "I love you." 18 | assert parse("\nmom\nwow\n")[0].strip() == "mom\nwow" 19 | assert text_type( 20 | parse(' center ') 21 | ) == "center" 22 | assert text_type(parse('\xcf\x80')) == '\xcf\x80' 23 | 24 | d = Element( 25 | 'foo', 26 | attrs={'foo': 'bar'}, 27 | children=['hit with a', Element('bar'), Element('bar')] 28 | ) 29 | 30 | try: 31 | d._doesnotexist 32 | assert False, "but found success. Damn." 33 | except AttributeError: 34 | pass 35 | assert d.bar._name == 'bar' 36 | try: 37 | d.doesnotexist 38 | assert False, "but found success. Damn." 39 | except AttributeError: 40 | pass 41 | 42 | assert hasattr(d, 'bar') is True 43 | 44 | assert d('foo') == 'bar' 45 | d(silly='yes') 46 | assert d('silly') == 'yes' 47 | assert d() == d._attrs 48 | 49 | assert d[0] == 'hit with a' 50 | d[0] = 'ice cream' 51 | assert d[0] == 'ice cream' 52 | del d[0] 53 | assert d[0]._name == "bar" 54 | assert len(d[:]) == len(d._dir) 55 | assert len(d[1:]) == len(d._dir) - 1 56 | assert len(d['bar', ]) == 2 57 | d['bar', ] = 'baz' 58 | assert len(d['bar', ]) == 3 59 | assert d['bar']._name == 'bar' 60 | 61 | d = Element('foo') 62 | 63 | doc = Namespace("http://example.org/bar") 64 | bbc = Namespace("http://example.org/bbc") 65 | dc = Namespace("http://purl.org/dc/elements/1.1/") 66 | d = parse(""" 70 | John Polk and John Palfrey 71 | John Polk 72 | John Palfrey 73 | Buffy 74 | """) 75 | 76 | assert repr(d) == '...' 77 | assert d.__repr__(1) == ( 78 | 'John Polk and John PalfreyJohn PolkJohn PalfreyBuffy' 83 | ) 84 | assert d.__repr__(1, 1) == ( 85 | '\n\tJohn Polk and John Palfrey\n\tJoh' 88 | 'n Polk\n\tJohn Palfrey\n\tBuffy\n' 90 | ) 91 | 92 | assert repr(parse("")) == '' 93 | 94 | assert text_type(d.author) == text_type(d['author']) == "John Polk and John Palfrey" 95 | assert d.author._name == doc.author 96 | assert text_type(d[dc.creator]) == "John Polk" 97 | assert d[dc.creator]._name == dc.creator 98 | assert text_type(d[dc.creator, ][1]) == "John Palfrey" 99 | d[dc.creator] = "Me!!!" 100 | assert text_type(d[dc.creator]) == "Me!!!" 101 | assert len(d[dc.creator, ]) == 1 102 | d[dc.creator, ] = "You!!!" 103 | assert len(d[dc.creator, ]) == 2 104 | 105 | assert d[bbc.show](bbc.station) == "4" 106 | d[bbc.show](bbc.station, "5") 107 | assert d[bbc.show](bbc.station) == "5" 108 | 109 | e = Element('e') 110 | e.c = '' 111 | assert e.__repr__(1) == '<img src="foo">' 112 | e.c = '2 > 4' 113 | assert e.__repr__(1) == '2 > 4' 114 | e.c = 'CDATA sections are closed with ]]>.' 115 | assert e.__repr__(1) == ( 116 | 'CDATA sections are <em>closed</em> with ]]>.' 117 | ) 118 | e.c = parse( 119 | '
i
loveyou
' 121 | ) 122 | assert e.__repr__(1) == ( 123 | '
i
' 124 | 'love
you
' 125 | ) 126 | 127 | e = Element('e') 128 | e('c', 'that "sucks"') 129 | assert e.__repr__(1) == '' 130 | 131 | assert quote("]]>") == "]]>" 132 | assert quote( 133 | '< dkdkdsd dkd sksdksdfsd fsdfdsf]]> kfdfkg >' 134 | ) == '< dkdkdsd dkd sksdksdfsd fsdfdsf]]> kfdfkg >' 135 | 136 | assert parse('').__repr__(1) == '' 137 | assert parse( 138 | '' 139 | ).__repr__(1) == '' 140 | 141 | # This uses internal entities to DoS vulnerable XML parsers 142 | evil_xml = """ 143 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | ]> 156 | &lol9;""" 157 | try: 158 | assert parse(evil_xml) 159 | # It never gets here and instead raises a defusedxml.common.EntitiesForbidden exception 160 | assert False 161 | except defusedxml.common.EntitiesForbidden: 162 | assert True 163 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27, py3, pypy, pypy3 3 | # Some environments don't have pypy or pypy3 4 | skip_missing_interpreters = true 5 | tox_pip_extensions_ext_pip_custom_platform = true 6 | 7 | [base] 8 | deps = -rrequirements-dev.txt 9 | 10 | # Streaming coverage omitted because baoicas is not in pypi 11 | [testenv] 12 | # Env for integration tests 13 | passenv = SF_USERNAME SF_PASSWORD SF_SECTOKEN SF_VERSION 14 | deps = {[base]deps} 15 | commands = 16 | pre-commit run --all-files 17 | coverage run --source ./pyforce -m pytest -vv --capture=no {posargs:tests} 18 | coverage report -m --fail-under 80 19 | 20 | [flake8] 21 | exclude = .svn,CVS,.bzr,.hg,.git,__pycache__,.ropeproject,.tox,docs,virtualenv_run 22 | filename = *.py,*.wsgi 23 | max-line-length = 80 24 | ignore = W606,W504 25 | 26 | [pytest] 27 | norecursedirs = .* _darcs CVS docs virtualenv_run 28 | --------------------------------------------------------------------------------