├── .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 | [](https://github.com/camunda-community-hub/community)[](https://github.com/Camunda-Community-Hub/community/blob/main/extension-lifecycle.md#stable-)
2 |
3 | # camunda-external-task-client-python3
4 | 
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()
--------------------------------------------------------------------------------