├── .github └── workflows │ ├── main.yml │ └── publish.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── bpmn_process ├── bpmn_error_example.bpmn ├── correlate_message_example.bpmn ├── event_subprocess_example.bpmn └── parallel_steps_example.bpmn ├── camunda ├── __init__.py ├── client │ ├── __init__.py │ ├── async_external_task_client.py │ ├── engine_client.py │ ├── external_task_client.py │ └── tests │ │ ├── __init__.py │ │ ├── test_async_external_task_client.py │ │ ├── test_async_external_task_client_auth.py │ │ ├── test_async_external_task_client_bearer.py │ │ ├── test_engine_client.py │ │ ├── test_engine_client_auth.py │ │ ├── test_engine_client_bearer.py │ │ ├── test_external_task_client.py │ │ ├── test_external_task_client_auth.py │ │ └── test_external_task_client_bearer.py ├── external_task │ ├── __init__.py │ ├── async_external_task_executor.py │ ├── async_external_task_worker.py │ ├── external_task.py │ ├── external_task_executor.py │ ├── external_task_worker.py │ └── tests │ │ ├── __init__.py │ │ ├── test_async_external_task_executor.py │ │ ├── test_async_external_task_worker.py │ │ ├── test_external_task.py │ │ ├── test_external_task_executor.py │ │ └── test_external_task_worker.py ├── process_definition │ ├── __init__.py │ ├── process_definition_client.py │ └── tests │ │ ├── __init__.py │ │ └── test_process_definition_client.py ├── utils │ ├── __init__.py │ ├── auth_basic.py │ ├── auth_bearer.py │ ├── log_utils.py │ ├── response_utils.py │ ├── tests │ │ ├── test_auth_basic.py │ │ ├── test_auth_bearer.py │ │ ├── test_response_utils.py │ │ └── test_utils.py │ └── utils.py └── variables │ ├── __init__.py │ ├── properties.py │ ├── tests │ ├── test_properties.py │ └── test_variables.py │ └── variables.py ├── docker-compose-auth.yml ├── docker-compose.yml ├── engine-rest └── web.xml ├── examples ├── __init__.py ├── bpmn_error_example.py ├── correlate_message.py ├── event_subprocess_example.py ├── examples_auth_basic │ ├── __init__.py │ ├── fetch_and_execute.py │ ├── get_process_instance.py │ ├── start_process.py │ └── task_handler_example.py ├── fetch_and_execute.py ├── get_process_instance.py ├── retry_task_example.py ├── start_process.py ├── task_handler_example.py └── tasks_example.py ├── postman_collection └── Camunda REST APIs.postman_collection.json ├── pyproject.toml ├── requirements.txt └── setup.py /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: camunda-external-task-client-python3 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: ["3.9", "3.10", "3.11"] 12 | 13 | steps: 14 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.0 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | 20 | - name: Cache pip 21 | uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 22 | with: 23 | # This path is specific to Ubuntu 24 | path: ~/.cache/pip 25 | # Look to see if there is a cache hit for the corresponding requirements file 26 | key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }} 27 | restore-keys: | 28 | ${{ runner.os }}-pip- 29 | ${{ runner.os }}- 30 | 31 | - name: Install dependencies 32 | run: | 33 | python -m pip install --upgrade pip 34 | pip install flake8 pytest 35 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 36 | - name: Lint with flake8 37 | run: | 38 | # stop the build if there are Python syntax errors or undefined names 39 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 40 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 41 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 42 | - name: Test with pytest 43 | run: | 44 | pip install pytest 45 | pip install pytest-cov 46 | pytest --junitxml=junit/test-results.xml --cov=camunda --cov-report=xml --cov-report=html --verbose 47 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python Package to PyPI 2 | on: 3 | release: 4 | types: [published] 5 | 6 | permissions: 7 | id-token: write 8 | contents: read 9 | 10 | jobs: 11 | pypi-publish: 12 | name: Upload release to PyPI 13 | runs-on: ubuntu-latest 14 | environment: pypi 15 | 16 | steps: 17 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 18 | 19 | - name: Set up Python 20 | uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.0 21 | with: 22 | python-version: "3.x" 23 | 24 | - name: Build package 25 | run: | 26 | python -m pip install build 27 | python -m build 28 | 29 | - name: Publish to PyPI 30 | uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4 31 | with: 32 | packages-dir: dist 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # IDE 132 | .idea 133 | 134 | # Mac 135 | .DS_Store 136 | 137 | postgres-data/ -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM camunda/camunda-bpm-platform:7.16.0 2 | USER root 3 | COPY --chown=camunda:camunda ./engine-rest/web.xml /camunda/webapps/engine-rest/WEB-INF/web.xml 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Community Extension](https://img.shields.io/badge/Community%20Extension-An%20open%20source%20community%20maintained%20project-FF4700)](https://github.com/camunda-community-hub/community)[![Lifecycle: Stable](https://img.shields.io/badge/Lifecycle-Stable-brightgreen)](https://github.com/Camunda-Community-Hub/community/blob/main/extension-lifecycle.md#stable-) 2 | 3 | # camunda-external-task-client-python3 4 | ![camunda-external-task-client-python3](https://github.com/trustfactors/camunda-external-task-client-python3/workflows/camunda-external-task-client-python3/badge.svg) 5 | 6 | This repository contains Camunda External Task Client written in Python3. 7 | 8 | 9 | Implement your [BPMN Service Task](https://docs.camunda.org/manual/latest/user-guide/process-engine/external-tasks/) in Python3. 10 | 11 | > Python >= 3.7 is required 12 | 13 | ## Installing 14 | Add following line to `requirements.txt` of your Python project. 15 | ``` 16 | git+https://github.com/trustfactors/camunda-external-task-client-python3.git/#egg=camunda-external-task-client-python3 17 | ``` 18 | 19 | Or use pip to install as shown below: 20 | ``` 21 | pip install camunda-external-task-client-python3 22 | ``` 23 | 24 | ## Running Camunda with Docker 25 | To run the examples provided in [examples](./examples) folder, you need to have Camunda running locally or somewhere. 26 | 27 | To run Camunda locally with Postgres DB as backend, you can use [docker-compose.yml](./docker-compose.yml) file. 28 | 29 | ``` 30 | $> docker-compose -f docker-compose.yml up 31 | ``` 32 | ### Auth Basic Examples 33 | 34 | To run the examples with Auth Basic provided in [examples/examples_auth_basic](./examples/examples_auth_basic) folder, you need to have Camunda with AuthBasic, running locally or somewhere. 35 | 36 | To run Camunda with AuthBasic locally with Postgres DB as backend, you can use [docker-compose-auth.yml](./docker-compose-auth.yml) file. 37 | 38 | ``` 39 | $> docker-compose -f docker-compose-auth.yml up 40 | ``` 41 | 42 | ## Usage 43 | 44 | 1. Make sure to have [Camunda](https://camunda.com/download/) running. 45 | 2. Create a simple process model with an External Service Task and define the topic as 'topicName'. 46 | 3. Deploy the process to the Camunda BPM engine. 47 | 4. In your Python code: 48 | 49 | ```python 50 | import time 51 | from camunda.external_task.external_task import ExternalTask, TaskResult 52 | from camunda.external_task.external_task_worker import ExternalTaskWorker 53 | 54 | # configuration for the Client 55 | default_config = { 56 | "maxTasks": 1, 57 | "lockDuration": 10000, 58 | "asyncResponseTimeout": 5000, 59 | "retries": 3, 60 | "retryTimeout": 5000, 61 | "sleepSeconds": 30 62 | } 63 | 64 | def handle_task(task: ExternalTask) -> TaskResult: 65 | """ 66 | This task handler you need to implement with your business logic. 67 | After completion of business logic call either task.complete() or task.failure() or task.bpmn_error() 68 | to report status of task to Camunda 69 | """ 70 | # add your business logic here 71 | # ... 72 | 73 | # mark task either complete/failure/bpmnError based on outcome of your business logic 74 | failure, bpmn_error = random_true(), random_true() # this code simulate random failure 75 | if failure: 76 | # this marks task as failed in Camunda 77 | return task.failure(error_message="task failed", error_details="failed task details", 78 | max_retries=3, retry_timeout=5000) 79 | elif bpmn_error: 80 | return task.bpmn_error(error_code="BPMN_ERROR_CODE", error_message="BPMN Error occurred", 81 | variables={"var1": "value1", "success": False}) 82 | 83 | # pass any output variables you may want to send to Camunda as dictionary to complete() 84 | return task.complete({"var1": 1, "var2": "value"}) 85 | 86 | def random_true(): 87 | current_milli_time = int(round(time.time() * 1000)) 88 | return current_milli_time % 2 == 0 89 | 90 | if __name__ == '__main__': 91 | ExternalTaskWorker(worker_id="1", config=default_config).subscribe("topicName", handle_task) 92 | ``` 93 | 94 | ## About External Tasks 95 | 96 | External Tasks are service tasks whose execution differs particularly from the execution of other service tasks (e.g. Human Tasks). 97 | The execution works in a way that units of work are polled from the engine before being completed. 98 | 99 | **camunda-external-task-client-python** allows you to create easily such client in Python3. 100 | 101 | ## Features 102 | 103 | ### [Start process](https://docs.camunda.org/manual/latest/reference/rest/process-definition/post-start-process-instance/) 104 | Camunda provides functionality to start a process instance for a given process definition. 105 | 106 | To start a process instance, we can use `start_process()` from [engine_client.py](./camunda/client/engine_client.py#L24) 107 | 108 | You can find a usage example [here](./examples/start_process.py). 109 | 110 | ```python 111 | client = EngineClient() 112 | resp_json = client.start_process(process_key="PARALLEL_STEPS_EXAMPLE", variables={"intVar": "1", "strVar": "hello"}, 113 | tenant_id="6172cdf0-7b32-4460-9da0-ded5107aa977", business_key=str(uuid.uuid1())) 114 | ``` 115 | 116 | ### [Fetch and Lock](https://docs.camunda.org/manual/latest/reference/rest/external-task/fetch/) 117 | 118 | `ExternalTaskWorker(worker_id="1").subscribe("topicName", handle_task)` starts long polling of the Camunda engine for external tasks. 119 | 120 | * Polling tasks from the engine works by performing a fetch & lock operation of tasks that have subscriptions. It then calls the handler function passed to `subscribe()` function. i.e. `handle_task` in above example. 121 | * Long Polling is done periodically based on the `asyncResponseTimeout` configuration. Read more about [Long Polling](https://docs.camunda.org/manual/latest/user-guide/process-engine/external-tasks/#long-polling-to-fetch-and-lock-external-tasks). 122 | 123 | ### [Complete](https://docs.camunda.org/manual/latest/reference/rest/external-task/post-complete/) 124 | ```python 125 | from camunda.external_task.external_task import ExternalTask, TaskResult 126 | from camunda.external_task.external_task_worker import ExternalTaskWorker 127 | def handle_task(task: ExternalTask) -> TaskResult: 128 | # add your business logic here 129 | 130 | # Complete the task 131 | # pass any output variables you may want to send to Camunda as dictionary to complete() 132 | return task.complete({"var1": 1, "var2": "value"}) 133 | 134 | ExternalTaskWorker(worker_id="1").subscribe("topicName", handle_task) 135 | ``` 136 | 137 | ### [Handle Failure](https://docs.camunda.org/manual/latest/reference/rest/external-task/post-failure/) 138 | ```python 139 | from camunda.external_task.external_task import ExternalTask, TaskResult 140 | from camunda.external_task.external_task_worker import ExternalTaskWorker 141 | def handle_task(task: ExternalTask) -> TaskResult: 142 | # add your business logic here 143 | 144 | # Handle task Failure 145 | return task.failure(error_message="task failed", error_details="failed task details", 146 | max_retries=3, retry_timeout=5000) 147 | # This client/worker uses max_retries if no retries are previously set in the task 148 | # if retries are previously set then it just decrements that count by one before reporting failure to Camunda 149 | # when retries are zero, Camunda creates an incident which then manually needs to be looked into on Camunda Cockpit 150 | 151 | ExternalTaskWorker(worker_id="1").subscribe("topicName", handle_task) 152 | ``` 153 | 154 | ### [Handle BPMN Error](https://docs.camunda.org/manual/latest/reference/rest/external-task/post-bpmn-error/) 155 | ```python 156 | from camunda.external_task.external_task import ExternalTask, TaskResult 157 | from camunda.external_task.external_task_worker import ExternalTaskWorker 158 | def handle_task(task: ExternalTask) -> TaskResult: 159 | # add your business logic here 160 | 161 | # Handle a BPMN Failure 162 | return task.bpmn_error(error_code="BPMN_ERROR", error_message="BPMN error occurred") 163 | 164 | ExternalTaskWorker(worker_id="1" ).subscribe("topicName", handle_task) 165 | ``` 166 | 167 | ### Access Process Variables 168 | ```python 169 | from camunda.external_task.external_task import ExternalTask, TaskResult 170 | from camunda.external_task.external_task_worker import ExternalTaskWorker 171 | def handle_task(task: ExternalTask) -> TaskResult: 172 | # add your business logic here 173 | # get the process variable 'score' 174 | score = task.get_variable("score") 175 | if int(score) >= 100: 176 | return task.complete(...) 177 | else: 178 | return task.failure(...) 179 | 180 | ExternalTaskWorker().subscribe("topicName", handle_task) 181 | ``` 182 | 183 | ### [Correlate message](https://docs.camunda.org/manual/7.13/reference/bpmn20/events/message-events/) 184 | Camunda provides functionality to send a message event to a running process instance. 185 | 186 | You can read more about the message events here: https://docs.camunda.org/manual/7.13/reference/bpmn20/events/message-events/ 187 | 188 | In our to send a message event to a process instance, a new function called `correlate_message()` is added to [engine_client.py](./camunda/client/engine_client.py#L60) 189 | 190 | We can correlate the message by: 191 | - process_instance_id 192 | - tenant_id 193 | - business_key 194 | - process_variables 195 | 196 | You can find a usage example [here](./examples/correlate_message.py). 197 | 198 | ```python 199 | client = EngineClient() 200 | resp_json = client.correlate_message("CANCEL_MESSAGE", business_key="b4a6f392-12ab-11eb-80ef-acde48001122") 201 | ``` 202 | ## AuthBasic Usage 203 | 204 | To create an EngineClient with AuthBasic simple 205 | 206 | ```python 207 | client = EngineClient(config={"auth_basic": {"username": "demo", "password": "demo"}}) 208 | resp_json = client.start_process(process_key="PARALLEL_STEPS_EXAMPLE", variables={"intVar": "1", "strVar": "hello"}, 209 | tenant_id="6172cdf0-7b32-4460-9da0-ded5107aa977", business_key=str(uuid.uuid1())) 210 | ``` 211 | 212 | To create an ExternalTaskWorker with AuthBasic simple 213 | 214 | ```python 215 | from camunda.external_task.external_task import ExternalTask, TaskResult 216 | from camunda.external_task.external_task_worker import ExternalTaskWorker 217 | 218 | config = {"auth_basic": {"username": "demo", "password": "demo"}} 219 | 220 | def handle_task(task: ExternalTask) -> TaskResult: 221 | # add your business logic here 222 | 223 | # Complete the task 224 | # pass any output variables you may want to send to Camunda as dictionary to complete() 225 | return task.complete({"var1": 1, "var2": "value"}) 226 | 227 | ExternalTaskWorker(worker_id="1", config=config).subscribe("topicName", handle_task) 228 | ``` 229 | 230 | ## License 231 | The source files in this repository are made available under the [Apache License Version 2.0](./LICENSE). 232 | -------------------------------------------------------------------------------- /bpmn_process/bpmn_error_example.bpmn: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Flow_17jp3ze 6 | 7 | 8 | 9 | Flow_17jp3ze 10 | Flow_0ztjtel 11 | 12 | 13 | Flow_01jyni4 14 | 15 | 16 | 17 | 18 | Flow_01jyni4 19 | Flow_19weksl 20 | 21 | 22 | 23 | Flow_19weksl 24 | 25 | 26 | 27 | Flow_0ztjtel 28 | Flow_0s54dcg 29 | 30 | 31 | Flow_0s54dcg 32 | 33 | 34 | 35 | Flow_05qyomt 36 | 37 | 38 | 39 | 40 | Flow_05qyomt 41 | Flow_0e22qy7 42 | 43 | 44 | Flow_0e22qy7 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | -------------------------------------------------------------------------------- /bpmn_process/correlate_message_example.bpmn: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Flow_1i98wn5 6 | 7 | 8 | 9 | Flow_1i98wn5 10 | Flow_04vqn7h 11 | 12 | 13 | 14 | Flow_04vqn7h 15 | Flow_0tnnj1f 16 | 17 | 18 | Flow_0tnnj1f 19 | 20 | 21 | 22 | 23 | Flow_0lk019x 24 | 25 | 26 | 27 | 28 | Flow_0lk019x 29 | Flow_1tefi39 30 | 31 | 32 | Flow_1tefi39 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /bpmn_process/event_subprocess_example.bpmn: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Flow_1gpbm89 6 | 7 | 8 | Flow_1npakp4 9 | Flow_1gpbm89 10 | 11 | 12 | Flow_1912jfr 13 | Flow_1npakp4 14 | 15 | 16 | Flow_1912jfr 17 | 18 | 19 | 20 | 21 | 22 | 23 | Flow_0jndz5x 24 | Flow_17xuk6e 25 | 26 | 27 | Flow_17xuk6e 28 | 29 | 30 | 31 | 32 | Flow_0jndz5x 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /bpmn_process/parallel_steps_example.bpmn: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Flow_19r583g 6 | 7 | 8 | 9 | Flow_19r583g 10 | Flow_0ozzjld 11 | Flow_058ezjl 12 | 13 | 14 | 15 | Flow_0ozzjld 16 | Flow_17mik9x 17 | 18 | 19 | 20 | Flow_058ezjl 21 | Flow_1qhzcn4 22 | 23 | 24 | Flow_1j9z2co 25 | Flow_0f0en1z 26 | 27 | 28 | Flow_0f0en1z 29 | 30 | 31 | 32 | 33 | Flow_17mik9x 34 | Flow_1qhzcn4 35 | Flow_1j9z2co 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | -------------------------------------------------------------------------------- /camunda/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/camunda-community-hub/camunda-external-task-client-python3/983d321f87de80f75f46dfb40b37973ee4731d41/camunda/__init__.py -------------------------------------------------------------------------------- /camunda/client/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/camunda-community-hub/camunda-external-task-client-python3/983d321f87de80f75f46dfb40b37973ee4731d41/camunda/client/__init__.py -------------------------------------------------------------------------------- /camunda/client/async_external_task_client.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from http import HTTPStatus 3 | 4 | import httpx 5 | 6 | from camunda.client.engine_client import ENGINE_LOCAL_BASE_URL 7 | from camunda.utils.log_utils import log_with_context 8 | from camunda.utils.response_utils import raise_exception_if_not_ok 9 | from camunda.utils.utils import str_to_list 10 | from camunda.utils.auth_basic import AuthBasic, obfuscate_password 11 | from camunda.utils.auth_bearer import AuthBearer 12 | from camunda.variables.variables import Variables 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | class AsyncExternalTaskClient: 18 | default_config = { 19 | "maxConcurrentTasks": 10, # Number of concurrent tasks you can process 20 | "lockDuration": 300000, # in milliseconds 21 | "asyncResponseTimeout": 30000, 22 | "retries": 3, 23 | "retryTimeout": 300000, 24 | "httpTimeoutMillis": 30000, 25 | "timeoutDeltaMillis": 5000, 26 | "includeExtensionProperties": True, # enables Camunda Extension Properties 27 | "deserializeValues": True, # deserialize values when fetch a task by default 28 | "usePriority": False, 29 | "sorting": None 30 | } 31 | 32 | def __init__(self, worker_id, engine_base_url=ENGINE_LOCAL_BASE_URL, config=None): 33 | config = config if config is not None else {} 34 | self.worker_id = worker_id 35 | self.external_task_base_url = engine_base_url + "/external-task" 36 | self.config = type(self).default_config.copy() 37 | self.config.update(config) 38 | self.is_debug = config.get('isDebug', False) 39 | self.http_timeout_seconds = self.config.get('httpTimeoutMillis') / 1000 40 | self._log_with_context(f"Created External Task client with config: {obfuscate_password(self.config)}") 41 | 42 | def get_fetch_and_lock_url(self): 43 | return f"{self.external_task_base_url}/fetchAndLock" 44 | 45 | async def fetch_and_lock(self, topic_names, process_variables=None, variables=None): 46 | url = self.get_fetch_and_lock_url() 47 | body = { 48 | "workerId": str(self.worker_id), # convert to string to make it JSON serializable 49 | "maxTasks": 1, 50 | "topics": self._get_topics(topic_names, process_variables, variables), 51 | "asyncResponseTimeout": self.config["asyncResponseTimeout"], 52 | "usePriority": self.config["usePriority"], 53 | "sorting": self.config["sorting"] 54 | } 55 | 56 | if self.is_debug: 57 | self._log_with_context(f"Trying to fetch and lock with request payload: {body}") 58 | http_timeout_seconds = self.__get_fetch_and_lock_http_timeout_seconds() 59 | 60 | async with httpx.AsyncClient() as client: 61 | response = await client.post(url, headers=self._get_headers(), json=body, timeout=http_timeout_seconds) 62 | raise_exception_if_not_ok(response) 63 | 64 | resp_json = response.json() 65 | if self.is_debug: 66 | self._log_with_context(f"Fetch and lock response JSON: {resp_json} for request: {body}") 67 | return resp_json 68 | 69 | def __get_fetch_and_lock_http_timeout_seconds(self): 70 | # Use HTTP timeout slightly more than async response / long polling timeout 71 | return (self.config["timeoutDeltaMillis"] + self.config["asyncResponseTimeout"]) / 1000 72 | 73 | def _get_topics(self, topic_names, process_variables, variables): 74 | topics = [] 75 | for topic in str_to_list(topic_names): 76 | topics.append({ 77 | "topicName": topic, 78 | "lockDuration": self.config["lockDuration"], 79 | "processVariables": process_variables if process_variables else {}, 80 | # Enables Camunda Extension Properties 81 | "includeExtensionProperties": self.config.get("includeExtensionProperties") or False, 82 | "deserializeValues": self.config["deserializeValues"], 83 | "variables": variables 84 | }) 85 | return topics 86 | 87 | async def complete(self, task_id, global_variables, local_variables=None): 88 | url = self.get_task_complete_url(task_id) 89 | 90 | body = { 91 | "workerId": self.worker_id, 92 | "variables": Variables.format(global_variables), 93 | "localVariables": Variables.format(local_variables) 94 | } 95 | 96 | async with httpx.AsyncClient() as client: 97 | response = await client.post(url, headers=self._get_headers(), json=body, timeout=self.http_timeout_seconds) 98 | raise_exception_if_not_ok(response) 99 | return response.status_code == HTTPStatus.NO_CONTENT 100 | 101 | def get_task_complete_url(self, task_id): 102 | return f"{self.external_task_base_url}/{task_id}/complete" 103 | 104 | async def failure(self, task_id, error_message, error_details, retries, retry_timeout): 105 | url = self.get_task_failure_url(task_id) 106 | logger.info(f"Setting retries to: {retries} for task: {task_id}") 107 | body = { 108 | "workerId": self.worker_id, 109 | "errorMessage": error_message, 110 | "retries": retries, 111 | "retryTimeout": retry_timeout, 112 | } 113 | if error_details: 114 | body["errorDetails"] = error_details 115 | 116 | async with httpx.AsyncClient() as client: 117 | response = await client.post(url, headers=self._get_headers(), json=body, timeout=self.http_timeout_seconds) 118 | raise_exception_if_not_ok(response) 119 | return response.status_code == HTTPStatus.NO_CONTENT 120 | 121 | def get_task_failure_url(self, task_id): 122 | return f"{self.external_task_base_url}/{task_id}/failure" 123 | 124 | async def bpmn_failure(self, task_id, error_code, error_message, variables=None): 125 | url = self.get_task_bpmn_error_url(task_id) 126 | 127 | body = { 128 | "workerId": self.worker_id, 129 | "errorCode": error_code, 130 | "errorMessage": error_message, 131 | "variables": Variables.format(variables), 132 | } 133 | 134 | if self.is_debug: 135 | self._log_with_context(f"Trying to report BPMN error with request payload: {body}") 136 | 137 | async with httpx.AsyncClient() as client: 138 | response = await client.post(url, headers=self._get_headers(), json=body, timeout=self.http_timeout_seconds) 139 | response.raise_for_status() 140 | return response.status_code == HTTPStatus.NO_CONTENT 141 | 142 | def get_task_bpmn_error_url(self, task_id): 143 | return f"{self.external_task_base_url}/{task_id}/bpmnError" 144 | 145 | @property 146 | def auth_basic(self) -> dict: 147 | if not self.config.get("auth_basic") or not isinstance(self.config.get("auth_basic"), dict): 148 | return {} 149 | token = AuthBasic(**self.config.get("auth_basic").copy()).token 150 | return {"Authorization": token} 151 | 152 | @property 153 | def auth_bearer(self) -> dict: 154 | if not self.config.get("auth_bearer") or not isinstance(self.config.get("auth_bearer"), dict): 155 | return {} 156 | token = AuthBearer(access_token=self.config["auth_bearer"]).access_token 157 | return {"Authorization": token} 158 | 159 | def _get_headers(self): 160 | headers = { 161 | "Content-Type": "application/json" 162 | } 163 | if self.auth_basic: 164 | headers.update(self.auth_basic) 165 | if self.auth_bearer: 166 | headers.update(self.auth_bearer) 167 | return headers 168 | 169 | def _log_with_context(self, msg, log_level='info', **kwargs): 170 | context = {"WORKER_ID": self.worker_id} 171 | log_with_context(msg, context=context, log_level=log_level, **kwargs) 172 | -------------------------------------------------------------------------------- /camunda/client/engine_client.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import logging 3 | from http import HTTPStatus 4 | 5 | import requests 6 | 7 | from camunda.utils.response_utils import raise_exception_if_not_ok 8 | from camunda.utils.utils import join 9 | from camunda.utils.auth_basic import AuthBasic 10 | from camunda.utils.auth_bearer import AuthBearer 11 | from camunda.variables.variables import Variables 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | ENGINE_LOCAL_BASE_URL = "http://localhost:8080/engine-rest" 16 | 17 | 18 | class EngineClient: 19 | 20 | def __init__(self, engine_base_url=ENGINE_LOCAL_BASE_URL, config=None): 21 | config = config if config is not None else {} 22 | self.config = config.copy() 23 | self.engine_base_url = engine_base_url 24 | 25 | def get_start_process_instance_url(self, process_key, tenant_id=None): 26 | if tenant_id: 27 | return f"{self.engine_base_url}/process-definition/key/{process_key}/tenant-id/{tenant_id}/start" 28 | return f"{self.engine_base_url}/process-definition/key/{process_key}/start" 29 | 30 | def start_process(self, process_key, variables, tenant_id=None, business_key=None): 31 | """ 32 | Start a process instance with the process_key and variables passed. 33 | :param process_key: Mandatory 34 | :param variables: Mandatory - can be empty dict 35 | :param tenant_id: Optional 36 | :param business_key: Optional 37 | :return: response json 38 | """ 39 | url = self.get_start_process_instance_url(process_key, tenant_id) 40 | body = { 41 | "variables": Variables.format(variables) 42 | } 43 | if business_key: 44 | body["businessKey"] = business_key 45 | 46 | response = requests.post(url, headers=self._get_headers(), json=body) 47 | raise_exception_if_not_ok(response) 48 | return response.json() 49 | 50 | def get_process_instance(self, process_key=None, variables=frozenset([]), tenant_ids=frozenset([])): 51 | url = f"{self.engine_base_url}/process-instance" 52 | url_params = self.__get_process_instance_url_params(process_key, tenant_ids, variables) 53 | response = requests.get(url, headers=self._get_headers(), params=url_params) 54 | raise_exception_if_not_ok(response) 55 | return response.json() 56 | 57 | @staticmethod 58 | def __get_process_instance_url_params(process_key, tenant_ids, variables): 59 | url_params = {} 60 | if process_key: 61 | url_params["processDefinitionKey"] = process_key 62 | var_filter = join(variables, ',') 63 | if var_filter: 64 | url_params["variables"] = var_filter 65 | tenant_ids_filter = join(tenant_ids, ',') 66 | if tenant_ids_filter: 67 | url_params["tenantIdIn"] = tenant_ids_filter 68 | return url_params 69 | 70 | @property 71 | def auth_basic(self) -> dict: 72 | if not self.config.get("auth_basic") or not isinstance(self.config.get("auth_basic"), dict): 73 | return {} 74 | token = AuthBasic(**self.config.get("auth_basic").copy()).token 75 | return {"Authorization": token} 76 | 77 | @property 78 | def auth_bearer(self) -> dict: 79 | if not self.config.get("auth_bearer") or not isinstance(self.config.get("auth_bearer"), dict): 80 | return {} 81 | token = AuthBearer(access_token=self.config["auth_bearer"]).access_token 82 | return {"Authorization": token} 83 | 84 | def _get_headers(self): 85 | headers = { 86 | "Content-Type": "application/json" 87 | } 88 | if self.auth_basic: 89 | headers.update(self.auth_basic) 90 | if self.auth_bearer: 91 | headers.update(self.auth_bearer) 92 | return headers 93 | 94 | def correlate_message(self, message_name, process_instance_id=None, tenant_id=None, business_key=None, 95 | process_variables=None): 96 | """ 97 | Correlates a message to the process engine to either trigger a message start event or 98 | an intermediate message catching event. 99 | :param message_name: 100 | :param process_instance_id: 101 | :param tenant_id: 102 | :param business_key: 103 | :param process_variables: 104 | :return: response json 105 | """ 106 | url = f"{self.engine_base_url}/message" 107 | body = { 108 | "messageName": message_name, 109 | "resultEnabled": True, 110 | "processVariables": Variables.format(process_variables) if process_variables else None, 111 | "processInstanceId": process_instance_id, 112 | "tenantId": tenant_id, 113 | "withoutTenantId": not tenant_id, 114 | "businessKey": business_key, 115 | } 116 | 117 | if process_instance_id: 118 | body.pop("tenantId") 119 | body.pop("withoutTenantId") 120 | 121 | body = {k: v for k, v in body.items() if v is not None} 122 | 123 | response = requests.post(url, headers=self._get_headers(), json=body) 124 | raise_exception_if_not_ok(response) 125 | return response.json() 126 | 127 | def get_jobs(self, 128 | offset: int, 129 | limit: int, 130 | tenant_ids=None, 131 | with_failure=None, 132 | process_instance_id=None, 133 | task_name=None, 134 | sort_by="jobDueDate", 135 | sort_order="desc"): 136 | # offset starts with zero 137 | # sort_order can be "asc" or "desc 138 | 139 | url = f"{self.engine_base_url}/job" 140 | params = { 141 | "firstResult": offset, 142 | "maxResults": limit, 143 | "sortBy": sort_by, 144 | "sortOrder": sort_order, 145 | } 146 | if process_instance_id: 147 | params["processInstanceId"] = process_instance_id 148 | if task_name: 149 | params["failedActivityId"] = task_name 150 | if with_failure: 151 | params["withException"] = "true" 152 | if tenant_ids: 153 | params["tenantIdIn"] = ','.join(tenant_ids) 154 | response = requests.get(url, params=params, headers=self._get_headers()) 155 | raise_exception_if_not_ok(response) 156 | return response.json() 157 | 158 | def set_job_retry(self, job_id, retries=1): 159 | url = f"{self.engine_base_url}/job/{job_id}/retries" 160 | body = {"retries": retries} 161 | 162 | response = requests.put(url, headers=self._get_headers(), json=body) 163 | raise_exception_if_not_ok(response) 164 | return response.status_code == HTTPStatus.NO_CONTENT 165 | 166 | def get_process_instance_variable(self, process_instance_id, variable_name, with_meta=False): 167 | url = f"{self.engine_base_url}/process-instance/{process_instance_id}/variables/{variable_name}" 168 | response = requests.get(url, headers=self._get_headers()) 169 | raise_exception_if_not_ok(response) 170 | resp_json = response.json() 171 | 172 | url_with_data = f"{url}/data" 173 | response = requests.get(url_with_data, headers=self._get_headers()) 174 | raise_exception_if_not_ok(response) 175 | 176 | decoded_value = base64.encodebytes(response.content).decode("utf-8") 177 | 178 | if with_meta: 179 | return dict(resp_json, value=decoded_value) 180 | return decoded_value 181 | -------------------------------------------------------------------------------- /camunda/client/external_task_client.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from http import HTTPStatus 3 | 4 | import requests 5 | 6 | from camunda.client.engine_client import ENGINE_LOCAL_BASE_URL 7 | from camunda.utils.log_utils import log_with_context 8 | from camunda.utils.response_utils import raise_exception_if_not_ok 9 | from camunda.utils.utils import str_to_list 10 | from camunda.utils.auth_basic import AuthBasic, obfuscate_password 11 | from camunda.utils.auth_bearer import AuthBearer 12 | from camunda.variables.variables import Variables 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | class ExternalTaskClient: 18 | default_config = { 19 | "maxTasks": 1, 20 | "lockDuration": 300000, # in milliseconds 21 | "asyncResponseTimeout": 30000, 22 | "retries": 3, 23 | "retryTimeout": 300000, 24 | "httpTimeoutMillis": 30000, 25 | "timeoutDeltaMillis": 5000, 26 | "includeExtensionProperties": True, # enables Camunda Extension Properties 27 | "deserializeValues": True, # deserialize values when fetch a task by default 28 | "usePriority": False, 29 | "sorting": None 30 | } 31 | 32 | def __init__(self, worker_id, engine_base_url=ENGINE_LOCAL_BASE_URL, config=None): 33 | config = config if config is not None else {} 34 | self.worker_id = worker_id 35 | self.external_task_base_url = engine_base_url + "/external-task" 36 | self.config = type(self).default_config.copy() 37 | self.config.update(config) 38 | self.is_debug = config.get('isDebug', False) 39 | self.http_timeout_seconds = self.config.get('httpTimeoutMillis') / 1000 40 | self._log_with_context(f"Created External Task client with config: {obfuscate_password(self.config)}") 41 | 42 | def get_fetch_and_lock_url(self): 43 | return f"{self.external_task_base_url}/fetchAndLock" 44 | 45 | def fetch_and_lock(self, topic_names, process_variables=None, variables=None): 46 | url = self.get_fetch_and_lock_url() 47 | body = { 48 | "workerId": str(self.worker_id), # convert to string to make it JSON serializable 49 | "maxTasks": self.config["maxTasks"], 50 | "topics": self._get_topics(topic_names, process_variables, variables), 51 | "asyncResponseTimeout": self.config["asyncResponseTimeout"], 52 | "usePriority": self.config["usePriority"], 53 | "sorting": self.config["sorting"] 54 | } 55 | 56 | if self.is_debug: 57 | self._log_with_context(f"trying to fetch and lock with request payload: {body}") 58 | http_timeout_seconds = self.__get_fetch_and_lock_http_timeout_seconds() 59 | response = requests.post(url, headers=self._get_headers(), json=body, timeout=http_timeout_seconds) 60 | raise_exception_if_not_ok(response) 61 | 62 | resp_json = response.json() 63 | if self.is_debug: 64 | self._log_with_context(f"fetch and lock response json: {resp_json} for request: {body}") 65 | return response.json() 66 | 67 | def __get_fetch_and_lock_http_timeout_seconds(self): 68 | # use HTTP timeout slightly more than async Response / long polling timeout 69 | return (self.config["timeoutDeltaMillis"] + self.config["asyncResponseTimeout"]) / 1000 70 | 71 | def _get_topics(self, topic_names, process_variables, variables): 72 | topics = [] 73 | for topic in str_to_list(topic_names): 74 | topics.append({ 75 | "topicName": topic, 76 | "lockDuration": self.config["lockDuration"], 77 | "processVariables": process_variables if process_variables else {}, 78 | # enables Camunda Extension Properties 79 | "includeExtensionProperties": self.config.get("includeExtensionProperties") or False, 80 | "deserializeValues": self.config["deserializeValues"], 81 | "variables": variables 82 | }) 83 | return topics 84 | 85 | def complete(self, task_id, global_variables, local_variables=None): 86 | url = self.get_task_complete_url(task_id) 87 | 88 | body = { 89 | "workerId": self.worker_id, 90 | "variables": Variables.format(global_variables), 91 | "localVariables": Variables.format(local_variables) 92 | } 93 | 94 | response = requests.post(url, headers=self._get_headers(), json=body, timeout=self.http_timeout_seconds) 95 | raise_exception_if_not_ok(response) 96 | return response.status_code == HTTPStatus.NO_CONTENT 97 | 98 | def get_task_complete_url(self, task_id): 99 | return f"{self.external_task_base_url}/{task_id}/complete" 100 | 101 | def failure(self, task_id, error_message, error_details, retries, retry_timeout): 102 | url = self.get_task_failure_url(task_id) 103 | logger.info(f"setting retries to: {retries} for task: {task_id}") 104 | body = { 105 | "workerId": self.worker_id, 106 | "errorMessage": error_message, 107 | "retries": retries, 108 | "retryTimeout": retry_timeout, 109 | } 110 | if error_details: 111 | body["errorDetails"] = error_details 112 | 113 | response = requests.post(url, headers=self._get_headers(), json=body, timeout=self.http_timeout_seconds) 114 | raise_exception_if_not_ok(response) 115 | return response.status_code == HTTPStatus.NO_CONTENT 116 | 117 | def get_task_failure_url(self, task_id): 118 | return f"{self.external_task_base_url}/{task_id}/failure" 119 | 120 | def bpmn_failure(self, task_id, error_code, error_message, variables=None): 121 | url = self.get_task_bpmn_error_url(task_id) 122 | 123 | body = { 124 | "workerId": self.worker_id, 125 | "errorCode": error_code, 126 | "errorMessage": error_message, 127 | "variables": Variables.format(variables), 128 | } 129 | 130 | if self.is_debug: 131 | self._log_with_context(f"trying to report bpmn error with request payload: {body}") 132 | 133 | resp = requests.post(url, headers=self._get_headers(), json=body, timeout=self.http_timeout_seconds) 134 | resp.raise_for_status() 135 | return resp.status_code == HTTPStatus.NO_CONTENT 136 | 137 | def get_task_bpmn_error_url(self, task_id): 138 | return f"{self.external_task_base_url}/{task_id}/bpmnError" 139 | 140 | @property 141 | def auth_basic(self) -> dict: 142 | if not self.config.get("auth_basic") or not isinstance(self.config.get("auth_basic"), dict): 143 | return {} 144 | token = AuthBasic(**self.config.get("auth_basic").copy()).token 145 | return {"Authorization": token} 146 | 147 | @property 148 | def auth_bearer(self) -> dict: 149 | if not self.config.get("auth_bearer") or not isinstance(self.config.get("auth_bearer"), dict): 150 | return {} 151 | token = AuthBearer(access_token=self.config["auth_bearer"]).access_token 152 | return {"Authorization": token} 153 | 154 | def _get_headers(self): 155 | headers = { 156 | "Content-Type": "application/json" 157 | } 158 | if self.auth_basic: 159 | headers.update(self.auth_basic) 160 | if self.auth_bearer: 161 | headers.update(self.auth_bearer) 162 | return headers 163 | 164 | def _log_with_context(self, msg, log_level='info', **kwargs): 165 | context = {"WORKER_ID": self.worker_id} 166 | log_with_context(msg, context=context, log_level=log_level, **kwargs) 167 | -------------------------------------------------------------------------------- /camunda/client/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/camunda-community-hub/camunda-external-task-client-python3/983d321f87de80f75f46dfb40b37973ee4731d41/camunda/client/tests/__init__.py -------------------------------------------------------------------------------- /camunda/client/tests/test_async_external_task_client.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from http import HTTPStatus 3 | from unittest.mock import patch, AsyncMock 4 | 5 | import httpx 6 | 7 | # Adjust the import based on your actual module path 8 | from camunda.client.async_external_task_client import AsyncExternalTaskClient, ENGINE_LOCAL_BASE_URL 9 | 10 | 11 | class AsyncExternalTaskClientTest(unittest.IsolatedAsyncioTestCase): 12 | """ 13 | Tests for async_external_task_client.py 14 | """ 15 | 16 | def setUp(self): 17 | # Common setup if needed 18 | self.default_worker_id = 1 19 | self.default_engine_url = ENGINE_LOCAL_BASE_URL 20 | 21 | async def test_creation_with_no_debug_config(self): 22 | client = AsyncExternalTaskClient(self.default_worker_id, self.default_engine_url, {}) 23 | self.assertFalse(client.is_debug) 24 | self.assertFalse(client.config.get("isDebug")) 25 | # Check default_config merges: 26 | self.assertEqual(client.config["maxConcurrentTasks"], 10) 27 | self.assertEqual(client.config["lockDuration"], 300000) 28 | 29 | async def test_creation_with_debug_config(self): 30 | client = AsyncExternalTaskClient(self.default_worker_id, self.default_engine_url, {"isDebug": True}) 31 | self.assertTrue(client.is_debug) 32 | self.assertTrue(client.config.get("isDebug")) 33 | 34 | @patch("httpx.AsyncClient.post") 35 | async def test_fetch_and_lock_success(self, mock_post): 36 | # Provide actual JSON as bytes 37 | content = b'[{"id": "someExternalTaskId", "topicName": "topicA"}]' 38 | mock_post.return_value = httpx.Response( 39 | status_code=200, 40 | request=httpx.Request("POST", "http://example.com"), 41 | content=content 42 | ) 43 | 44 | client = AsyncExternalTaskClient(self.default_worker_id, self.default_engine_url, {}) 45 | # Perform call 46 | tasks = await client.fetch_and_lock("topicA") 47 | 48 | # Assertions 49 | expected_url = f"{ENGINE_LOCAL_BASE_URL}/external-task/fetchAndLock" 50 | self.assertEqual([{"id": "someExternalTaskId", "topicName": "topicA"}], tasks) 51 | mock_post.assert_awaited_once() # Check post was awaited exactly once 52 | args, kwargs = mock_post.call_args 53 | self.assertEqual(expected_url, args[0], "Expected correct fetchAndLock endpoint URL") 54 | # You could also check the payload or headers here: 55 | self.assertIn("json", kwargs) 56 | self.assertEqual(kwargs["json"]["workerId"], "1") # str(worker_id) 57 | 58 | @patch("httpx.AsyncClient.post") 59 | async def test_fetch_and_lock_server_error(self, mock_post): 60 | # Create a real httpx.Response with status=500 61 | server_err_resp = httpx.Response( 62 | status_code=HTTPStatus.INTERNAL_SERVER_ERROR, 63 | request=httpx.Request("POST", "http://example.com/external-task/fetchAndLock"), 64 | content=b"Internal Server Error" 65 | ) 66 | # Each call to mock_post() returns this real response object 67 | mock_post.return_value = server_err_resp 68 | 69 | client = AsyncExternalTaskClient(self.default_worker_id, self.default_engine_url, {}) 70 | 71 | # Now we expect an exception 72 | with self.assertRaises(httpx.HTTPStatusError) as ctx: 73 | await client.fetch_and_lock("topicA") 74 | 75 | # Optional: confirm the error message 76 | self.assertIn("500 Internal Server Error", str(ctx.exception)) 77 | 78 | @patch("httpx.AsyncClient.post", new_callable=AsyncMock) 79 | async def test_complete_success(self, mock_post): 80 | mock_post.return_value.status_code = HTTPStatus.NO_CONTENT 81 | 82 | client = AsyncExternalTaskClient(self.default_worker_id, self.default_engine_url, {}) 83 | result = await client.complete("myTaskId", {"globalVar": 1}) 84 | 85 | self.assertTrue(result) 86 | mock_post.assert_awaited_once() 87 | complete_url = f"{ENGINE_LOCAL_BASE_URL}/external-task/myTaskId/complete" 88 | self.assertEqual(complete_url, mock_post.call_args[0][0]) 89 | 90 | @patch("httpx.AsyncClient.post", new_callable=AsyncMock) 91 | async def test_failure_with_error_details(self, mock_post): 92 | mock_post.return_value.status_code = HTTPStatus.NO_CONTENT 93 | 94 | client = AsyncExternalTaskClient(self.default_worker_id, self.default_engine_url, {}) 95 | result = await client.failure( 96 | task_id="myTaskId", 97 | error_message="some error", 98 | error_details="stacktrace info", 99 | retries=3, 100 | retry_timeout=10000 101 | ) 102 | 103 | self.assertTrue(result) 104 | mock_post.assert_awaited_once() 105 | failure_url = f"{ENGINE_LOCAL_BASE_URL}/external-task/myTaskId/failure" 106 | self.assertEqual(failure_url, mock_post.call_args[0][0]) 107 | self.assertIn("errorDetails", mock_post.call_args[1]["json"]) 108 | 109 | @patch("httpx.AsyncClient.post", new_callable=AsyncMock) 110 | async def test_bpmn_failure_success(self, mock_post): 111 | mock_post.return_value.status_code = HTTPStatus.NO_CONTENT 112 | 113 | client = AsyncExternalTaskClient(self.default_worker_id, self.default_engine_url, {"isDebug": True}) 114 | result = await client.bpmn_failure( 115 | task_id="myTaskId", 116 | error_code="BPMN_ERROR", 117 | error_message="an example BPMN error", 118 | variables={"foo": "bar"} 119 | ) 120 | 121 | self.assertTrue(result) 122 | mock_post.assert_awaited_once() 123 | bpmn_url = f"{ENGINE_LOCAL_BASE_URL}/external-task/myTaskId/bpmnError" 124 | args, kwargs = mock_post.call_args 125 | self.assertEqual(bpmn_url, args[0]) 126 | self.assertEqual(kwargs["json"]["errorCode"], "BPMN_ERROR") 127 | self.assertTrue(client.is_debug) # Confirm the debug flag is set 128 | 129 | -------------------------------------------------------------------------------- /camunda/client/tests/test_async_external_task_client_auth.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from http import HTTPStatus 3 | from unittest.mock import AsyncMock, patch 4 | 5 | from camunda.client.async_external_task_client import AsyncExternalTaskClient 6 | from camunda.client.engine_client import ENGINE_LOCAL_BASE_URL 7 | 8 | 9 | class AsyncExternalTaskClientAuthTest(unittest.IsolatedAsyncioTestCase): 10 | async def test_auth_basic_fetch_and_lock_no_debug(self): 11 | with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post: 12 | mock_post.return_value.status_code = HTTPStatus.OK 13 | mock_post.return_value.json.return_value = [] 14 | 15 | client = AsyncExternalTaskClient( 16 | 1, 17 | ENGINE_LOCAL_BASE_URL, 18 | {"auth_basic": {"username": "demo", "password": "demo"}} 19 | ) 20 | await client.fetch_and_lock("someTopic") 21 | 22 | # Confirm "Authorization" header is present 23 | headers_used = mock_post.call_args[1]["headers"] 24 | self.assertIn("Authorization", headers_used) 25 | self.assertTrue(headers_used["Authorization"].startswith("Basic ")) 26 | 27 | async def test_auth_basic_fetch_and_lock_with_debug(self): 28 | with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post: 29 | mock_post.return_value.status_code = HTTPStatus.OK 30 | mock_post.return_value.json.return_value = [] 31 | 32 | client = AsyncExternalTaskClient( 33 | 1, 34 | ENGINE_LOCAL_BASE_URL, 35 | {"auth_basic": {"username": "demo", "password": "demo"}, "isDebug": True} 36 | ) 37 | await client.fetch_and_lock("someTopic") 38 | 39 | # Confirm "Authorization" header is present 40 | headers_used = mock_post.call_args[1]["headers"] 41 | self.assertIn("Authorization", headers_used) 42 | self.assertTrue(headers_used["Authorization"].startswith("Basic ")) -------------------------------------------------------------------------------- /camunda/client/tests/test_async_external_task_client_bearer.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from http import HTTPStatus 3 | from unittest.mock import patch, AsyncMock 4 | 5 | from camunda.client.async_external_task_client import AsyncExternalTaskClient 6 | from camunda.client.engine_client import ENGINE_LOCAL_BASE_URL 7 | 8 | 9 | class AsyncExternalTaskClientAuthTest(unittest.IsolatedAsyncioTestCase): 10 | 11 | async def test_auth_bearer_fetch_and_lock_no_debug(self): 12 | token = "some.super.long.jwt" 13 | with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post: 14 | mock_post.return_value.status_code = HTTPStatus.OK 15 | mock_post.return_value.json.return_value = [] 16 | 17 | client = AsyncExternalTaskClient( 18 | 1, 19 | ENGINE_LOCAL_BASE_URL, 20 | {"auth_bearer": {"access_token": token}} 21 | ) 22 | await client.fetch_and_lock("someTopic") 23 | 24 | headers_used = mock_post.call_args[1]["headers"] 25 | self.assertIn("Authorization", headers_used) 26 | self.assertEqual(f"Bearer {token}", headers_used["Authorization"]) 27 | 28 | async def test_auth_bearer_fetch_and_lock_with_debug(self): 29 | token = "some.super.long.jwt" 30 | with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post: 31 | mock_post.return_value.status_code = HTTPStatus.OK 32 | mock_post.return_value.json.return_value = [] 33 | 34 | client = AsyncExternalTaskClient( 35 | 1, 36 | ENGINE_LOCAL_BASE_URL, 37 | {"auth_bearer": {"access_token": token}, "isDebug": True} 38 | ) 39 | await client.fetch_and_lock("someTopic") 40 | 41 | headers_used = mock_post.call_args[1]["headers"] 42 | self.assertIn("Authorization", headers_used) 43 | self.assertEqual(f"Bearer {token}", headers_used["Authorization"]) -------------------------------------------------------------------------------- /camunda/client/tests/test_engine_client.py: -------------------------------------------------------------------------------- 1 | import base64 2 | from http import HTTPStatus 3 | from unittest import TestCase 4 | from unittest.mock import patch 5 | 6 | import responses 7 | 8 | from camunda.client.engine_client import EngineClient, ENGINE_LOCAL_BASE_URL 9 | 10 | 11 | class EngineClientTest(TestCase): 12 | tenant_id = "6172cdf0-7b32-4460-9da0-ded5107aa977" 13 | process_key = "PARALLEL_STEPS_EXAMPLE" 14 | 15 | def setUp(self): 16 | self.client = EngineClient() 17 | 18 | @responses.activate 19 | def test_start_process_success(self): 20 | resp_payload = { 21 | "links": [ 22 | { 23 | "method": "GET", 24 | "href": "http://localhost:8080/engine-rest/process-instance/cb678be8-9b93-11ea-bad9-0242ac110002", 25 | "rel": "self" 26 | } 27 | ], 28 | "id": "cb678be8-9b93-11ea-bad9-0242ac110002", 29 | "definitionId": "PARALLEL_STEPS_EXAMPLE:1:9b72da83-9b91-11ea-bad9-0242ac110002", 30 | "businessKey": "123456", 31 | "caseInstanceId": None, 32 | "ended": False, 33 | "suspended": False, 34 | "tenantId": None 35 | } 36 | responses.add(responses.POST, self.client.get_start_process_instance_url(self.process_key, self.tenant_id), 37 | json=resp_payload, status=HTTPStatus.OK) 38 | actual_resp_payload = self.client.start_process(self.process_key, {}, self.tenant_id, "123456") 39 | self.assertDictEqual(resp_payload, actual_resp_payload) 40 | 41 | @responses.activate 42 | def test_start_process_not_found_raises_exception(self): 43 | resp_payload = { 44 | "type": "RestException", 45 | "message": "No matching process definition with key: PROCESS_KEY_NOT_EXISTS and tenant-id: tenant_123" 46 | } 47 | responses.add(responses.POST, 48 | self.client.get_start_process_instance_url("PROCESS_KEY_NOT_EXISTS", self.tenant_id), 49 | status=HTTPStatus.NOT_FOUND, json=resp_payload) 50 | with self.assertRaises(Exception) as exception_ctx: 51 | self.client.start_process("PROCESS_KEY_NOT_EXISTS", {}, self.tenant_id) 52 | 53 | self.assertEqual("received 404 : RestException : " 54 | "No matching process definition with key: PROCESS_KEY_NOT_EXISTS and tenant-id: tenant_123", 55 | str(exception_ctx.exception)) 56 | 57 | @responses.activate 58 | def test_start_process_bad_request_raises_exception(self): 59 | client = EngineClient() 60 | expected_message = "Cannot instantiate process definition " \ 61 | "PARALLEL_STEPS_EXAMPLE:1:9b72da83-9b91-11ea-bad9-0242ac110002: " \ 62 | "Cannot convert value '1aa2345' of type 'Integer' to java type java.lang.Integer" 63 | resp_payload = { 64 | "type": "InvalidRequestException", 65 | "message": expected_message 66 | } 67 | responses.add(responses.POST, client.get_start_process_instance_url(self.process_key, self.tenant_id), 68 | status=HTTPStatus.BAD_REQUEST, json=resp_payload) 69 | with self.assertRaises(Exception) as exception_ctx: 70 | client.start_process(self.process_key, {"int_var": "1aa2345"}, self.tenant_id) 71 | 72 | self.assertEqual(f"received 400 : InvalidRequestException : {expected_message}", str(exception_ctx.exception)) 73 | 74 | @responses.activate 75 | def test_start_process_server_error_raises_exception(self): 76 | responses.add(responses.POST, self.client.get_start_process_instance_url(self.process_key, self.tenant_id), 77 | status=HTTPStatus.INTERNAL_SERVER_ERROR) 78 | with self.assertRaises(Exception) as exception_ctx: 79 | self.client.start_process(self.process_key, {"int_var": "1aa2345"}, self.tenant_id) 80 | 81 | self.assertEqual(HTTPStatus.INTERNAL_SERVER_ERROR, exception_ctx.exception.response.status_code) 82 | self.assertIn("Server Error: Internal Server Error", str(exception_ctx.exception)) 83 | 84 | @responses.activate 85 | def test_get_process_instance_success(self): 86 | resp_payload = [ 87 | { 88 | "links": [], 89 | "id": "c2c68785-9f42-11ea-a841-0242ac1c0004", 90 | "definitionId": "PARALLEL_STEPS_EXAMPLE:1:88613042-9f42-11ea-a841-0242ac1c0004", 91 | "businessKey": None, 92 | "caseInstanceId": None, 93 | "ended": False, 94 | "suspended": False, 95 | "tenantId": self.tenant_id 96 | } 97 | ] 98 | get_process_instance_url = f"{ENGINE_LOCAL_BASE_URL}/process-instance" \ 99 | f"?processDefinitionKey={self.process_key}" \ 100 | f"&tenantIdIn={self.tenant_id}" \ 101 | f"&variables=intVar_eq_1,strVar_eq_hello" 102 | responses.add(responses.GET, get_process_instance_url, status=HTTPStatus.OK, json=resp_payload) 103 | actual_resp_payload = self.client.get_process_instance(process_key=self.process_key, 104 | variables=["intVar_eq_1", "strVar_eq_hello"], 105 | tenant_ids=[self.tenant_id]) 106 | self.assertListEqual(resp_payload, actual_resp_payload) 107 | 108 | @responses.activate 109 | def test_get_process_instance_bad_request_raises_exception(self): 110 | expected_message = "Invalid variable comparator specified: XXX" 111 | resp_payload = { 112 | "type": "InvalidRequestException", 113 | "message": expected_message 114 | } 115 | get_process_instance_url = f"{ENGINE_LOCAL_BASE_URL}/process-instance" \ 116 | f"?processDefinitionKey={self.process_key}" \ 117 | f"&tenantIdIn={self.tenant_id}" \ 118 | f"&variables=intVar_XXX_1,strVar_eq_hello" 119 | responses.add(responses.GET, get_process_instance_url, status=HTTPStatus.BAD_REQUEST, json=resp_payload) 120 | with self.assertRaises(Exception) as exception_ctx: 121 | self.client.get_process_instance(process_key=self.process_key, 122 | variables=["intVar_XXX_1", "strVar_eq_hello"], 123 | tenant_ids=[self.tenant_id]) 124 | 125 | self.assertEqual(f"received 400 : InvalidRequestException : {expected_message}", str(exception_ctx.exception)) 126 | 127 | @responses.activate 128 | def test_get_process_instance_server_error_raises_exception(self): 129 | get_process_instance_url = f"{ENGINE_LOCAL_BASE_URL}/process-instance" \ 130 | f"?processDefinitionKey={self.process_key}" \ 131 | f"&tenantIdIn={self.tenant_id}" \ 132 | f"&variables=intVar_XXX_1,strVar_eq_hello" 133 | responses.add(responses.GET, get_process_instance_url, status=HTTPStatus.INTERNAL_SERVER_ERROR) 134 | with self.assertRaises(Exception) as exception_ctx: 135 | self.client.get_process_instance(process_key=self.process_key, 136 | variables=["intVar_XXX_1", "strVar_eq_hello"], 137 | tenant_ids=[self.tenant_id]) 138 | 139 | self.assertEqual(HTTPStatus.INTERNAL_SERVER_ERROR, exception_ctx.exception.response.status_code) 140 | self.assertIn("Server Error: Internal Server Error", str(exception_ctx.exception)) 141 | 142 | @patch('requests.post') 143 | def test_correlate_message_with_only_message_name(self, mock_post): 144 | expected_request_payload = { 145 | "messageName": "CANCEL_MESSAGE", 146 | "withoutTenantId": True, 147 | "resultEnabled": True 148 | } 149 | 150 | self.client.correlate_message("CANCEL_MESSAGE") 151 | mock_post.assert_called_with(ENGINE_LOCAL_BASE_URL + "/message", 152 | json=expected_request_payload, 153 | headers={'Content-Type': 'application/json'}) 154 | 155 | @patch('requests.post') 156 | def test_correlate_message_with_business_key(self, mock_post): 157 | expected_request_payload = { 158 | "messageName": "CANCEL_MESSAGE", 159 | "withoutTenantId": True, 160 | "businessKey": "123456", 161 | "resultEnabled": True 162 | } 163 | 164 | self.client.correlate_message("CANCEL_MESSAGE", business_key="123456") 165 | mock_post.assert_called_with(ENGINE_LOCAL_BASE_URL + "/message", 166 | json=expected_request_payload, 167 | headers={'Content-Type': 'application/json'}) 168 | 169 | @patch('requests.post') 170 | def test_correlate_message_with_tenant_id(self, mock_post): 171 | expected_request_payload = { 172 | "messageName": "CANCEL_MESSAGE", 173 | "withoutTenantId": False, 174 | "tenantId": "123456", 175 | "resultEnabled": True 176 | } 177 | 178 | self.client.correlate_message("CANCEL_MESSAGE", tenant_id="123456") 179 | mock_post.assert_called_with(ENGINE_LOCAL_BASE_URL + "/message", 180 | json=expected_request_payload, 181 | headers={'Content-Type': 'application/json'}) 182 | 183 | @responses.activate 184 | def test_correlate_message_invalid_message_name_raises_exception(self): 185 | expected_message = "org.camunda.bpm.engine.MismatchingMessageCorrelationException: " \ 186 | "Cannot correlate message 'XXX': No process definition or execution matches the parameters" 187 | resp_payload = { 188 | "type": "RestException", 189 | "message": expected_message 190 | } 191 | correlate_msg_url = f"{ENGINE_LOCAL_BASE_URL}/message" 192 | responses.add(responses.POST, correlate_msg_url, status=HTTPStatus.BAD_REQUEST, json=resp_payload) 193 | with self.assertRaises(Exception) as exception_ctx: 194 | self.client.correlate_message(message_name="XXX") 195 | 196 | self.assertEqual(f"received 400 : RestException : {expected_message}", str(exception_ctx.exception)) 197 | 198 | @responses.activate 199 | def test_get_process_instance_variable_without_meta(self): 200 | process_instance_id = "c2c68785-9f42-11ea-a841-0242ac1c0004" 201 | variable_name = "var1" 202 | process_instance_var_url = \ 203 | f"{ENGINE_LOCAL_BASE_URL}/process-instance/{process_instance_id}/variables/{variable_name}" 204 | resp_frame_payload = {"value": None, "valueInfo": {}, "type": ""} 205 | resp_data_payload = base64.decodebytes(b"hellocamunda") 206 | process_instance_var_data_url = f"{process_instance_var_url}/data" 207 | 208 | responses.add(responses.GET, process_instance_var_url, status=HTTPStatus.OK, json=resp_frame_payload) 209 | responses.add(responses.GET, process_instance_var_data_url, status=HTTPStatus.OK, body=resp_data_payload) 210 | 211 | resp = self.client.get_process_instance_variable(process_instance_id, variable_name) 212 | self.assertEqual("hellocamunda\n", resp) 213 | 214 | @responses.activate 215 | def test_get_process_instance_variable_with_meta(self): 216 | process_instance_id = "c2c68785-9f42-11ea-a841-0242ac1c0004" 217 | variable_name = "var1" 218 | process_instance_var_url = \ 219 | f"{ENGINE_LOCAL_BASE_URL}/process-instance/{process_instance_id}/variables/{variable_name}" 220 | resp_frame_payload = {"value": None, "valueInfo": {}, "type": ""} 221 | resp_data_payload = base64.decodebytes(b"hellocamunda") 222 | process_instance_var_data_url = f"{process_instance_var_url}/data" 223 | 224 | responses.add(responses.GET, process_instance_var_url, status=HTTPStatus.OK, json=resp_frame_payload) 225 | responses.add(responses.GET, process_instance_var_data_url, status=HTTPStatus.OK, body=resp_data_payload) 226 | 227 | resp = self.client.get_process_instance_variable(process_instance_id, variable_name, True) 228 | self.assertEqual({"value": "hellocamunda\n", "valueInfo": {}, "type": ""}, resp) 229 | -------------------------------------------------------------------------------- /camunda/client/tests/test_external_task_client.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from camunda.client.engine_client import ENGINE_LOCAL_BASE_URL 4 | from camunda.client.external_task_client import ExternalTaskClient 5 | 6 | 7 | class ExternalTaskClientTest(TestCase): 8 | 9 | def test_creation_with_no_debug_config(self): 10 | client = ExternalTaskClient(1, ENGINE_LOCAL_BASE_URL, {}) 11 | self.assertFalse(client.is_debug) 12 | self.assertFalse(client.config.get("isDebug")) 13 | 14 | def test_creation_with_debug_config(self): 15 | client = ExternalTaskClient(1, ENGINE_LOCAL_BASE_URL, {"isDebug": True}) 16 | self.assertTrue(client.is_debug) 17 | self.assertTrue(client.config.get("isDebug")) 18 | -------------------------------------------------------------------------------- /camunda/client/tests/test_external_task_client_auth.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from camunda.client.engine_client import ENGINE_LOCAL_BASE_URL 4 | from camunda.client.external_task_client import ExternalTaskClient 5 | 6 | 7 | class ExternalTaskClientTest(TestCase): 8 | 9 | def test_auth_basic_creation_with_no_debug_config(self): 10 | client = ExternalTaskClient( 11 | 1, ENGINE_LOCAL_BASE_URL, {"auth_basic": {"username": "demo", "password": "demo"}}) 12 | self.assertFalse(client.is_debug) 13 | self.assertFalse(client.config.get("isDebug")) 14 | 15 | def test_auth_basic_creation_with_debug_config(self): 16 | client = ExternalTaskClient( 17 | 1, ENGINE_LOCAL_BASE_URL,{"auth_basic": {"username": "demo", "password": "demo"}, "isDebug": True}) 18 | self.assertTrue(client.is_debug) 19 | self.assertTrue(client.config.get("isDebug")) 20 | -------------------------------------------------------------------------------- /camunda/client/tests/test_external_task_client_bearer.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from camunda.client.engine_client import ENGINE_LOCAL_BASE_URL 4 | from camunda.client.external_task_client import ExternalTaskClient 5 | 6 | 7 | class ExternalTaskClientTest(TestCase): 8 | 9 | def test_auth_bearer_creation_with_no_debug_config(self): 10 | token = ('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkZW1vIn0' 11 | '.NbMsjy8QQ5nrjGTXqdTrJ6g0dqawRvZAqp4XvNt437M') 12 | client = ExternalTaskClient( 13 | 1, ENGINE_LOCAL_BASE_URL, {"auth_bearer": {"access_token": token}}) 14 | self.assertFalse(client.is_debug) 15 | self.assertFalse(client.config.get("isDebug")) 16 | 17 | def test_auth_bearer_creation_with_debug_config(self): 18 | token = ('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkZW1vIn0' 19 | '.NbMsjy8QQ5nrjGTXqdTrJ6g0dqawRvZAqp4XvNt437M') 20 | client = ExternalTaskClient( 21 | 1, ENGINE_LOCAL_BASE_URL, 22 | {"auth_bearer": {"access_token": token}, "isDebug": True}) 23 | self.assertTrue(client.is_debug) 24 | self.assertTrue(client.config.get("isDebug")) 25 | -------------------------------------------------------------------------------- /camunda/external_task/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/camunda-community-hub/camunda-external-task-client-python3/983d321f87de80f75f46dfb40b37973ee4731d41/camunda/external_task/__init__.py -------------------------------------------------------------------------------- /camunda/external_task/async_external_task_executor.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from camunda.client.async_external_task_client import AsyncExternalTaskClient 4 | from camunda.utils.log_utils import log_with_context 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | class AsyncExternalTaskExecutor: 10 | 11 | def __init__(self, worker_id: str, external_task_client: AsyncExternalTaskClient): 12 | self.worker_id = worker_id 13 | self.external_task_client = external_task_client 14 | 15 | async def execute_task(self, task, action): 16 | topic = task.get_topic_name() 17 | task_id = task.get_task_id() 18 | self._log_with_context(f"Executing external task for Topic: {topic}", task_id=task_id) 19 | task_result = await action(task) 20 | # in case task result is not set inside action function, set it in task here 21 | task.set_task_result(task_result) 22 | await self._handle_task_result(task_result) 23 | return task_result 24 | 25 | async def _handle_task_result(self, task_result): 26 | task = task_result.get_task() 27 | topic = task.get_topic_name() 28 | task_id = task.get_task_id() 29 | if task_result.is_success(): 30 | await self._handle_task_success(task_id, task_result, topic) 31 | elif task_result.is_bpmn_error(): 32 | await self._handle_task_bpmn_error(task_id, task_result, topic) 33 | elif task_result.is_failure(): 34 | await self._handle_task_failure(task_id, task_result, topic) 35 | else: 36 | err_msg = f"task result for task_id={task_id} must be either complete/failure/BPMNError" 37 | self._log_with_context(err_msg, task_id=task_id, log_level='warning') 38 | raise Exception(err_msg) 39 | 40 | def _strip_long_variables(self, variables): 41 | """remove value of complex variables for the dict""" 42 | if not variables: 43 | return variables 44 | cleaned = {} 45 | for k, v in variables.items(): 46 | if isinstance(v, dict) and v.get("type", "") in ("File", "Bytes"): 47 | cleaned[k] = {**v, "value": "..."} 48 | else: 49 | cleaned[k] = v 50 | return cleaned 51 | 52 | async def _handle_task_success(self, task_id, task_result, topic): 53 | self._log_with_context(f"Marking task complete for Topic: {topic}", task_id) 54 | if await self.external_task_client.complete(task_id, task_result.global_variables, task_result.local_variables): 55 | self._log_with_context(f"Marked task completed - Topic: {topic} " 56 | f"global_variables: {self._strip_long_variables(task_result.global_variables)} " 57 | f"local_variables: {self._strip_long_variables(task_result.local_variables)}", 58 | task_id, log_level='debug') 59 | else: 60 | self._log_with_context(f"Not able to mark task completed - Topic: {topic} " 61 | f"global_variables: {self._strip_long_variables(task_result.global_variables)} " 62 | f"local_variables: {self._strip_long_variables(task_result.local_variables)}", 63 | task_id, log_level='error') 64 | raise Exception(f"Not able to mark complete for task_id={task_id} " 65 | f"for topic={topic}, worker_id={self.worker_id}") 66 | 67 | async def _handle_task_failure(self, task_id, task_result, topic): 68 | self._log_with_context(f"Marking task failed - Topic: {topic} task_result: {task_result}", task_id) 69 | if await self.external_task_client.failure(task_id, task_result.error_message, task_result.error_details, 70 | task_result.retries, task_result.retry_timeout): 71 | self._log_with_context(f"Marked task failed - Topic: {topic} task_result: {task_result}", task_id) 72 | else: 73 | self._log_with_context(f"Not able to mark task failure - Topic: {topic}", task_id=task_id) 74 | raise Exception(f"Not able to mark failure for task_id={task_id} " 75 | f"for topic={topic}, worker_id={self.worker_id}") 76 | 77 | async def _handle_task_bpmn_error(self, task_id, task_result, topic): 78 | bpmn_error_handled = await self.external_task_client.bpmn_failure(task_id, task_result.bpmn_error_code, 79 | task_result.error_message, 80 | task_result.global_variables) 81 | if bpmn_error_handled: 82 | self._log_with_context(f"BPMN Error Handled: {bpmn_error_handled} " 83 | f"Topic: {topic} task_result: {task_result}") 84 | else: 85 | self._log_with_context(f"Not able to mark BPMN error - Topic: {topic}", task_id=task_id) 86 | raise Exception(f"Not able to mark BPMN Error for task_id={task_id} " 87 | f"for topic={topic}, worker_id={self.worker_id}") 88 | 89 | def _log_with_context(self, msg, task_id=None, log_level='info', **kwargs): 90 | context = {"WORKER_ID": self.worker_id, "TASK_ID": task_id} 91 | log_with_context(msg, context=context, log_level=log_level, **kwargs) 92 | -------------------------------------------------------------------------------- /camunda/external_task/async_external_task_worker.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Any, Callable, Dict, List, Optional 3 | 4 | from camunda.client.async_external_task_client import AsyncExternalTaskClient 5 | from camunda.client.external_task_client import ENGINE_LOCAL_BASE_URL 6 | from camunda.external_task.async_external_task_executor import AsyncExternalTaskExecutor 7 | from camunda.external_task.external_task import ExternalTask 8 | from camunda.utils.auth_basic import obfuscate_password 9 | from camunda.utils.log_utils import log_with_context 10 | from camunda.utils.utils import get_exception_detail 11 | 12 | 13 | class AsyncExternalTaskWorker: 14 | DEFAULT_SLEEP_SECONDS = 1 # Sleep duration when no tasks are fetched 15 | 16 | def __init__( 17 | self, 18 | worker_id: str, 19 | base_url: str = ENGINE_LOCAL_BASE_URL, 20 | config: Optional[Dict[str, Any]] = None, 21 | ): 22 | self.config = config or {} 23 | self.worker_id = worker_id 24 | self.client = AsyncExternalTaskClient(self.worker_id, base_url, self.config) 25 | self.executor = AsyncExternalTaskExecutor(self.worker_id, self.client) 26 | self.subscriptions: List[asyncio.Task] = [] 27 | max_concurrent_tasks = self.config.get('maxConcurrentTasks', 10) 28 | self.semaphore = asyncio.Semaphore(max_concurrent_tasks) 29 | self.running_tasks = set() 30 | self._log_with_context( 31 | f"Created new External Task Worker with config: {obfuscate_password(self.config)}" 32 | ) 33 | 34 | async def subscribe( 35 | self, 36 | topic_handlers: Dict[str, Callable[[ExternalTask], Any]], 37 | process_variables: Optional[Dict[str, Any]] = None, 38 | variables: Optional[List[str]] = None, 39 | ): 40 | self.subscriptions = [ 41 | asyncio.create_task( 42 | self._fetch_and_execute_safe(topic, action, process_variables, variables) 43 | ) 44 | for topic, action in topic_handlers.items() 45 | ] 46 | await asyncio.gather(*self.subscriptions) 47 | 48 | async def _fetch_and_execute_safe( 49 | self, 50 | topic_name: str, 51 | action: Callable[[ExternalTask], Any], 52 | process_variables: Optional[Dict[str, Any]] = None, 53 | variables: Optional[List[str]] = None, 54 | ): 55 | sleep_seconds = self._get_sleep_seconds() 56 | while True: 57 | try: 58 | await self.semaphore.acquire() 59 | tasks_processed = await self.fetch_and_execute(topic_name, action, process_variables, variables) 60 | if not tasks_processed: 61 | # Release semaphore if no tasks were fetched 62 | self.semaphore.release() 63 | await asyncio.sleep(sleep_seconds) 64 | else: 65 | await asyncio.sleep(0) # Yield control to the event loop 66 | except asyncio.CancelledError: 67 | self._log_with_context(f"Task for topic {topic_name} was cancelled.") 68 | break 69 | except Exception as e: 70 | self._log_with_context( 71 | f"Error fetching and executing tasks: {get_exception_detail(e)} " 72 | f"for topic={topic_name} with Process variables: {process_variables}. " 73 | f"Retrying after {sleep_seconds} seconds", 74 | exc_info=True, 75 | log_level="error" 76 | ) 77 | self.semaphore.release() 78 | await asyncio.sleep(sleep_seconds) 79 | 80 | async def fetch_and_execute( 81 | self, 82 | topic_name: str, 83 | action: Callable[[ExternalTask], Any], 84 | process_variables: Optional[Dict[str, Any]] = None, 85 | variables: Optional[List[str]] = None, 86 | ): 87 | self._log_with_context( 88 | f"Fetching and executing external tasks for Topic: {topic_name} " 89 | f"with Process variables: {process_variables}", 90 | log_level="debug" 91 | ) 92 | resp_json = await self.client.fetch_and_lock([topic_name], process_variables, variables) 93 | tasks = self._parse_response(resp_json, topic_name, process_variables) 94 | if not tasks: 95 | return False 96 | 97 | for task in tasks: 98 | # Start processing the task in the background 99 | running_task = asyncio.create_task(self._execute_task(task, action)) 100 | self.running_tasks.add(running_task) 101 | # Release semaphore when task is done 102 | running_task.add_done_callback(lambda t: self.semaphore.release()) 103 | # Remove from running_tasks when done 104 | running_task.add_done_callback(self.running_tasks.discard) 105 | return True 106 | 107 | def _parse_response( 108 | self, 109 | resp_json: List[Dict[str, Any]], 110 | topic_name: str, 111 | process_variables: Optional[Dict[str, Any]], 112 | ) -> List[ExternalTask]: 113 | tasks = [ExternalTask(context) for context in resp_json or []] 114 | tasks_count = len(tasks) 115 | self._log_with_context( 116 | f"{tasks_count} external task(s) found for " 117 | f"Topic: {topic_name}, Process variables: {process_variables}", 118 | log_level="debug" 119 | ) 120 | return tasks 121 | 122 | async def _execute_task(self, task: ExternalTask, action: Callable[[ExternalTask], Any]): 123 | try: 124 | await self.executor.execute_task(task, action) 125 | except asyncio.CancelledError: 126 | task_result = task.failure( 127 | error_message='Task execution cancelled', 128 | error_details='Task was cancelled by the user or system', 129 | max_retries=self.config.get('retries', AsyncExternalTaskClient.default_config['retries']), 130 | retry_timeout=self.config.get('retryTimeout', AsyncExternalTaskClient.default_config['retryTimeout']) 131 | ) 132 | await self.executor._handle_task_result(task_result) 133 | self._log_with_context( 134 | f"Task execution cancelled for task_id: {task.get_task_id()}", 135 | topic=task.get_topic_name(), 136 | task_id=task.get_task_id(), 137 | log_level="info" 138 | ) 139 | return task_result 140 | except Exception as e: 141 | task_result = task.failure( 142 | error_message='Task execution failed', 143 | error_details='An unexpected error occurred while executing the task', 144 | max_retries=self.config.get('retries', AsyncExternalTaskClient.default_config['retries']), 145 | retry_timeout=self.config.get('retryTimeout', AsyncExternalTaskClient.default_config['retryTimeout']) 146 | ) 147 | await self.executor._handle_task_result(task_result) 148 | self._log_with_context( 149 | f"Error when executing task: {get_exception_detail(e)}. " 150 | f"Task execution cancelled for task_id: {task.get_task_id()}.", 151 | topic=task.get_topic_name(), 152 | task_id=task.get_task_id(), 153 | log_level="error", 154 | exc_info=True 155 | ) 156 | return task_result 157 | 158 | def _log_with_context( 159 | self, 160 | msg: str, 161 | topic: Optional[str] = None, 162 | task_id: Optional[str] = None, 163 | log_level: str = "info", 164 | **kwargs: Any, 165 | ): 166 | context = {"WORKER_ID": str(self.worker_id), "TOPIC": topic, "TASK_ID": task_id} 167 | log_with_context(msg, context=context, log_level=log_level, **kwargs) 168 | 169 | def _get_sleep_seconds(self) -> int: 170 | return self.config.get("sleepSeconds", self.DEFAULT_SLEEP_SECONDS) 171 | 172 | async def stop(self): 173 | # First, cancel running tasks 174 | for task in self.running_tasks: 175 | task.cancel() 176 | await asyncio.gather(*self.running_tasks, return_exceptions=True) 177 | 178 | # Then, cancel the fetch loops (subscriptions) 179 | for task in self.subscriptions: 180 | task.cancel() 181 | await asyncio.gather(*self.subscriptions, return_exceptions=True) 182 | -------------------------------------------------------------------------------- /camunda/external_task/external_task.py: -------------------------------------------------------------------------------- 1 | from camunda.variables.properties import Properties 2 | from camunda.variables.variables import Variables 3 | 4 | 5 | class ExternalTask: 6 | def __init__(self, context): 7 | self._context = context 8 | self._variables = Variables(context.get("variables", {})) 9 | self._task_result = TaskResult.empty_task_result(task=self) 10 | self._extProperties = Properties(context.get("extensionProperties", {})) 11 | 12 | def get_worker_id(self): 13 | return self._context["workerId"] 14 | 15 | def get_process_instance_id(self): 16 | return self._context["processInstanceId"] 17 | 18 | def get_variables(self): 19 | return self._variables.to_dict() 20 | 21 | def get_extension_properties(self) -> dict: 22 | return self._extProperties.to_dict() 23 | 24 | def get_task_id(self): 25 | return self._context["id"] 26 | 27 | def get_activity_id(self): 28 | return self._context["activityId"] 29 | 30 | def get_topic_name(self): 31 | return self._context["topicName"] 32 | 33 | def get_variable(self, variable_name, with_meta=False): 34 | return self._variables.get_variable(variable_name, with_meta=with_meta) 35 | 36 | def get_extension_property(self, property_name) -> str: 37 | return self._extProperties.get_property(property_name) 38 | 39 | def get_tenant_id(self): 40 | return self._context.get("tenantId", None) 41 | 42 | def get_business_key(self): 43 | return self._context.get("businessKey", None) 44 | 45 | def get_task_result(self): 46 | return self._task_result 47 | 48 | def set_task_result(self, task_result): 49 | self._task_result = task_result 50 | 51 | def complete(self, global_variables={}, local_variables={}): 52 | self._task_result = TaskResult.success(self, global_variables, local_variables) 53 | return self._task_result 54 | 55 | def failure(self, error_message, error_details, max_retries, retry_timeout): 56 | retries = self._calculate_retries(max_retries) 57 | self._task_result = TaskResult.failure( 58 | self, 59 | error_message=error_message, 60 | error_details=error_details, 61 | retries=retries, 62 | retry_timeout=retry_timeout, 63 | ) 64 | return self._task_result 65 | 66 | def _calculate_retries(self, max_retries): 67 | retries = self._context.get("retries", None) 68 | retries = int(retries - 1) if retries and retries >= 1 else max_retries 69 | return retries 70 | 71 | def bpmn_error(self, error_code, error_message, variables={}): 72 | self._task_result = TaskResult.bpmn_error( 73 | self, 74 | error_code=error_code, 75 | error_message=error_message, 76 | variables=variables, 77 | ) 78 | return self._task_result 79 | 80 | def __str__(self): 81 | return f"{self._context}" 82 | 83 | 84 | class TaskResult: 85 | def __init__( 86 | self, 87 | task, 88 | success=False, 89 | global_variables={}, 90 | local_variables={}, 91 | bpmn_error_code=None, 92 | error_message=None, 93 | error_details={}, 94 | retries=0, 95 | retry_timeout=300000, 96 | ): 97 | self.task = task 98 | self.success_state = success 99 | self.global_variables = global_variables 100 | self.local_variables = local_variables 101 | self.bpmn_error_code = bpmn_error_code 102 | self.error_message = error_message 103 | self.error_details = error_details 104 | self.retries = retries 105 | self.retry_timeout = retry_timeout 106 | 107 | @classmethod 108 | def success(cls, task, global_variables, local_variables={}): 109 | return TaskResult( 110 | task, 111 | success=True, 112 | global_variables=global_variables, 113 | local_variables=local_variables, 114 | ) 115 | 116 | @classmethod 117 | def failure(cls, task, error_message, error_details, retries, retry_timeout): 118 | return TaskResult( 119 | task, 120 | success=False, 121 | error_message=error_message, 122 | error_details=error_details, 123 | retries=retries, 124 | retry_timeout=retry_timeout, 125 | ) 126 | 127 | @classmethod 128 | def bpmn_error(cls, task, error_code, error_message, variables={}): 129 | return TaskResult( 130 | task, 131 | success=False, 132 | bpmn_error_code=error_code, 133 | error_message=error_message, 134 | global_variables=variables, 135 | ) 136 | 137 | @classmethod 138 | def empty_task_result(cls, task): 139 | return TaskResult(task, success=False) 140 | 141 | def is_success(self): 142 | return ( 143 | self.success_state 144 | and self.bpmn_error_code is None 145 | and self.error_message is None 146 | ) 147 | 148 | def is_failure(self): 149 | return ( 150 | not self.success_state 151 | and self.error_message is not None 152 | and not self.is_bpmn_error() 153 | ) 154 | 155 | def is_bpmn_error(self): 156 | return not self.success_state and self.bpmn_error_code 157 | 158 | def get_task(self): 159 | return self.task 160 | 161 | def __str__(self): 162 | if self.is_success(): 163 | return f"success: task_id={self.task.get_task_id()}, global_variables={self.global_variables}, local_variables={self.local_variables}" 164 | elif self.is_failure(): 165 | return ( 166 | f"failure: task_id={self.task.get_task_id()}, " 167 | f"error_message={self.error_message}, error_details={self.error_details}, " 168 | f"retries={self.retries}, retry_timeout={self.retry_timeout}" 169 | ) 170 | elif self.is_bpmn_error(): 171 | return f"bpmn_error: task_id={self.task.get_task_id()}, error_code={self.bpmn_error_code}" 172 | 173 | return "empty_task_result" 174 | -------------------------------------------------------------------------------- /camunda/external_task/external_task_executor.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from camunda.utils.log_utils import log_with_context 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | 8 | class ExternalTaskExecutor: 9 | 10 | def __init__(self, worker_id, external_task_client): 11 | self.worker_id = worker_id 12 | self.external_task_client = external_task_client 13 | 14 | def execute_task(self, task, action): 15 | topic = task.get_topic_name() 16 | task_id = task.get_task_id() 17 | self._log_with_context(f"Executing external task for Topic: {topic}", task_id=task_id) 18 | task_result = action(task) 19 | # in case task result is not set inside action function, set it in task here 20 | task.set_task_result(task_result) 21 | self._handle_task_result(task_result) 22 | return task_result 23 | 24 | def _handle_task_result(self, task_result): 25 | task = task_result.get_task() 26 | topic = task.get_topic_name() 27 | task_id = task.get_task_id() 28 | if task_result.is_success(): 29 | self._handle_task_success(task_id, task_result, topic) 30 | elif task_result.is_bpmn_error(): 31 | self._handle_task_bpmn_error(task_id, task_result, topic) 32 | elif task_result.is_failure(): 33 | self._handle_task_failure(task_id, task_result, topic) 34 | else: 35 | err_msg = f"task result for task_id={task_id} must be either complete/failure/BPMNError" 36 | self._log_with_context(err_msg, task_id=task_id, log_level='warning') 37 | raise Exception(err_msg) 38 | 39 | def _strip_long_variables(self, variables): 40 | """remove value of complex variables for the dict""" 41 | if not variables: 42 | return variables 43 | cleaned = {} 44 | for k, v in variables.items(): 45 | if isinstance(v, dict) and v.get("type", "") in ("File", "Bytes"): 46 | cleaned[k] = {**v, "value": "..."} 47 | else: 48 | cleaned[k] = v 49 | return cleaned 50 | 51 | def _handle_task_success(self, task_id, task_result, topic): 52 | self._log_with_context(f"Marking task complete for Topic: {topic}", task_id) 53 | if self.external_task_client.complete(task_id, task_result.global_variables, task_result.local_variables): 54 | self._log_with_context(f"Marked task completed - Topic: {topic} " 55 | f"global_variables: {self._strip_long_variables(task_result.global_variables)} " 56 | f"local_variables: {self._strip_long_variables(task_result.local_variables)}", task_id) 57 | else: 58 | self._log_with_context(f"Not able to mark task completed - Topic: {topic} " 59 | f"global_variables: {self._strip_long_variables(task_result.global_variables)} " 60 | f"local_variables: {self._strip_long_variables(task_result.local_variables)}", task_id) 61 | raise Exception(f"Not able to mark complete for task_id={task_id} " 62 | f"for topic={topic}, worker_id={self.worker_id}") 63 | 64 | def _handle_task_failure(self, task_id, task_result, topic): 65 | self._log_with_context(f"Marking task failed - Topic: {topic} task_result: {task_result}", task_id) 66 | if self.external_task_client.failure(task_id, task_result.error_message, task_result.error_details, 67 | task_result.retries, task_result.retry_timeout): 68 | self._log_with_context(f"Marked task failed - Topic: {topic} task_result: {task_result}", task_id) 69 | else: 70 | self._log_with_context(f"Not able to mark task failure - Topic: {topic}", task_id=task_id) 71 | raise Exception(f"Not able to mark failure for task_id={task_id} " 72 | f"for topic={topic}, worker_id={self.worker_id}") 73 | 74 | def _handle_task_bpmn_error(self, task_id, task_result, topic): 75 | bpmn_error_handled = self.external_task_client.bpmn_failure(task_id, task_result.bpmn_error_code, 76 | task_result.error_message, 77 | task_result.global_variables) 78 | if bpmn_error_handled: 79 | self._log_with_context(f"BPMN Error Handled: {bpmn_error_handled} " 80 | f"Topic: {topic} task_result: {task_result}") 81 | else: 82 | self._log_with_context(f"Not able to mark BPMN error - Topic: {topic}", task_id=task_id) 83 | raise Exception(f"Not able to mark BPMN Error for task_id={task_id} " 84 | f"for topic={topic}, worker_id={self.worker_id}") 85 | 86 | def _log_with_context(self, msg, task_id=None, log_level='info', **kwargs): 87 | context = {"WORKER_ID": self.worker_id, "TASK_ID": task_id} 88 | log_with_context(msg, context=context, log_level=log_level, **kwargs) 89 | -------------------------------------------------------------------------------- /camunda/external_task/external_task_worker.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from camunda.client.external_task_client import ExternalTaskClient, ENGINE_LOCAL_BASE_URL 4 | from camunda.external_task.external_task import ExternalTask 5 | from camunda.external_task.external_task_executor import ExternalTaskExecutor 6 | from camunda.utils.log_utils import log_with_context 7 | from camunda.utils.auth_basic import obfuscate_password 8 | from camunda.utils.utils import get_exception_detail 9 | 10 | 11 | class ExternalTaskWorker: 12 | DEFAULT_SLEEP_SECONDS = 300 13 | 14 | def __init__(self, worker_id, base_url=ENGINE_LOCAL_BASE_URL, config=None): 15 | config = config if config is not None else {} # To avoid to have a mutable default for a parameter 16 | self.worker_id = worker_id 17 | self.client = ExternalTaskClient(self.worker_id, base_url, config) 18 | self.executor = ExternalTaskExecutor(self.worker_id, self.client) 19 | self.config = config 20 | self._log_with_context(f"Created new External Task Worker with config: {obfuscate_password(self.config)}") 21 | 22 | def subscribe(self, topic_names, action, process_variables=None, variables=None): 23 | while True: 24 | self._fetch_and_execute_safe(topic_names, action, process_variables, variables) 25 | 26 | self._log_with_context("Stopping worker") # Fixme: This code seems to be unreachable? 27 | 28 | def _fetch_and_execute_safe( 29 | self, topic_names, action, process_variables=None, variables=None 30 | ): 31 | try: 32 | self.fetch_and_execute(topic_names, action, process_variables, variables) 33 | except NoExternalTaskFound: 34 | self._log_with_context(f"no External Task found for Topics: {topic_names}, " 35 | f"Process variables: {process_variables}", topic=topic_names) 36 | except BaseException as e: 37 | sleep_seconds = self._get_sleep_seconds() 38 | self._log_with_context(f'error fetching and executing tasks: {get_exception_detail(e)} ' 39 | f'for topic(s)={topic_names} with Process variables: {process_variables}. ' 40 | f'retrying after {sleep_seconds} seconds', exc_info=True) 41 | time.sleep(sleep_seconds) 42 | 43 | def fetch_and_execute(self, topic_names, action, process_variables=None, variables=None): 44 | self._log_with_context(f"Fetching and Executing external tasks for Topics: {topic_names} " 45 | f"with Process variables: {process_variables}") 46 | resp_json = self._fetch_and_lock(topic_names, process_variables, variables) 47 | tasks = self._parse_response(resp_json, topic_names, process_variables) 48 | if len(tasks) == 0: 49 | raise NoExternalTaskFound(f"no External Task found for Topics: {topic_names}, " 50 | f"Process variables: {process_variables}") 51 | self._execute_tasks(tasks, action) 52 | 53 | def _fetch_and_lock(self, topic_names, process_variables=None, variables=None): 54 | self._log_with_context(f"Fetching and Locking external tasks for Topics: {topic_names} " 55 | f"with Process variables: {process_variables}") 56 | return self.client.fetch_and_lock(topic_names, process_variables, variables) 57 | 58 | def _parse_response(self, resp_json, topic_names, process_variables): 59 | tasks = [] 60 | if resp_json: 61 | for context in resp_json: 62 | task = ExternalTask(context) 63 | tasks.append(task) 64 | 65 | tasks_count = len(tasks) 66 | self._log_with_context(f"{tasks_count} External task(s) found for " 67 | f"Topics: {topic_names}, Process variables: {process_variables}") 68 | return tasks 69 | 70 | def _execute_tasks(self, tasks, action): 71 | for task in tasks: 72 | self._execute_task(task, action) 73 | 74 | def _execute_task(self, task, action): 75 | try: 76 | self.executor.execute_task(task, action) 77 | except Exception as e: 78 | self._log_with_context(f'error when executing task: {get_exception_detail(e)}', 79 | topic=task.get_topic_name(), task_id=task.get_task_id(), 80 | log_level='error', exc_info=True) 81 | raise e 82 | 83 | def _log_with_context(self, msg, topic=None, task_id=None, log_level='info', **kwargs): 84 | context = {"WORKER_ID": str(self.worker_id), "TOPIC": topic, "TASK_ID": task_id} 85 | log_with_context(msg, context=context, log_level=log_level, **kwargs) 86 | 87 | def _get_sleep_seconds(self): 88 | return self.config.get("sleepSeconds", self.DEFAULT_SLEEP_SECONDS) 89 | 90 | 91 | class NoExternalTaskFound(Exception): 92 | pass 93 | -------------------------------------------------------------------------------- /camunda/external_task/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/camunda-community-hub/camunda-external-task-client-python3/983d321f87de80f75f46dfb40b37973ee4731d41/camunda/external_task/tests/__init__.py -------------------------------------------------------------------------------- /camunda/external_task/tests/test_async_external_task_executor.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import AsyncMock 3 | 4 | from camunda.client.async_external_task_client import AsyncExternalTaskClient 5 | from camunda.external_task.async_external_task_executor import AsyncExternalTaskExecutor 6 | from camunda.external_task.external_task import ExternalTask, TaskResult 7 | 8 | 9 | class AsyncExternalTaskExecutorTest(unittest.IsolatedAsyncioTestCase): 10 | 11 | async def asyncSetUp(self): 12 | """ 13 | asyncSetUp runs before each test method in IsolatedAsyncioTestCase. 14 | We instantiate an AsyncExternalTaskClient and patch/mocks as needed. 15 | """ 16 | self.mock_client = AsyncMock(spec=AsyncExternalTaskClient) 17 | self.mock_client.complete.return_value = True 18 | self.mock_client.failure.return_value = True 19 | self.mock_client.bpmn_failure.return_value = True 20 | 21 | self.executor = AsyncExternalTaskExecutor( 22 | worker_id="someWorker", 23 | external_task_client=self.mock_client 24 | ) 25 | 26 | async def test_execute_task_success(self): 27 | async def success_action(task: ExternalTask): 28 | return TaskResult.success(task, {"globalVar": 42}, {"localVar": "foo"}) 29 | 30 | task = ExternalTask({"id": "taskId", "topicName": "someTopic"}) 31 | 32 | result = await self.executor.execute_task(task, success_action) 33 | 34 | # Assertions 35 | self.assertTrue(result.is_success()) 36 | self.mock_client.complete.assert_awaited_once_with( 37 | "taskId", {"globalVar": 42}, {"localVar": "foo"} 38 | ) 39 | 40 | async def test_execute_task_failure(self): 41 | async def fail_action(task: ExternalTask): 42 | return TaskResult.failure( 43 | task, 44 | error_message="Some error", 45 | error_details="Details here", 46 | retries=3, 47 | retry_timeout=1000 48 | ) 49 | 50 | task = ExternalTask({"id": "taskId", "topicName": "someTopic"}) 51 | result = await self.executor.execute_task(task, fail_action) 52 | 53 | # Assertions 54 | self.assertTrue(result.is_failure()) 55 | self.mock_client.failure.assert_awaited_once_with( 56 | "taskId", "Some error", "Details here", 3, 1000 57 | ) 58 | 59 | async def test_execute_task_bpmn_error(self): 60 | async def bpmn_error_action(task: ExternalTask): 61 | return TaskResult.bpmn_error( 62 | task, 63 | error_code="bpmn_err_code", 64 | error_message="bpmn error message", 65 | variables={"varA": True} 66 | ) 67 | 68 | task = ExternalTask({"id": "taskId", "topicName": "someTopic"}) 69 | result = await self.executor.execute_task(task, bpmn_error_action) 70 | 71 | # Assertions 72 | self.assertTrue(result.is_bpmn_error()) 73 | self.mock_client.bpmn_failure.assert_awaited_once_with( 74 | "taskId", "bpmn_err_code", "bpmn error message", {"varA": True} 75 | ) 76 | 77 | async def test_execute_task_empty_result_raises_exception(self): 78 | """ 79 | If the action returns an "empty" TaskResult (not success/failure/BPMNError), 80 | executor should raise an exception. 81 | """ 82 | 83 | async def empty_action(task: ExternalTask): 84 | return TaskResult.empty_task_result(task) 85 | 86 | task = ExternalTask({"id": "taskId", "topicName": "someTopic"}) 87 | 88 | with self.assertRaises(Exception) as ctx: 89 | await self.executor.execute_task(task, empty_action) 90 | 91 | self.assertIn("must be either complete/failure/BPMNError", str(ctx.exception)) 92 | 93 | async def test_handle_task_success_when_client_returns_false_raises_exception(self): 94 | """ 95 | If client.complete returns False, an Exception must be raised. 96 | """ 97 | self.mock_client.complete.return_value = False 98 | 99 | async def success_action(task: ExternalTask): 100 | return TaskResult.success(task, {"var": "val"}) 101 | 102 | task = ExternalTask({"id": "taskId", "topicName": "someTopic"}) 103 | 104 | with self.assertRaises(Exception) as ctx: 105 | await self.executor.execute_task(task, success_action) 106 | 107 | self.assertIn("Not able to mark complete for task_id=taskId", str(ctx.exception)) 108 | 109 | async def test_handle_task_failure_when_client_returns_false_raises_exception(self): 110 | """ 111 | If client.failure returns False, an Exception must be raised. 112 | """ 113 | self.mock_client.failure.return_value = False 114 | 115 | async def fail_action(task: ExternalTask): 116 | return TaskResult.failure(task, "errMsg", "errDetails", 3, 2000) 117 | 118 | task = ExternalTask({"id": "taskId", "topicName": "someTopic"}) 119 | 120 | with self.assertRaises(Exception) as ctx: 121 | await self.executor.execute_task(task, fail_action) 122 | 123 | self.assertIn("Not able to mark failure for task_id=taskId", str(ctx.exception)) 124 | 125 | async def test_handle_task_bpmn_error_when_client_returns_false_raises_exception(self): 126 | """ 127 | If client.bpmn_failure returns False, an Exception must be raised. 128 | """ 129 | self.mock_client.bpmn_failure.return_value = False 130 | 131 | async def bpmn_error_action(task: ExternalTask): 132 | return TaskResult.bpmn_error(task, "ERR_CODE", "error message") 133 | 134 | task = ExternalTask({"id": "taskId", "topicName": "someTopic"}) 135 | 136 | with self.assertRaises(Exception) as ctx: 137 | await self.executor.execute_task(task, bpmn_error_action) 138 | 139 | self.assertIn("Not able to mark BPMN Error for task_id=taskId", str(ctx.exception)) 140 | -------------------------------------------------------------------------------- /camunda/external_task/tests/test_async_external_task_worker.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import unittest 3 | from unittest.mock import AsyncMock, patch 4 | 5 | from camunda.client.async_external_task_client import AsyncExternalTaskClient 6 | from camunda.external_task.async_external_task_worker import AsyncExternalTaskWorker 7 | from camunda.external_task.external_task import ExternalTask, TaskResult 8 | 9 | 10 | class AsyncExternalTaskWorkerTest(unittest.IsolatedAsyncioTestCase): 11 | 12 | async def asyncSetUp(self): 13 | """ 14 | Setup a worker with a mock AsyncExternalTaskClient 15 | """ 16 | self.mock_client = AsyncMock(spec=AsyncExternalTaskClient) 17 | self.mock_client.fetch_and_lock.return_value = [] 18 | 19 | self.config = {"maxConcurrentTasks": 2, "sleepSeconds": 0} # faster tests 20 | self.worker = AsyncExternalTaskWorker("testWorker", config=self.config) 21 | # Replace the worker's .client with our mock 22 | self.worker.client = self.mock_client 23 | # Similarly, replace the executor's .external_task_client 24 | self.worker.executor.external_task_client = self.mock_client 25 | 26 | async def test_fetch_and_execute_no_tasks_returns_false(self): 27 | """ 28 | If fetch_and_lock returns [], then fetch_and_execute should return False. 29 | """ 30 | self.mock_client.fetch_and_lock.return_value = [] 31 | result = await self.worker.fetch_and_execute( 32 | topic_name="myTopic", 33 | action=AsyncMock(return_value=None) # doesn't matter, won't be called 34 | ) 35 | self.assertFalse(result) 36 | 37 | async def test_fetch_and_execute_tasks_creates_execute_task_coroutines(self): 38 | """ 39 | If fetch_and_lock returns multiple tasks, ensure each is passed into _execute_task 40 | in the background. 41 | """ 42 | # 2 tasks with different variables 43 | resp = [ 44 | { 45 | "id": "task1", 46 | "topicName": "myTopic", 47 | "workerId": "aWorkerId", 48 | "variables": {"foo": {"value": "bar"}} 49 | }, 50 | { 51 | "id": "task2", 52 | "topicName": "myTopic", 53 | "workerId": "aWorkerId2", 54 | "variables": {"abc": {"value": 123}} 55 | } 56 | ] 57 | self.mock_client.fetch_and_lock.return_value = resp 58 | 59 | async def success_action(task: ExternalTask): 60 | # Return a success result for each 61 | return TaskResult.success(task, {"someGlobalVar": 99}) 62 | 63 | returned = await self.worker.fetch_and_execute("myTopic", success_action) 64 | self.assertTrue(returned) 65 | # confirm 2 tasks => 2 coroutines started 66 | self.assertEqual(len(self.worker.running_tasks), 2) 67 | 68 | # Let them all finish 69 | await asyncio.gather(*self.worker.running_tasks, return_exceptions=True) 70 | 71 | # Now they should be removed from running_tasks 72 | self.assertEqual(len(self.worker.running_tasks), 0) 73 | 74 | async def test_execute_task_failure_when_action_raises_exception(self): 75 | """ 76 | If an uncaught exception occurs in the user-provided action, 77 | the worker’s _execute_task wraps the result as a failure and tries to call 78 | external_task_client.failure(...) 79 | """ 80 | self.mock_client.fetch_and_lock.return_value = [ 81 | {"id": "task1", "topicName": "topicX", "workerId": "w1"} 82 | ] 83 | 84 | async def fail_action(task: ExternalTask): 85 | raise RuntimeError("Something went wrong") 86 | 87 | # We'll run fetch_and_execute => it should spawn one background task 88 | await self.worker.fetch_and_execute("topicX", fail_action) 89 | 90 | # Wait for background tasks to complete 91 | await asyncio.gather(*self.worker.running_tasks, return_exceptions=True) 92 | 93 | # Confirm the worker attempted to call 'failure(...)' 94 | self.mock_client.failure.assert_awaited_once() 95 | task_id, error_message, error_details, retries, retry_timeout = self.mock_client.failure.call_args.args 96 | self.assertEqual(task_id, "task1") 97 | self.assertEqual("Task execution failed", error_message) 98 | self.assertEqual("An unexpected error occurred while executing the task", error_details) 99 | self.assertEqual(3, retries) 100 | self.assertEqual(300000, retry_timeout) 101 | 102 | @patch.object(AsyncExternalTaskWorker, "_fetch_and_execute_safe") 103 | async def test_cancel_running_tasks_single_iteration(self, mock_fetch_and_execute): 104 | # Make _fetch_and_execute_safe run exactly once, then return 105 | async def one_iteration(*args, **kwargs): 106 | await self.worker.semaphore.acquire() 107 | await self.worker.fetch_and_execute(*args, **kwargs) 108 | # no 'while True', so it ends 109 | 110 | mock_fetch_and_execute.side_effect = one_iteration 111 | 112 | async def fake_long_action(task): 113 | await asyncio.sleep(9999999) 114 | 115 | self.mock_client.fetch_and_lock.return_value = [{"id": "taskX", "topicName": "topicA"}] 116 | 117 | sub_task = asyncio.create_task( 118 | self.worker._fetch_and_execute_safe("topicA", fake_long_action) 119 | ) 120 | self.worker.subscriptions.append(sub_task) 121 | 122 | # Wait for that single iteration to run 123 | await asyncio.sleep(0.2) 124 | 125 | await self.worker.stop() 126 | await asyncio.sleep(0) # let cancellation finish 127 | 128 | for t in self.worker.running_tasks: 129 | self.assertTrue(t.done()) 130 | -------------------------------------------------------------------------------- /camunda/external_task/tests/test_external_task.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from camunda.external_task.external_task import ExternalTask 4 | 5 | 6 | class ExternalTaskTest(TestCase): 7 | 8 | def test_external_task_creation_from_context(self): 9 | context = { 10 | "id": "123", "workerId": "321", "topicName": "my_topic", "tenantId": "tenant1", 11 | "processInstanceId": "processInstanceId1", 12 | "variables": { 13 | "applicationId": { 14 | "type": "String", 15 | "value": "appId987", 16 | "valueInfo": {} 17 | } 18 | } 19 | } 20 | task = ExternalTask(context=context) 21 | 22 | self.assertEqual("123", task.get_task_id()) 23 | self.assertEqual("321", task.get_worker_id()) 24 | self.assertEqual("my_topic", task.get_topic_name()) 25 | self.assertEqual("tenant1", task.get_tenant_id()) 26 | self.assertEqual("processInstanceId1", task.get_process_instance_id()) 27 | self.assertDictEqual({"applicationId": "appId987"}, task.get_variables()) 28 | self.assertEqual("empty_task_result", str(task.get_task_result())) 29 | 30 | def test_complete_returns_success_task_result(self): 31 | task = ExternalTask(context={}) 32 | task_result = task.complete({}) 33 | 34 | self.assertEqual(task, task_result.get_task()) 35 | self.assertEqual(task_result, task.get_task_result()) 36 | 37 | self.assertTrue(task_result.is_success()) 38 | self.assertFalse(task_result.is_failure()) 39 | self.assertFalse(task_result.is_bpmn_error()) 40 | 41 | def test_failure_returns_failure_task_result(self): 42 | task = ExternalTask(context={}) 43 | task_result = task.failure(error_message="unknown error", error_details="error details here", 44 | max_retries=3, retry_timeout=1000) 45 | 46 | self.assertEqual(task, task_result.get_task()) 47 | self.assertEqual(task_result, task.get_task_result()) 48 | 49 | self.assertFalse(task_result.is_success()) 50 | self.assertTrue(task_result.is_failure()) 51 | self.assertFalse(task_result.is_bpmn_error()) 52 | 53 | self.assertEqual("unknown error", task_result.error_message) 54 | self.assertEqual("error details here", task_result.error_details) 55 | self.assertEqual(3, task_result.retries) 56 | self.assertEqual(1000, task_result.retry_timeout) 57 | 58 | def test_bpmn_error_returns_bpmn_error_task_result(self): 59 | task = ExternalTask(context={}) 60 | task_result = task.bpmn_error(error_code="bpmn_error_code_1", error_message="bpmn error") 61 | 62 | self.assertEqual(task, task_result.get_task()) 63 | self.assertEqual(task_result, task.get_task_result()) 64 | 65 | self.assertFalse(task_result.is_success()) 66 | self.assertFalse(task_result.is_failure()) 67 | self.assertTrue(task_result.is_bpmn_error()) 68 | 69 | self.assertEqual("bpmn_error_code_1", task_result.bpmn_error_code) 70 | 71 | def test_task_with_retries_returns_failure_task_result_with_decremented_retries(self): 72 | retries = 3 73 | task = ExternalTask(context={"retries": retries}) 74 | task_result = task.failure(error_message="unknown error", error_details="error details here", 75 | max_retries=10, retry_timeout=1000) 76 | 77 | self.assertEqual(retries - 1, task_result.retries) 78 | 79 | def test_get_variable_returns_none_for_missing_variable(self): 80 | task = ExternalTask(context={}) 81 | variable = task.get_variable("var_name") 82 | self.assertIsNone(variable) 83 | 84 | def test_get_variable_returns_value_for_variable_present(self): 85 | task = ExternalTask(context={"variables": {"var_name": {"value": 1}}}) 86 | variable = task.get_variable("var_name") 87 | self.assertEqual(1, variable) 88 | 89 | def test_get_variable_returns_with_meta(self): 90 | task = ExternalTask(context={"variables": {"var_name": {"value": 1}}}) 91 | variable = task.get_variable("var_name", True) 92 | self.assertEqual({"value": 1}, variable) 93 | 94 | def test_get_variable_returns_without_meta(self): 95 | task = ExternalTask(context={"variables": {"var_name": {"value": 1}}}) 96 | variable = task.get_variable("var_name", False) 97 | self.assertEqual(1, variable) 98 | 99 | def test_get_property_returns_value_for_property_present(self): 100 | task = ExternalTask(context={"extensionProperties": {"var1":"one","var2":"two"}}) 101 | prop = task.get_extension_property("var1") 102 | self.assertEqual("one", prop) 103 | 104 | def test_str(self): 105 | task = ExternalTask(context={"variables": {"var_name": {"value": 1}}}) 106 | self.assertEqual("{'variables': {'var_name': {'value': 1}}}", str(task)) 107 | -------------------------------------------------------------------------------- /camunda/external_task/tests/test_external_task_executor.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import collections 3 | from http import HTTPStatus 4 | from unittest import TestCase 5 | 6 | import responses 7 | 8 | from camunda.client.external_task_client import ExternalTaskClient 9 | from camunda.external_task.external_task import TaskResult, ExternalTask 10 | from camunda.external_task.external_task_executor import ExternalTaskExecutor 11 | 12 | 13 | class ExternalTaskExecutorTest(TestCase): 14 | 15 | @staticmethod 16 | def task_success_action(task): 17 | output_vars = {"var1": 1, "var2": "value", "var3": True} 18 | return TaskResult.success(task, output_vars) 19 | 20 | @responses.activate 21 | def test_task_complete(self): 22 | task = ExternalTask({"id": "1", "topicName": "my_topic"}) 23 | output_vars = {"var1": 1, "var2": "value", "var3": True} 24 | expected_task_result = TaskResult.success(task, output_vars) 25 | 26 | external_task_client = ExternalTaskClient(worker_id=1) 27 | responses.add( 28 | responses.POST, 29 | external_task_client.get_task_complete_url(task.get_task_id()), 30 | status=HTTPStatus.NO_CONTENT 31 | ) 32 | executor = ExternalTaskExecutor(worker_id=1, external_task_client=external_task_client) 33 | 34 | actual_task_result = executor.execute_task(task, self.task_success_action) 35 | self.assertEqual(str(expected_task_result), str(actual_task_result)) 36 | 37 | @staticmethod 38 | def task_failure_action(task): 39 | return TaskResult.failure(task, error_message="unknown task failure", error_details="unknown error", 40 | retries=3, retry_timeout=30000) 41 | 42 | @responses.activate 43 | def test_task_failure(self): 44 | task = ExternalTask({"id": "1", "topicName": "my_topic"}) 45 | expected_task_result = TaskResult.failure( 46 | task, 47 | error_message="unknown task failure", 48 | error_details="unknown error", 49 | retries=3, 50 | retry_timeout=30000 51 | ) 52 | 53 | external_task_client = ExternalTaskClient(worker_id=1) 54 | responses.add( 55 | responses.POST, 56 | external_task_client.get_task_failure_url(task.get_task_id()), 57 | status=HTTPStatus.NO_CONTENT 58 | ) 59 | executor = ExternalTaskExecutor(worker_id=1, external_task_client=external_task_client) 60 | 61 | actual_task_result = executor.execute_task(task, self.task_failure_action) 62 | self.assertEqual(str(expected_task_result), str(actual_task_result)) 63 | 64 | @staticmethod 65 | def task_bpmn_error_action(task): 66 | return TaskResult.bpmn_error(task, error_code="bpmn_err_code_1", error_message="bpmn error") 67 | 68 | @responses.activate 69 | def test_task_bpmn_error(self): 70 | task = ExternalTask({"id": "1", "topicName": "my_topic"}) 71 | expected_task_result = TaskResult.bpmn_error(task, error_code="bpmn_err_code_1", error_message="bpmn error") 72 | 73 | external_task_client = ExternalTaskClient(worker_id=1) 74 | responses.add( 75 | responses.POST, 76 | external_task_client.get_task_bpmn_error_url(task.get_task_id()), 77 | status=HTTPStatus.NO_CONTENT 78 | ) 79 | executor = ExternalTaskExecutor(worker_id=1, external_task_client=external_task_client) 80 | 81 | actual_task_result = executor.execute_task(task, self.task_bpmn_error_action) 82 | self.assertEqual(str(expected_task_result), str(actual_task_result)) 83 | 84 | @staticmethod 85 | def task_result_not_complete_failure_bpmn_error(task): 86 | return TaskResult.empty_task_result(task) 87 | 88 | def test_task_result_not_complete_failure_bpmn_error_raises_exception(self): 89 | task = ExternalTask({"id": "1", "topicName": "my_topic"}) 90 | external_task_client = ExternalTaskClient(worker_id=1) 91 | executor = ExternalTaskExecutor(worker_id=1, external_task_client=external_task_client) 92 | 93 | with self.assertRaises(Exception) as exception_ctx: 94 | executor.execute_task(task, self.task_result_not_complete_failure_bpmn_error) 95 | 96 | self.assertEqual( 97 | "task result for task_id=1 must be either complete/failure/BPMNError", 98 | str(exception_ctx.exception) 99 | ) 100 | 101 | @responses.activate 102 | def test_execute_task_raises_exception_raised_when_updating_status_in_engine(self): 103 | client = ExternalTaskClient(worker_id=1) 104 | task = ExternalTask({"id": "1", "topicName": "my_topic"}) 105 | executor = ExternalTaskExecutor(worker_id=1, external_task_client=client) 106 | 107 | TaskResultStatusInput = collections.namedtuple( 108 | 'TaskResultStatusInput', 109 | ['task_status', 'task_action', 'task_status_url', 'error_message'] 110 | ) 111 | 112 | task_result_tests = [ 113 | TaskResultStatusInput( 114 | "complete", self.task_success_action, 115 | client.get_task_complete_url(task.get_task_id()), 116 | "cannot update task status to complete" 117 | ), 118 | TaskResultStatusInput( 119 | "failure", self.task_failure_action, 120 | client.get_task_failure_url(task.get_task_id()), 121 | "cannot update task status to failure" 122 | ), 123 | TaskResultStatusInput( 124 | "bpmn_error", self.task_bpmn_error_action, 125 | client.get_task_bpmn_error_url(task.get_task_id()), 126 | "cannot update task status to BPMN err" 127 | ) 128 | ] 129 | 130 | for task_result_test in task_result_tests: 131 | with self.subTest(task_result_test.task_status): 132 | responses.add( 133 | responses.POST, 134 | task_result_test.task_status_url, 135 | body=Exception(task_result_test.error_message) 136 | ) 137 | 138 | with self.assertRaises(Exception) as exception_ctx: 139 | executor.execute_task(task, task_result_test.task_action) 140 | 141 | self.assertEqual(task_result_test.error_message, str(exception_ctx.exception)) 142 | 143 | @responses.activate 144 | def test_execute_task_raises_exception_if_engine_returns_http_status_other_than_no_content_204(self): 145 | client = ExternalTaskClient(worker_id=1) 146 | task = ExternalTask({"id": "1", "topicName": "my_topic"}) 147 | executor = ExternalTaskExecutor(worker_id=1, external_task_client=client) 148 | 149 | TaskResultStatusInput = collections.namedtuple('TaskResultStatusInput', 150 | ['task_status', 'task_action', 'task_status_url', 151 | 'http_status_code', 'expected_error_message']) 152 | 153 | task_result_tests = [ 154 | TaskResultStatusInput( 155 | "complete", self.task_success_action, 156 | client.get_task_complete_url(task.get_task_id()), HTTPStatus.OK, 157 | 'Not able to mark complete for task_id=1 for topic=my_topic, worker_id=1' 158 | ), 159 | TaskResultStatusInput( 160 | "failure", self.task_failure_action, 161 | client.get_task_failure_url(task.get_task_id()), HTTPStatus.CREATED, 162 | 'Not able to mark failure for task_id=1 for topic=my_topic, worker_id=1' 163 | ), 164 | TaskResultStatusInput( 165 | "bpmn_error", self.task_bpmn_error_action, 166 | client.get_task_bpmn_error_url(task.get_task_id()), HTTPStatus.ACCEPTED, 167 | 'Not able to mark BPMN Error for task_id=1 for topic=my_topic, worker_id=1' 168 | ) 169 | ] 170 | 171 | for task_result_test in task_result_tests: 172 | with self.subTest(task_result_test.task_status): 173 | responses.add( 174 | responses.POST, task_result_test.task_status_url, 175 | status=task_result_test.http_status_code 176 | ) 177 | 178 | with self.assertRaises(Exception) as exception_ctx: 179 | executor.execute_task(task, task_result_test.task_action) 180 | 181 | self.assertEqual(task_result_test.expected_error_message, str(exception_ctx.exception)) 182 | 183 | def test_strip_long_variables(self): 184 | variables = { 185 | "var0": "string", 186 | "var1": {"value": "string"}, 187 | "var2": {"value": 1}, 188 | "var3": {"value": "{\"key\": \"value\"}", "type": "Json"}, 189 | "var4": {"value": base64.encodebytes(b"string"), "type": "Bytes"}, 190 | "var5": {"value": "some file content", "type": "File"}, 191 | } 192 | cleaned = ExternalTaskExecutor("worker-1", None)._strip_long_variables(variables) 193 | self.assertEqual({ 194 | "var0": "string", 195 | "var1": {"value": "string"}, 196 | "var2": {"value": 1}, 197 | "var3": {"value": "{\"key\": \"value\"}", "type": "Json"}, 198 | "var4": {"value": "...", "type": "Bytes"}, 199 | "var5": {"value": "...", "type": "File"}, 200 | }, cleaned) 201 | -------------------------------------------------------------------------------- /camunda/external_task/tests/test_external_task_worker.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from unittest import mock, TestCase 3 | from unittest.mock import patch 4 | 5 | import responses 6 | 7 | from camunda.client.external_task_client import ExternalTaskClient 8 | from camunda.external_task.external_task import TaskResult, ExternalTask 9 | from camunda.external_task.external_task_worker import ExternalTaskWorker 10 | 11 | 12 | class ExternalTaskWorkerTest(TestCase): 13 | 14 | @responses.activate 15 | @patch('camunda.client.external_task_client.ExternalTaskClient.complete') 16 | def test_fetch_and_execute_calls_task_action_for_each_task_fetched(self, _): 17 | external_task_client = ExternalTaskClient(worker_id=0) 18 | resp_payload = [{ 19 | "activityId": "anActivityId", 20 | "activityInstanceId": "anActivityInstanceId", 21 | "errorMessage": "anErrorMessage", 22 | "errorDetails": "anErrorDetails", 23 | "executionId": "anExecutionId", 24 | "id": "anExternalTaskId", 25 | "lockExpirationTime": "2015-10-06T16:34:42", 26 | "processDefinitionId": "aProcessDefinitionId", 27 | "processDefinitionKey": "aProcessDefinitionKey", 28 | "processInstanceId": "aProcessInstanceId", 29 | "tenantId": None, 30 | "retries": 3, 31 | "workerId": "aWorkerId", 32 | "priority": 4, 33 | "topicName": "createOrder", 34 | "variables": { 35 | "orderId": { 36 | "type": "String", 37 | "value": "1234", 38 | "valueInfo": {} 39 | } 40 | } 41 | }, 42 | { 43 | "activityId": "anActivityId", 44 | "activityInstanceId": "anActivityInstanceId", 45 | "errorMessage": "anErrorMessage", 46 | "errorDetails": "anotherErrorDetails", 47 | "executionId": "anExecutionId", 48 | "id": "anExternalTaskId", 49 | "lockExpirationTime": "2015-10-06T16:34:42", 50 | "processDefinitionId": "aProcessDefinitionId", 51 | "processDefinitionKey": "aProcessDefinitionKey", 52 | "processInstanceId": "aProcessInstanceId", 53 | "tenantId": None, 54 | "retries": 3, 55 | "workerId": "aWorkerId", 56 | "priority": 0, 57 | "topicName": "createOrder", 58 | "variables": { 59 | "orderId": { 60 | "type": "String", 61 | "value": "3456", 62 | "valueInfo": {} 63 | } 64 | } 65 | }] 66 | responses.add(responses.POST, external_task_client.get_fetch_and_lock_url(), 67 | status=HTTPStatus.OK, json=resp_payload) 68 | 69 | worker = ExternalTaskWorker(worker_id=0) 70 | mock_action = mock.Mock() 71 | task = ExternalTask({"id": "anExternalTaskId", "workerId": "aWorkerId", "topicName": "createOrder"}) 72 | mock_action.return_value = TaskResult.success(task=task, global_variables={}) 73 | 74 | worker.fetch_and_execute("my_topic", mock_action) 75 | self.assertEqual(2, mock_action.call_count) 76 | 77 | @responses.activate 78 | def test_fetch_and_execute_raises_exception_if_task_action_raises_exception(self): 79 | external_task_client = ExternalTaskClient(worker_id=0) 80 | resp_payload = [{ 81 | "activityId": "anActivityId", 82 | "activityInstanceId": "anActivityInstanceId", 83 | "errorMessage": "anErrorMessage", 84 | "errorDetails": "anErrorDetails", 85 | "executionId": "anExecutionId", 86 | "id": "anExternalTaskId", 87 | "lockExpirationTime": "2015-10-06T16:34:42", 88 | "processDefinitionId": "aProcessDefinitionId", 89 | "processDefinitionKey": "aProcessDefinitionKey", 90 | "processInstanceId": "aProcessInstanceId", 91 | "tenantId": None, 92 | "retries": 3, 93 | "workerId": "aWorkerId", 94 | "priority": 4, 95 | "topicName": "createOrder", 96 | "variables": { 97 | "orderId": { 98 | "type": "String", 99 | "value": "1234", 100 | "valueInfo": {} 101 | } 102 | } 103 | }] 104 | responses.add(responses.POST, external_task_client.get_fetch_and_lock_url(), 105 | status=HTTPStatus.OK, json=resp_payload) 106 | 107 | worker = ExternalTaskWorker(worker_id=0) 108 | mock_action = mock.Mock() 109 | mock_action.side_effect = Exception("error executing task action") 110 | 111 | with self.assertRaises(Exception) as exception_ctx: 112 | worker.fetch_and_execute("my_topic", mock_action) 113 | 114 | self.assertEqual("error executing task action", str(exception_ctx.exception)) 115 | 116 | @responses.activate 117 | def test_fetch_and_execute_raises_exception_if_no_tasks_found(self): 118 | external_task_client = ExternalTaskClient(worker_id=0) 119 | resp_payload = [] 120 | responses.add(responses.POST, external_task_client.get_fetch_and_lock_url(), 121 | status=HTTPStatus.OK, json=resp_payload) 122 | 123 | worker = ExternalTaskWorker(worker_id=0) 124 | mock_action = mock.Mock() 125 | process_variables = {"var1": "value1", "var2": "value2"} 126 | with self.assertRaises(Exception) as context: 127 | worker.fetch_and_execute("my_topic", mock_action, process_variables) 128 | 129 | self.assertEqual(f"no External Task found for Topics: my_topic, Process variables: {process_variables}", 130 | str(context.exception)) 131 | 132 | @responses.activate 133 | @patch('time.sleep', return_value=None) 134 | def test_fetch_and_execute_safe_raises_exception_sleep_is_called(self, mock_time_sleep): 135 | external_task_client = ExternalTaskClient(worker_id=0) 136 | responses.add(responses.POST, external_task_client.get_fetch_and_lock_url(), 137 | status=HTTPStatus.INTERNAL_SERVER_ERROR) 138 | 139 | sleep_seconds = 100 140 | worker = ExternalTaskWorker(worker_id=0, config={"sleepSeconds": sleep_seconds}) 141 | mock_action = mock.Mock() 142 | 143 | worker._fetch_and_execute_safe("my_topic", mock_action) 144 | 145 | self.assertEqual(0, mock_action.call_count) 146 | self.assertEqual(1, mock_time_sleep.call_count) 147 | mock_time_sleep.assert_called_with(sleep_seconds) 148 | -------------------------------------------------------------------------------- /camunda/process_definition/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/camunda-community-hub/camunda-external-task-client-python3/983d321f87de80f75f46dfb40b37973ee4731d41/camunda/process_definition/__init__.py -------------------------------------------------------------------------------- /camunda/process_definition/process_definition_client.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import requests 4 | 5 | from camunda.client.engine_client import EngineClient, ENGINE_LOCAL_BASE_URL 6 | from camunda.utils.response_utils import raise_exception_if_not_ok 7 | from camunda.utils.utils import join 8 | from camunda.variables.variables import Variables 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class ProcessDefinitionClient(EngineClient): 14 | def __init__(self, engine_base_url=ENGINE_LOCAL_BASE_URL, config=None): 15 | super().__init__(engine_base_url, config=config) 16 | 17 | def get_process_definitions( 18 | self, 19 | process_key, 20 | version_tag, 21 | tenant_ids, 22 | sort_by="version", 23 | sort_order="desc", 24 | offset=0, 25 | limit=1, 26 | ): 27 | url = self.get_process_definitions_url() 28 | url_params = self.get_process_definitions_url_params( 29 | process_key, version_tag, tenant_ids, sort_by, sort_order, offset, limit 30 | ) 31 | response = requests.get(url, headers=self._get_headers(), params=url_params) 32 | raise_exception_if_not_ok(response) 33 | return response.json() 34 | 35 | def get_process_definitions_url(self): 36 | return f"{self.engine_base_url}/process-definition" 37 | 38 | def get_process_definitions_url_params( 39 | self, 40 | process_key, 41 | version_tag=None, 42 | tenant_ids=None, 43 | sort_by="version", 44 | sort_order="desc", 45 | offset=0, 46 | limit=1, 47 | ): 48 | """ 49 | offset starts with zero 50 | sort_order can be "asc" or "desc 51 | """ 52 | url_params = { 53 | "key": process_key, 54 | "versionTagLike": f"{version_tag}%" if version_tag else None, 55 | "tenantIdIn": join(tenant_ids, ","), 56 | "sortBy": sort_by, 57 | "sortOrder": sort_order, 58 | "firstResult": offset, 59 | "maxResults": limit, 60 | } 61 | 62 | url_params = {k: v for k, v in url_params.items() if v is not None and v != ""} 63 | 64 | return url_params 65 | 66 | def start_process_by_version( 67 | self, process_key, version_tag, variables, tenant_id=None, business_key=None 68 | ): 69 | """ 70 | Start a process instance with the process_key and specified version tag and variables passed. 71 | If multiple versions with same version tag found, it triggers the latest one 72 | :param process_key: Mandatory 73 | :param version_tag: 74 | :param variables: Mandatory - can be empty dict 75 | :param tenant_id: Optional 76 | :param business_key: Optional 77 | :return: response json 78 | """ 79 | tenant_ids = [tenant_id] if tenant_id else [] 80 | process_definitions = self.get_process_definitions( 81 | process_key, 82 | version_tag, 83 | tenant_ids, 84 | sort_by="version", 85 | sort_order="desc", 86 | offset=0, 87 | limit=1, 88 | ) 89 | 90 | if len(process_definitions) == 0: 91 | raise Exception( 92 | f"cannot start process because no process definitions found " 93 | f"for process_key: {process_key}, version_tag: {version_tag} and tenant_id: {tenant_id}" 94 | ) 95 | 96 | process_definition_id = process_definitions[0]["id"] 97 | version = process_definitions[0]["version"] 98 | if len(process_definitions) > 1: 99 | logger.info( 100 | f"multiple process definitions found for process_key: {process_key}, " 101 | f"version_tag: {version_tag} and tenant_id: {tenant_id}, " 102 | f"using latest process_definition_id: {process_definition_id} with version: {version}" 103 | ) 104 | else: 105 | logger.info( 106 | f"exactly one process definition found for process_key: {process_key}, " 107 | f"version_tag: {version_tag} and tenant_id: {tenant_id}, " 108 | f"using process_definition_id: {process_definition_id} with version: {version}" 109 | ) 110 | 111 | url = self.get_start_process_url(process_definition_id) 112 | body = {"variables": Variables.format(variables)} 113 | if business_key: 114 | body["businessKey"] = business_key 115 | 116 | response = requests.post(url, headers=self._get_headers(), json=body) 117 | raise_exception_if_not_ok(response) 118 | return response.json() 119 | 120 | def get_start_process_url(self, process_definition_id): 121 | return ( 122 | f"{self.engine_base_url}/process-definition/{process_definition_id}/start" 123 | ) 124 | -------------------------------------------------------------------------------- /camunda/process_definition/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/camunda-community-hub/camunda-external-task-client-python3/983d321f87de80f75f46dfb40b37973ee4731d41/camunda/process_definition/tests/__init__.py -------------------------------------------------------------------------------- /camunda/process_definition/tests/test_process_definition_client.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from unittest import TestCase 3 | 4 | import responses 5 | 6 | from camunda.process_definition.process_definition_client import ProcessDefinitionClient 7 | 8 | 9 | class ProcessDefinitionClientTest(TestCase): 10 | 11 | def setUp(self): 12 | self.process_client = ProcessDefinitionClient() 13 | 14 | def test_get_process_definitions_url_params_uses_non_none_params(self): 15 | url_params = self.process_client.get_process_definitions_url_params( 16 | process_key="PROCESS_KEY", 17 | version_tag=None, 18 | tenant_ids=None, 19 | sort_by="version", 20 | sort_order="desc" 21 | ) 22 | self.assertDictEqual({ 23 | "key": 'PROCESS_KEY', 24 | "sortBy": "version", 25 | "sortOrder": "desc", 26 | "firstResult": 0, 27 | "maxResults": 1, 28 | }, url_params) 29 | 30 | def test_get_process_definitions_url_params_uses_all_specified_params(self): 31 | url_params = self.process_client.get_process_definitions_url_params( 32 | process_key="PROCESS_KEY", 33 | version_tag='1.2.3', 34 | tenant_ids=['tenant1'], 35 | sort_by="version", 36 | sort_order="asc" 37 | ) 38 | self.assertDictEqual({ 39 | "key": "PROCESS_KEY", 40 | "versionTagLike": "1.2.3%", 41 | "tenantIdIn": "tenant1", 42 | "sortBy": "version", 43 | "sortOrder": "asc", 44 | "firstResult": 0, 45 | "maxResults": 1, 46 | }, url_params) 47 | 48 | @responses.activate 49 | def test_start_process_by_version_raises_exception_if_no_process_definitions_found(self): 50 | get_process_definitions_resp = [] 51 | responses.add(responses.GET, self.process_client.get_process_definitions_url(), 52 | status=HTTPStatus.OK, json=get_process_definitions_resp) 53 | 54 | with self.assertRaises(Exception) as context: 55 | self.process_client.start_process_by_version("ORIGINATION", "3.8.3", {}, "tenant1") 56 | 57 | self.assertEqual(f"cannot start process because no process definitions found " 58 | f"for process_key: ORIGINATION, version_tag: 3.8.3 and tenant_id: tenant1", 59 | str(context.exception)) 60 | 61 | @responses.activate 62 | def test_start_process_by_version_uses_first_process_definition_id_if_more_than_one_found(self): 63 | get_process_definitions_resp = [ 64 | { 65 | "id": "process_definition_id_2", 66 | "key": "ORIGINATION", 67 | "category": "http://bpmn.io/schema/bpmn", 68 | "description": "- Removed unwanted external tasks", 69 | "name": "Origination", 70 | "version": 37, 71 | "resource": "bpmn/origination.bpmn", 72 | "deploymentId": "05f7527e-737e-11eb-ac5c-0a58a9feac2a", 73 | "diagram": None, 74 | "suspended": False, 75 | "tenantId": "tenant1", 76 | "versionTag": "3.8.3", 77 | "historyTimeToLive": None, 78 | "startableInTasklist": True 79 | }, 80 | { 81 | "id": "process_definition_id_1", 82 | "key": "ORIGINATION", 83 | "category": "http://bpmn.io/schema/bpmn", 84 | "description": "- Added new external tasks", 85 | "name": "Origination", 86 | "version": 36, 87 | "resource": "bpmn/origination.bpmn", 88 | "deploymentId": "035d3c55-5f01-11eb-bcaf-0a58a9feac2a", 89 | "diagram": None, 90 | "suspended": False, 91 | "tenantId": "tenant1", 92 | "versionTag": "3.8.3", 93 | "historyTimeToLive": None, 94 | "startableInTasklist": True 95 | }, 96 | ] 97 | responses.add(responses.GET, self.process_client.get_process_definitions_url(), 98 | status=HTTPStatus.OK, json=get_process_definitions_resp) 99 | 100 | start_process_resp = { 101 | "links": [ 102 | { 103 | "method": "GET", 104 | "href": "http://localhost:8080/engine-rest/process-instance/e07b461a-80d0-11eb-83ea-0a58a9feac2a", 105 | "rel": "self" 106 | } 107 | ], 108 | "id": "e07b461a-80d0-11eb-83ea-0a58a9feac2a", 109 | "definitionId": "process_definition_id_2", 110 | "businessKey": "businessKey", 111 | "caseInstanceId": None, 112 | "ended": False, 113 | "suspended": False, 114 | "tenantId": "tenant1", 115 | "variables": { 116 | "applicationId": { 117 | "type": "String", 118 | "value": "30ea7b40-283b-4526-8979-371f4ffc9ee0", 119 | "valueInfo": {} 120 | } 121 | } 122 | } 123 | responses.add(responses.POST, self.process_client.get_start_process_url('process_definition_id_2'), 124 | status=HTTPStatus.OK, json=start_process_resp) 125 | 126 | resp_json = self.process_client.start_process_by_version("ORIGINATION", "3.8.3", {}, "tenant1") 127 | 128 | self.assertDictEqual(start_process_resp, resp_json) 129 | 130 | @responses.activate 131 | def test_start_process_by_version_returns_process_details_if_started_successfully(self): 132 | get_process_definitions_resp = [ 133 | { 134 | "id": "process_definition_id", 135 | "key": "ORIGINATION", 136 | "category": "http://bpmn.io/schema/bpmn", 137 | "description": "- Removed unwanted external tasks", 138 | "name": "Origination", 139 | "version": 37, 140 | "resource": "bpmn/origination.bpmn", 141 | "deploymentId": "05f7527e-737e-11eb-ac5c-0a58a9feac2a", 142 | "diagram": None, 143 | "suspended": False, 144 | "tenantId": "tenant1", 145 | "versionTag": "3.8.3", 146 | "historyTimeToLive": None, 147 | "startableInTasklist": True 148 | } 149 | ] 150 | responses.add(responses.GET, self.process_client.get_process_definitions_url(), 151 | status=HTTPStatus.OK, json=get_process_definitions_resp) 152 | 153 | start_process_resp = { 154 | "links": [ 155 | { 156 | "method": "GET", 157 | "href": "http://localhost:8080/engine-rest/process-instance/e07b461a-80d0-11eb-83ea-0a58a9feac2a", 158 | "rel": "self" 159 | } 160 | ], 161 | "id": "e07b461a-80d0-11eb-83ea-0a58a9feac2a", 162 | "definitionId": "89337817-75c9-11eb-84bb-0a58a9feac2a", 163 | "businessKey": "businessKey", 164 | "caseInstanceId": None, 165 | "ended": False, 166 | "suspended": False, 167 | "tenantId": "tenant1", 168 | "variables": { 169 | "applicationId": { 170 | "type": "String", 171 | "value": "30ea7b40-283b-4526-8979-371f4ffc9ee0", 172 | "valueInfo": {} 173 | } 174 | } 175 | } 176 | responses.add(responses.POST, self.process_client.get_start_process_url('process_definition_id'), 177 | status=HTTPStatus.OK, json=start_process_resp) 178 | 179 | resp_json = self.process_client.start_process_by_version("ORIGINATION", "3.8.3", {}, "tenant1") 180 | 181 | self.assertDictEqual(start_process_resp, resp_json) 182 | -------------------------------------------------------------------------------- /camunda/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/camunda-community-hub/camunda-external-task-client-python3/983d321f87de80f75f46dfb40b37973ee4731d41/camunda/utils/__init__.py -------------------------------------------------------------------------------- /camunda/utils/auth_basic.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import copy 3 | from pydantic import BaseModel 4 | 5 | 6 | def obfuscate_password(config: dict) -> dict: 7 | """Obfuscate password value in auth_basic config 8 | 9 | :param config: config from ExternalTaskWorker or ExternalTaskClient 10 | :returns: _config with obfuscated password 11 | """ 12 | _config = copy.deepcopy(config) 13 | _auth = _config.get('auth_basic') 14 | if _auth is not None and 'password' in _auth.keys(): 15 | _auth['password'] = '***' 16 | return _config 17 | 18 | class AuthBasic(BaseModel): 19 | username: str 20 | password: str 21 | token: str = "" 22 | 23 | def __init__(self, **data): 24 | super().__init__(**data) 25 | token = f"{self.username}:{self.password}" 26 | bytemsg = base64.b64encode(token.encode('utf-8')) 27 | tokenb64 = str(bytemsg, "utf-8") 28 | self.token = f"Basic {tokenb64}" 29 | -------------------------------------------------------------------------------- /camunda/utils/auth_bearer.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Union 2 | 3 | from pydantic import BaseModel, validator 4 | 5 | 6 | class AuthBearer(BaseModel): 7 | access_token: str 8 | 9 | @validator('access_token', pre=True) 10 | @classmethod 11 | def get_token_from_dict(cls, value: Union[str, Dict[str, Any]]) -> str: 12 | if isinstance(value, str): 13 | return value 14 | if not isinstance(value, dict): 15 | raise ValueError('token should be dict or str') 16 | if not value.get('access_token'): 17 | raise KeyError( 18 | 'you should pass the token inside "access_token" key') 19 | return value['access_token'] 20 | 21 | @validator('access_token') 22 | @classmethod 23 | def concat_bearer(cls, value: str) -> str: 24 | if not any([ 25 | value.startswith('Bearer'), 26 | value.startswith('bearer') 27 | ]): 28 | return f'Bearer {value}' 29 | -------------------------------------------------------------------------------- /camunda/utils/log_utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | def log_with_context(message, context=None, log_level='info', **kwargs): 5 | context = context if context is not None else {} 6 | log_function = __get_log_function(log_level) 7 | 8 | log_context_prefix = __get_log_context_prefix(context) 9 | if log_context_prefix: 10 | log_function(f"{log_context_prefix} {message}", **kwargs) 11 | else: 12 | log_function(message, **kwargs) 13 | 14 | 15 | def __get_log_context_prefix(context): 16 | log_context_prefix = "" 17 | if context: 18 | for k, v in context.items(): 19 | if v is not None: 20 | log_context_prefix += f"[{k}:{v}]" 21 | return log_context_prefix 22 | 23 | 24 | def __get_log_function(log_level): 25 | switcher = { 26 | 'debug': logging.debug, 27 | 'info': logging.info, 28 | 'warning': logging.warning, 29 | 'error': logging.error 30 | } 31 | return switcher.get(log_level, logging.info) 32 | -------------------------------------------------------------------------------- /camunda/utils/response_utils.py: -------------------------------------------------------------------------------- 1 | def raise_exception_if_not_ok(response): 2 | # Check if the response has the `ok` attribute 3 | if hasattr(response, 'ok'): 4 | if response.ok: 5 | return 6 | else: 7 | # For httpx, treat status_code < 400 as "ok" 8 | if response.status_code < 400: 9 | return 10 | 11 | resp_json = __get_json_or_raise_for_status(response) 12 | 13 | raise Exception(get_response_error_message(response.status_code, resp_json)) 14 | 15 | 16 | def __get_json_or_raise_for_status(response): 17 | try: 18 | return response.json() 19 | except ValueError as e: 20 | # if no json available in response then use raise_for_status() to raise exception 21 | response.raise_for_status() 22 | 23 | 24 | def get_response_error_message(status_code, resp_json): 25 | error_msg = f'received {status_code}' 26 | 27 | err_type = resp_json.get('type', '') 28 | message = resp_json.get('message', '') 29 | if err_type: 30 | error_msg += f" : {err_type}" 31 | 32 | if message: 33 | error_msg += f" : {message}" 34 | 35 | return error_msg 36 | -------------------------------------------------------------------------------- /camunda/utils/tests/test_auth_basic.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from camunda.utils.auth_basic import AuthBasic, obfuscate_password 4 | 5 | 6 | class TestAuthBasic(TestCase): 7 | def test_auth_basic(self): 8 | auth_basic = AuthBasic(**{ 9 | "username": "test", 10 | "password": "test", 11 | }) 12 | self.assertEqual(auth_basic.token, 'Basic dGVzdDp0ZXN0') 13 | 14 | 15 | def test_obfuscate_password(self): 16 | default_config = { 17 | "auth_basic": {"username": "demo", "password": "demo"}, 18 | "maxTasks": 1, 19 | "lockDuration": 10000, 20 | "asyncResponseTimeout": 0, 21 | "isDebug": True, 22 | } 23 | obfuscate_config = { 24 | "auth_basic": {"username": "demo", "password": "***"}, 25 | "maxTasks": 1, 26 | "lockDuration": 10000, 27 | "asyncResponseTimeout": 0, 28 | "isDebug": True, 29 | } 30 | self.assertEqual(obfuscate_password(default_config), obfuscate_config) 31 | -------------------------------------------------------------------------------- /camunda/utils/tests/test_auth_bearer.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from camunda.utils.auth_bearer import AuthBearer 4 | 5 | 6 | class TestAuthBasic(TestCase): 7 | """Can you generate a bearer token using jwt lib. 8 | 9 | reffer - https://pyjwt.readthedocs.io/en/stable/ 10 | """ 11 | def test_str_token_bearer(self): 12 | token = ('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkZW1vIn0' 13 | '.NbMsjy8QQ5nrjGTXqdTrJ6g0dqawRvZAqp4XvNt437M') 14 | auth_bearer = AuthBearer(access_token=token) 15 | self.assertEqual(auth_bearer.access_token, f'Bearer {token}') 16 | 17 | def test_dict_token_bearer(self): 18 | token = ('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkZW1vIn0' 19 | '.NbMsjy8QQ5nrjGTXqdTrJ6g0dqawRvZAqp4XvNt437M') 20 | auth_bearer = AuthBearer(access_token={'access_token': token}) 21 | self.assertEqual(auth_bearer.access_token, f'Bearer {token}') 22 | 23 | def test_error_dict_token_bearer(self): 24 | token = ('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkZW1vIn0' 25 | '.NbMsjy8QQ5nrjGTXqdTrJ6g0dqawRvZAqp4XvNt437M') 26 | with self.assertRaises(KeyError): 27 | AuthBearer(access_token={'token': token}) 28 | -------------------------------------------------------------------------------- /camunda/utils/tests/test_response_utils.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from unittest import TestCase 3 | 4 | import requests 5 | 6 | from camunda.utils.response_utils import raise_exception_if_not_ok, get_response_error_message 7 | 8 | 9 | class TestRaiseExceptionIfResponseNotOk(TestCase): 10 | 11 | def test_does_not_raise_exception_if_response_is_ok(self): 12 | try: 13 | raise_exception_if_not_ok(self.__mock_response(HTTPStatus.OK, {})) 14 | except Exception: 15 | self.fail("raise_exception_if_not_ok() should not raise Exception when response is ok") 16 | 17 | def test_raise_exception_if_response_is_not_ok(self): 18 | data = {'type': "SomeExceptionClass", "message": "a detailed message"} 19 | with self.assertRaises(Exception) as context: 20 | raise_exception_if_not_ok(self.__mock_response(HTTPStatus.BAD_REQUEST, data)) 21 | 22 | self.assertEqual("received 400 : SomeExceptionClass : a detailed message", str(context.exception)) 23 | 24 | def __mock_response(self, status_code, data): 25 | response = requests.Response() 26 | response.status_code = status_code 27 | response.json = lambda: data 28 | return response 29 | 30 | def test_get_response_error_message_no_error_type_no_message(self): 31 | data = {} 32 | error_msg = get_response_error_message(HTTPStatus.BAD_REQUEST, data) 33 | self.assertEqual("received 400", error_msg) 34 | 35 | def test_get_response_error_message_only_type_no_msg(self): 36 | data = {'type': "InvalidRequestType", "message": ""} 37 | error_msg = get_response_error_message(HTTPStatus.BAD_REQUEST, data) 38 | self.assertEqual("received 400 : InvalidRequestType", error_msg) 39 | 40 | def test_get_response_error_message_only_msg_no_type(self): 41 | data = {"message": "a detailed message"} 42 | error_msg = get_response_error_message(HTTPStatus.BAD_REQUEST, data) 43 | self.assertEqual("received 400 : a detailed message", error_msg) 44 | -------------------------------------------------------------------------------- /camunda/utils/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from camunda.utils.utils import str_to_list, join 4 | 5 | 6 | class TestUtils(TestCase): 7 | def test_str_to_list_returns_list_as_is(self): 8 | self.assertEqual([], str_to_list([])) 9 | self.assertEqual([1, 2, 3], str_to_list([1, 2, 3])) 10 | self.assertEqual(["a", "b", "c"], str_to_list(["a", "b", "c"])) 11 | 12 | def test_str_to_list_returns_list_with_string_passed(self): 13 | self.assertEqual(["hello"], str_to_list("hello")) 14 | 15 | def test_join_empty_list(self): 16 | self.assertEqual("", join(None, ',')) 17 | self.assertEqual("", join([], ',')) 18 | 19 | def test_join_non_empty_list(self): 20 | self.assertEqual("1", join([1], ',')) 21 | self.assertEqual("1,2,3", join([1, 2, 3], ',')) 22 | -------------------------------------------------------------------------------- /camunda/utils/utils.py: -------------------------------------------------------------------------------- 1 | def str_to_list(values): 2 | if isinstance(values, str): 3 | return [values] 4 | return values 5 | 6 | 7 | def get_exception_detail(exception): 8 | return f"{type(exception)} : {str(exception)}" 9 | 10 | 11 | def join(list_of_values, separator): 12 | if list_of_values: 13 | return separator.join(str(v) for v in list_of_values) 14 | return '' 15 | -------------------------------------------------------------------------------- /camunda/variables/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/camunda-community-hub/camunda-external-task-client-python3/983d321f87de80f75f46dfb40b37973ee4731d41/camunda/variables/__init__.py -------------------------------------------------------------------------------- /camunda/variables/properties.py: -------------------------------------------------------------------------------- 1 | class Properties: 2 | """ 3 | Properties are key->value pairs which acts as a kind of constant in Camunda. 4 | A property can be set via Camunda Modeller's Properties Panel on the Extension tab. 5 | 6 | Properties appear in a ExternalTask as soon as the config for an ExternalTaskClient will 7 | have a configuration includeExtensionProperties: True (which is the default) 8 | 9 | Properties will store strings only. 10 | """ 11 | def __init__(self, properties={}): 12 | self.properties = properties 13 | 14 | def get_property(self, property_name) -> str: 15 | """ 16 | access a single property 17 | """ 18 | return self.properties.get(property_name, None) 19 | 20 | def to_dict(self) -> dict: 21 | """ 22 | Converts the properties to a simple dictionary 23 | """ 24 | result = {} 25 | for k, v in self.properties.items(): 26 | result[k] = v 27 | return result 28 | -------------------------------------------------------------------------------- /camunda/variables/tests/test_properties.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from camunda.variables.properties import Properties 4 | 5 | 6 | class PropertiesTest(TestCase): 7 | 8 | def test_get_variable_returns_none_when_variable_absent(self): 9 | properties = Properties({}) 10 | self.assertIsNone(properties.get_property("var1")) 11 | 12 | def test_get_variable_returns_value_when_variable_present(self): 13 | properties = Properties({"var1": "one"}) 14 | self.assertEqual("one", properties.get_property("var1")) 15 | 16 | def test_to_dict_returns_variables_as_dict(self): 17 | properties = Properties({"var1": "Sample1", 18 | "var2": "Sample2", 19 | "var3": "Sample3"}) 20 | self.assertDictEqual({"var1": "Sample1", "var2": "Sample2", "var3": "Sample3"}, properties.to_dict()) 21 | -------------------------------------------------------------------------------- /camunda/variables/tests/test_variables.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from camunda.variables.variables import Variables 4 | 5 | 6 | class VariablesTest(TestCase): 7 | 8 | def test_get_variable_returns_none_when_variable_absent(self): 9 | variables = Variables({}) 10 | self.assertIsNone(variables.get_variable("var1")) 11 | 12 | def test_get_variable_returns_value_when_variable_present(self): 13 | variables = Variables({"var1": {"value": 1}}) 14 | self.assertEqual(1, variables.get_variable("var1")) 15 | 16 | def test_get_variable_returns_with_meta(self): 17 | var1_raw = {"value": 1} 18 | variables = Variables({"var1": var1_raw}) 19 | self.assertEqual(var1_raw, variables.get_variable("var1", True)) 20 | 21 | def test_get_variable_returns_without_meta(self): 22 | var1_raw = {"value": 1} 23 | variables = Variables({"var1": var1_raw}) 24 | self.assertEqual(1, variables.get_variable("var1", False)) 25 | 26 | def test_format_returns_empty_dict_when_none_is_passed(self): 27 | variables = None 28 | self.assertDictEqual({}, Variables.format(variables)) 29 | 30 | def test_format_returns_empty_dict_when_variables_absent(self): 31 | variables = {} 32 | self.assertDictEqual({}, Variables.format(variables)) 33 | 34 | def test_format_returns_dict_with_value_when_nested_dict(self): 35 | var1_raw = {"var2": 1, "var3": "test"} 36 | variables = {"var1": var1_raw} 37 | self.assertDictEqual({"var1": {"value": var1_raw}}, Variables.format(variables)) 38 | 39 | def test_format_returns_formatted_variables_when_variables_present(self): 40 | variables = {"var1": 1, "var2": True, "var3": "string"} 41 | formatted_vars = Variables.format(variables) 42 | self.assertDictEqual({"var1": {"value": 1}, 43 | "var2": {"value": True}, 44 | "var3": {"value": "string"}}, formatted_vars) 45 | 46 | def test_format_returns_formatted_variables_keeps_already_formatted(self): 47 | variables = {"var1": 1, "var2": True, "var3": "string", "var4": {"value": 1}} 48 | formatted_vars = Variables.format(variables) 49 | self.assertDictEqual({"var1": {"value": 1}, 50 | "var2": {"value": True}, 51 | "var3": {"value": "string"}, 52 | "var4": {"value": 1}}, formatted_vars) 53 | 54 | def test_to_dict_returns_variables_as_dict(self): 55 | variables = Variables({"var1": {"value": 1}, 56 | "var2": {"value": True}, 57 | "var3": {"value": "string"}}) 58 | self.assertDictEqual({"var1": 1, "var2": True, "var3": "string"}, variables.to_dict()) 59 | 60 | 61 | -------------------------------------------------------------------------------- /camunda/variables/variables.py: -------------------------------------------------------------------------------- 1 | 2 | import json 3 | 4 | class Variables: 5 | def __init__(self, variables={}): 6 | self.variables = variables 7 | 8 | def get_variable(self, variable_name, with_meta=False): 9 | variable = self.variables.get(variable_name, None) 10 | if not variable: 11 | return None 12 | if with_meta: 13 | return variable 14 | return variable["value"] 15 | 16 | @classmethod 17 | def format(cls, variables): 18 | """ 19 | Gives the correct format to variables. 20 | :param variables: dict - Dictionary of variable names to values. 21 | :return: Dictionary of well formed variables 22 | {"var1": 1, "var2": True} 23 | -> 24 | {"var1": {"value": 1}, "var2": {"value": True}} 25 | """ 26 | formatted_vars = {} 27 | if variables: 28 | formatted_vars = { 29 | k: v if (isinstance(v, dict) and "value" in v.keys()) else {"value": v} 30 | for k, v in variables.items() 31 | } 32 | return formatted_vars 33 | 34 | def to_dict(self): 35 | """ 36 | Converts the variables to a simple dictionary 37 | :return: dict 38 | {"var1": {"value": 1}, "var2": {"value": True}} 39 | -> 40 | {"var1": 1, "var2": True} 41 | """ 42 | result = {} 43 | for k, v in self.variables.items(): 44 | result[k] = v["value"] 45 | return result 46 | -------------------------------------------------------------------------------- /docker-compose-auth.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | postgres: 5 | image: postgres:11 6 | volumes: 7 | - ./postgres-data:/var/lib/postgresql/data 8 | environment: 9 | - POSTGRES_DB=app 10 | - POSTGRES_PASSWORD=password 11 | ports: 12 | - "5432:5432" 13 | 14 | camunda_workflow: 15 | build: 16 | context: . 17 | container_name: camunda_workflow_example_auth 18 | depends_on: 19 | - postgres 20 | ports: 21 | - "8080:8080" 22 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | postgres: 5 | image: postgres:11 6 | volumes: 7 | - ./postgres-data:/var/lib/postgresql/data 8 | environment: 9 | - POSTGRES_DB=app 10 | - POSTGRES_PASSWORD=password 11 | ports: 12 | - "5432:5432" 13 | 14 | camunda_workflow: 15 | image: camunda/camunda-bpm-platform:7.13.0 16 | container_name: camunda_workflow_example 17 | depends_on: 18 | - postgres 19 | ports: 20 | - "8080:8080" 21 | -------------------------------------------------------------------------------- /engine-rest/web.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 12 | 13 | 14 | 15 | org.camunda.bpm.engine.rest.impl.web.bootstrap.RestContainerBootstrap 16 | 17 | 18 | 19 | org.camunda.bpm.engine.rest.impl.FetchAndLockContextListener 20 | 21 | 22 | 23 | EmptyBodyFilter 24 | org.camunda.bpm.engine.rest.filter.EmptyBodyFilter 25 | true 26 | 27 | 28 | EmptyBodyFilter 29 | /* 30 | 31 | 32 | 33 | CacheControlFilter 34 | org.camunda.bpm.engine.rest.filter.CacheControlFilter 35 | true 36 | 37 | 38 | CacheControlFilter 39 | /* 40 | 41 | 42 | 43 | 44 | camunda-auth 45 | 46 | org.camunda.bpm.engine.rest.security.auth.ProcessEngineAuthenticationFilter 47 | 48 | true 49 | 50 | authentication-provider 51 | org.camunda.bpm.engine.rest.security.auth.impl.HttpBasicAuthenticationProvider 52 | 53 | 54 | rest-url-pattern-prefix 55 | 56 | 57 | 58 | 59 | 60 | camunda-auth 61 | /* 62 | 63 | 64 | 65 | Resteasy 66 | org.jboss.resteasy.plugins.server.servlet.HttpServlet30Dispatcher 67 | 68 | javax.ws.rs.Application 69 | org.camunda.bpm.engine.rest.impl.application.DefaultApplication 70 | 71 | true 72 | 73 | 74 | 75 | Resteasy 76 | /* 77 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/camunda-community-hub/camunda-external-task-client-python3/983d321f87de80f75f46dfb40b37973ee4731d41/examples/__init__.py -------------------------------------------------------------------------------- /examples/bpmn_error_example.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from concurrent.futures.thread import ThreadPoolExecutor 3 | 4 | from camunda.external_task.external_task import ExternalTask 5 | from camunda.external_task.external_task_worker import ExternalTaskWorker 6 | from camunda.utils.log_utils import log_with_context 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | default_config = { 11 | "maxTasks": 1, 12 | "lockDuration": 10000, 13 | "asyncResponseTimeout": 30000, 14 | "retries": 3, 15 | "retryTimeout": 5000, 16 | "sleepSeconds": 30, 17 | "isDebug": True, 18 | } 19 | 20 | 21 | def validate_image(task: ExternalTask): 22 | """ 23 | To simulate BPMN/Failure/Success, this handler uses image name variable (to be passed when launching the process) 24 | """ 25 | log_context = {"WORKER_ID": task.get_worker_id(), 26 | "TASK_ID": task.get_task_id(), 27 | "TOPIC": task.get_topic_name()} 28 | 29 | log_with_context("executing validate_image", log_context) 30 | img_name = task.get_variable('imgName') 31 | 32 | if "poor" in img_name: 33 | return task.bpmn_error("POOR_QUALITY_IMAGE", "Image quality is bad", 34 | {"img_rejection_code": "POOR_QUALITY_CODE_XX", 35 | "img_rejection_reason": f"Image quality must be at least GOOD"}) 36 | elif "jpg" in img_name: 37 | return task.complete({"img_approved": True}) 38 | elif "corrupt" in img_name: 39 | return task.failure("Cannot validate image", "image is corrupted", 0, default_config.get("retryTimeout")) 40 | else: 41 | return task.bpmn_error("INVALID_IMAGE", "Image extension must be jpg", 42 | {"img_rejection_code": "INVALID_IMG_NAME", 43 | "img_rejection_reason": f"Image name {img_name} is invalid"}) 44 | 45 | 46 | def generic_task_handler(task: ExternalTask): 47 | log_context = {"WORKER_ID": task.get_worker_id(), 48 | "TASK_ID": task.get_task_id(), 49 | "TOPIC": task.get_topic_name()} 50 | 51 | log_with_context("executing generic task handler", log_context) 52 | return task.complete() 53 | 54 | 55 | def main(): 56 | configure_logging() 57 | topics = [("VALIDATE_IMAGE", validate_image), 58 | # ("APPROVE_IMAGE", generic_task_handler), 59 | # ("REJECT_IMAGE", generic_task_handler), 60 | # ("ENHANCE_IMAGE_QUALITY", generic_task_handler), 61 | ] 62 | executor = ThreadPoolExecutor(max_workers=len(topics)) 63 | for index, topic_handler in enumerate(topics): 64 | topic = topic_handler[0] 65 | handler_func = topic_handler[1] 66 | executor.submit(ExternalTaskWorker(worker_id=index, config=default_config).subscribe, topic, handler_func) 67 | 68 | 69 | def configure_logging(): 70 | logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", 71 | handlers=[logging.StreamHandler()]) 72 | 73 | 74 | if __name__ == '__main__': 75 | main() 76 | -------------------------------------------------------------------------------- /examples/correlate_message.py: -------------------------------------------------------------------------------- 1 | from camunda.client.engine_client import EngineClient 2 | 3 | 4 | def main(): 5 | client = EngineClient() 6 | resp_json = client.correlate_message("CANCEL_MESSAGE", business_key="b4a6f392-12ab-11eb-80ef-acde48001122") 7 | print(resp_json) 8 | 9 | 10 | if __name__ == '__main__': 11 | main() 12 | -------------------------------------------------------------------------------- /examples/event_subprocess_example.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from concurrent.futures.thread import ThreadPoolExecutor 3 | 4 | from camunda.external_task.external_task import ExternalTask 5 | from camunda.external_task.external_task_worker import ExternalTaskWorker 6 | from camunda.utils.log_utils import log_with_context 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | default_config = { 11 | "maxTasks": 1, 12 | "lockDuration": 10000, 13 | "asyncResponseTimeout": 30000, 14 | "retries": 3, 15 | "retryTimeout": 5000, 16 | "sleepSeconds": 30, 17 | "isDebug": True, 18 | } 19 | 20 | 21 | def generic_task_handler(task: ExternalTask): 22 | log_context = {"WORKER_ID": task.get_worker_id(), 23 | "TASK_ID": task.get_task_id(), 24 | "TOPIC": task.get_topic_name()} 25 | 26 | log_with_context("executing generic task handler", log_context) 27 | return task.complete() 28 | 29 | 30 | def main(): 31 | configure_logging() 32 | topics = [ 33 | ("STEP_1", generic_task_handler), 34 | # ("STEP_2", generic_task_handler), 35 | # ("CLEAN_UP", generic_task_handler), 36 | ] 37 | executor = ThreadPoolExecutor(max_workers=len(topics)) 38 | for index, topic_handler in enumerate(topics): 39 | topic = topic_handler[0] 40 | handler_func = topic_handler[1] 41 | executor.submit(ExternalTaskWorker(worker_id=index, config=default_config).subscribe, topic, handler_func) 42 | 43 | 44 | def configure_logging(): 45 | logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", 46 | handlers=[logging.StreamHandler()]) 47 | 48 | 49 | if __name__ == '__main__': 50 | main() 51 | -------------------------------------------------------------------------------- /examples/examples_auth_basic/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/camunda-community-hub/camunda-external-task-client-python3/983d321f87de80f75f46dfb40b37973ee4731d41/examples/examples_auth_basic/__init__.py -------------------------------------------------------------------------------- /examples/examples_auth_basic/fetch_and_execute.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from camunda.external_task.external_task_worker import ExternalTaskWorker 4 | from task_handler_example import handle_task 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | default_config = { 9 | "auth_basic": {"username": "demo", "password": "demo"}, 10 | "maxTasks": 1, 11 | "lockDuration": 10000, 12 | "asyncResponseTimeout": 0, 13 | "isDebug": True, 14 | } 15 | 16 | 17 | def main(): 18 | configure_logging() 19 | topics = ["PARALLEL_STEP_1", "PARALLEL_STEP_2", "COMBINE_STEP"] 20 | for index, topic in enumerate(topics): 21 | ExternalTaskWorker(worker_id=index, config=default_config) \ 22 | .fetch_and_execute(topic_names=topic, action=handle_task, process_variables={"strVar": "hello"}) 23 | 24 | 25 | def configure_logging(): 26 | logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", 27 | handlers=[logging.StreamHandler()]) 28 | 29 | 30 | if __name__ == '__main__': 31 | main() 32 | -------------------------------------------------------------------------------- /examples/examples_auth_basic/get_process_instance.py: -------------------------------------------------------------------------------- 1 | from camunda.client.engine_client import EngineClient 2 | 3 | 4 | def main(): 5 | client = EngineClient(config={"auth_basic": {"username": "demo", "password": "demo"}}) 6 | resp_json = client.get_process_instance("PARALLEL_STEPS_EXAMPLE", ["intVar_eq_1", "strVar_eq_hello"], 7 | ["6172cdf0-7b32-4460-9da0-ded5107aa977"]) 8 | print(resp_json) 9 | 10 | 11 | if __name__ == '__main__': 12 | main() 13 | -------------------------------------------------------------------------------- /examples/examples_auth_basic/start_process.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from camunda.client.engine_client import EngineClient 4 | 5 | 6 | def main(): 7 | client = EngineClient(config={"auth_basic": {"username": "demo", "password": "demo"}}) 8 | resp_json = client.start_process( 9 | process_key="PARALLEL_STEPS_EXAMPLE", variables={"intVar": "1", "strVar": "hello"}, 10 | tenant_id="6172cdf0-7b32-4460-9da0-ded5107aa977", business_key=str(uuid.uuid1())) 11 | print(resp_json) 12 | 13 | 14 | if __name__ == '__main__': 15 | main() 16 | -------------------------------------------------------------------------------- /examples/examples_auth_basic/task_handler_example.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from random import randint 3 | 4 | import time 5 | 6 | from camunda.external_task.external_task import ExternalTask 7 | from camunda.utils.log_utils import log_with_context 8 | 9 | 10 | def handle_task(task: ExternalTask): 11 | log_context = {"WORKER_ID": task.get_worker_id(), 12 | "TASK_ID": task.get_task_id(), 13 | "TOPIC": task.get_topic_name()} 14 | 15 | log_with_context(f"handle_task started: business key = {task.get_business_key()}", log_context) 16 | 17 | # simulate task execution 18 | execution_time = randint(0, 10) 19 | log_with_context(f"handle_task - business logic execution started for task: " 20 | f"it will execute for {execution_time} seconds", log_context) 21 | time.sleep(execution_time) 22 | 23 | # simulate that task results randomly into failure/BPMN error/complete 24 | failure = random_true() 25 | bpmn_error = False if failure else random_true() 26 | # override the values to simulate success/failure/BPMN error explicitly (if needed) 27 | failure, bpmn_error = False, False 28 | log_with_context(f"handle_task - business logic executed: failure: {failure}, bpmn_error: {bpmn_error}", 29 | log_context) 30 | 31 | return __handle_task_result(task, failure, bpmn_error) 32 | 33 | 34 | def __handle_task_result(task, failure, bpmn_error): 35 | if failure: 36 | return task.failure("task failed", "failed task details", 3, 5000) 37 | elif bpmn_error: 38 | return task.bpmn_error("BPMN_ERROR_CODE") 39 | return task.complete({"success": True, "task_completed_on": str(datetime.now())}) 40 | 41 | 42 | def random_true(): 43 | current_milli_time = int(round(time.time() * 1000)) 44 | return current_milli_time % 2 == 0 45 | -------------------------------------------------------------------------------- /examples/fetch_and_execute.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from camunda.external_task.external_task_worker import ExternalTaskWorker 4 | from examples.task_handler_example import handle_task 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | default_config = { 9 | "maxTasks": 1, 10 | "lockDuration": 10000, 11 | "asyncResponseTimeout": 0, 12 | "isDebug": True, 13 | } 14 | 15 | 16 | def main(): 17 | configure_logging() 18 | topics = ["PARALLEL_STEP_1", "PARALLEL_STEP_2", "COMBINE_STEP"] 19 | for index, topic in enumerate(topics): 20 | ExternalTaskWorker(worker_id=index, config=default_config) \ 21 | .fetch_and_execute(topic_names=topic, action=handle_task, process_variables={"strVar": "hello"}) 22 | 23 | 24 | def configure_logging(): 25 | logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", 26 | handlers=[logging.StreamHandler()]) 27 | 28 | 29 | if __name__ == '__main__': 30 | main() 31 | -------------------------------------------------------------------------------- /examples/get_process_instance.py: -------------------------------------------------------------------------------- 1 | from camunda.client.engine_client import EngineClient 2 | 3 | 4 | def main(): 5 | client = EngineClient() 6 | resp_json = client.get_process_instance("PARALLEL_STEPS_EXAMPLE", ["intVar_eq_1", "strVar_eq_hello"], 7 | ["6172cdf0-7b32-4460-9da0-ded5107aa977"]) 8 | print(resp_json) 9 | 10 | 11 | if __name__ == '__main__': 12 | main() 13 | -------------------------------------------------------------------------------- /examples/retry_task_example.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from concurrent.futures.thread import ThreadPoolExecutor 3 | 4 | from camunda.external_task.external_task import ExternalTask 5 | from camunda.external_task.external_task_worker import ExternalTaskWorker 6 | from camunda.utils.log_utils import log_with_context 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | default_config = { 11 | "maxTasks": 1, 12 | "lockDuration": 10000, 13 | "asyncResponseTimeout": 30000, 14 | "retries": 3, 15 | "retryTimeout": 5000, 16 | "sleepSeconds": 30, 17 | "isDebug": True, 18 | } 19 | 20 | 21 | def generic_task_handler(task: ExternalTask): 22 | log_context = {"WORKER_ID": task.get_worker_id(), 23 | "TASK_ID": task.get_task_id(), 24 | "TOPIC": task.get_topic_name()} 25 | 26 | log_with_context("executing generic task handler", log_context) 27 | return task.complete() 28 | 29 | 30 | def fail_task_handler(task: ExternalTask): 31 | log_context = {"WORKER_ID": task.get_worker_id(), 32 | "TASK_ID": task.get_task_id(), 33 | "TOPIC": task.get_topic_name()} 34 | 35 | log_with_context("executing fail_task_handler", log_context) 36 | return task.failure("task failed", "task failed forced", 0, 10) 37 | 38 | 39 | def main(): 40 | configure_logging() 41 | topics = [ 42 | ("TASK_1", fail_task_handler), 43 | ("TASK_2", fail_task_handler), 44 | ] 45 | executor = ThreadPoolExecutor(max_workers=len(topics)) 46 | for index, topic_handler in enumerate(topics): 47 | topic = topic_handler[0] 48 | handler_func = topic_handler[1] 49 | executor.submit(ExternalTaskWorker(worker_id=index, config=default_config).subscribe, topic, handler_func) 50 | 51 | 52 | def configure_logging(): 53 | logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", 54 | handlers=[logging.StreamHandler()]) 55 | 56 | 57 | if __name__ == '__main__': 58 | main() 59 | -------------------------------------------------------------------------------- /examples/start_process.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from camunda.client.engine_client import EngineClient 4 | 5 | 6 | def main(): 7 | client = EngineClient() 8 | resp_json = client.start_process(process_key="PARALLEL_STEPS_EXAMPLE", variables={"intVar": "1", "strVar": "hello"}, 9 | tenant_id="6172cdf0-7b32-4460-9da0-ded5107aa977", business_key=str(uuid.uuid1())) 10 | print(resp_json) 11 | 12 | 13 | if __name__ == '__main__': 14 | main() 15 | -------------------------------------------------------------------------------- /examples/task_handler_example.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from random import randint 3 | 4 | import time 5 | 6 | from camunda.external_task.external_task import ExternalTask 7 | from camunda.utils.log_utils import log_with_context 8 | 9 | 10 | def handle_task(task: ExternalTask): 11 | log_context = {"WORKER_ID": task.get_worker_id(), 12 | "TASK_ID": task.get_task_id(), 13 | "TOPIC": task.get_topic_name()} 14 | 15 | log_with_context(f"handle_task started: business key = {task.get_business_key()}", log_context) 16 | 17 | # simulate task execution 18 | execution_time = randint(0, 10) 19 | log_with_context(f"handle_task - business logic execution started for task: " 20 | f"it will execute for {execution_time} seconds", log_context) 21 | time.sleep(execution_time) 22 | 23 | # simulate that task results randomly into failure/BPMN error/complete 24 | failure = random_true() 25 | bpmn_error = False if failure else random_true() 26 | # override the values to simulate success/failure/BPMN error explicitly (if needed) 27 | failure, bpmn_error = False, False 28 | log_with_context(f"handle_task - business logic executed: failure: {failure}, bpmn_error: {bpmn_error}", 29 | log_context) 30 | 31 | return __handle_task_result(task, failure, bpmn_error) 32 | 33 | 34 | def __handle_task_result(task, failure, bpmn_error): 35 | if failure: 36 | return task.failure("task failed", "failed task details", 3, 5000) 37 | elif bpmn_error: 38 | return task.bpmn_error("BPMN_ERROR_CODE") 39 | return task.complete({"success": True, "task_completed_on": str(datetime.now())}) 40 | 41 | 42 | def random_true(): 43 | current_milli_time = int(round(time.time() * 1000)) 44 | return current_milli_time % 2 == 0 45 | -------------------------------------------------------------------------------- /examples/tasks_example.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from concurrent.futures.thread import ThreadPoolExecutor 3 | 4 | from camunda.external_task.external_task_worker import ExternalTaskWorker 5 | from examples.task_handler_example import handle_task 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | default_config = { 10 | "maxTasks": 1, 11 | "lockDuration": 10000, 12 | "asyncResponseTimeout": 3000, 13 | "retries": 3, 14 | "retryTimeout": 5000, 15 | "sleepSeconds": 30, 16 | "isDebug": True, 17 | "httpTimeoutMillis": 3000, 18 | } 19 | 20 | 21 | def main(): 22 | configure_logging() 23 | topics = ["PARALLEL_STEP_1", "PARALLEL_STEP_2", "COMBINE_STEP"] 24 | executor = ThreadPoolExecutor(max_workers=len(topics)) 25 | for index, topic in enumerate(topics): 26 | executor.submit(ExternalTaskWorker(worker_id=index, config=default_config).subscribe, topic, handle_task, 27 | {"strVar": "hello"}) 28 | 29 | 30 | def configure_logging(): 31 | logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", 32 | handlers=[logging.StreamHandler()]) 33 | 34 | 35 | if __name__ == '__main__': 36 | main() 37 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=78", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "camunda_external_task_client_python3" 7 | version = "4.5.0" 8 | authors = [ 9 | { name = "Deserve Labs", email="devteam@deserve.com" }, 10 | ] 11 | dependencies = [ 12 | "requests>=2.24.0" 13 | ] 14 | description = "Camunda External Task Client for Python 3" 15 | readme = "README.md" 16 | requires-python = ">=3.7" 17 | classifiers = [ 18 | "Intended Audience :: Developers", 19 | "Operating System :: OS Independent", 20 | "Programming Language :: Python :: 3", 21 | "Programming Language :: Python :: 3 :: Only", 22 | "Programming Language :: Python :: 3.7", 23 | "Programming Language :: Python :: 3.8", 24 | "Programming Language :: Python :: 3.9", 25 | "Programming Language :: Python :: 3.10", 26 | "Topic :: Software Development :: Libraries", 27 | ] 28 | license = "Apache-2.0" 29 | license-files = ["LICEN[CS]E*"] 30 | 31 | [project.urls] 32 | Homepage = "https://github.com/camunda-community-hub/camunda-external-task-client-python3" 33 | Issues = "https://github.com/camunda-community-hub/camunda-external-task-client-python3/issues" 34 | 35 | [tool.setuptools] 36 | package-dir = {"" = "."} 37 | 38 | [tool.setuptools.packages.find] 39 | where = ["."] 40 | exclude = ["tests"] 41 | include = ["*"] -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests==2.26.0 2 | responses==0.14.0 3 | pytest-cov==3.0.0 4 | pydantic==1.8.2 5 | httpx==0.27.2 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | setup() --------------------------------------------------------------------------------