├── pyzeebe ├── py.typed ├── job │ ├── __init__.py │ ├── job_status.py │ └── job.py ├── client │ ├── __init__.py │ └── sync_client.py ├── task │ ├── __init__.py │ ├── types.py │ ├── exception_handler.py │ ├── task.py │ ├── task_config.py │ └── task_builder.py ├── worker │ ├── __init__.py │ ├── task_state.py │ ├── job_executor.py │ └── job_poller.py ├── grpc_internals │ ├── __init__.py │ ├── grpc_utils.py │ ├── zeebe_adapter.py │ ├── zeebe_cluster_adapter.py │ ├── zeebe_message_adapter.py │ └── zeebe_adapter_base.py ├── proto │ └── __init__.py ├── errors │ ├── message_errors.py │ ├── credentials_errors.py │ ├── pyzeebe_errors.py │ ├── zeebe_errors.py │ ├── job_errors.py │ ├── process_errors.py │ └── __init__.py ├── credentials │ └── __init__.py ├── types.py ├── channel │ ├── __init__.py │ ├── insecure_channel.py │ ├── secure_channel.py │ ├── channel_options.py │ └── utils.py ├── function_tools │ ├── __init__.py │ ├── dict_tools.py │ ├── parameter_tools.py │ └── async_tools.py └── __init__.py ├── tests ├── __init__.py ├── unit │ ├── __init__.py │ ├── job │ │ ├── __init__.py │ │ └── job_test.py │ ├── task │ │ ├── __init__.py │ │ └── task_config_test.py │ ├── utils │ │ ├── __init__.py │ │ ├── grpc_utils.py │ │ ├── function_tools.py │ │ ├── dummy_functions.py │ │ └── random_utils.py │ ├── channel │ │ ├── __init__.py │ │ ├── channel_options_test.py │ │ ├── insecure_channel_test.py │ │ ├── secure_channel_test.py │ │ └── oauth_channel_test.py │ ├── client │ │ ├── __init__.py │ │ ├── conftest.py │ │ ├── client_test.py │ │ └── sync_client_test.py │ ├── worker │ │ ├── __init__.py │ │ ├── conftest.py │ │ ├── task_state_test.py │ │ ├── job_executor_test.py │ │ └── task_router_test.py │ ├── credentials │ │ └── __init__.py │ ├── function_tools │ │ ├── __init__.py │ │ ├── dict_tools_test.py │ │ ├── async_tools_test.py │ │ └── parameter_tools_test.py │ ├── grpc_internals │ │ ├── __init__.py │ │ ├── grpc_utils_test.py │ │ ├── zeebe_cluster_adapter_test.py │ │ ├── zeebe_message_adapter_test.py │ │ └── zeebe_adapter_base_test.py │ └── conftest.py └── integration │ ├── __init__.py │ ├── utils │ ├── process_run.py │ ├── __init__.py │ ├── wait_for_process.py │ └── process_stats.py │ ├── topology_test.py │ ├── healthcheck_test.py │ ├── cancel_process_test.py │ ├── publish_message_test.py │ ├── evaluate_decision_test.py │ ├── run_process_test.py │ ├── test.dmn │ ├── test.bpmn │ └── conftest.py ├── docs ├── _static │ └── .gitkeep ├── zeebe_adapter.rst ├── client.rst ├── channels.rst ├── client_reference.rst ├── worker.rst ├── Makefile ├── worker_reference.rst ├── make.bat ├── channels_reference.rst ├── worker_taskrouter.rst ├── index.rst ├── client_quickstart.rst ├── errors.rst ├── zeebe_adapter_reference.rst ├── worker_quickstart.rst ├── conf.py ├── decorators.rst ├── channels_configuration.rst ├── worker_tasks.rst └── channels_quickstart.rst ├── renovate.json ├── .github ├── dependabot.yml ├── workflows │ ├── publish.yml │ ├── documentation.yml │ ├── lint.yml │ └── test.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── PULL_REQUEST_TEMPLATE.md ├── .readthedocs.yml ├── LICENSE.md ├── .pre-commit-config.yaml ├── examples ├── client.py └── worker.py ├── update_proto.py ├── .gitignore ├── pyproject.toml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md └── README.md /pyzeebe/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/_static/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pyzeebe/job/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pyzeebe/client/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pyzeebe/task/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pyzeebe/worker/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/job/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/task/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/channel/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/client/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/worker/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pyzeebe/grpc_internals/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/credentials/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/function_tools/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/grpc_internals/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pyzeebe/proto/__init__.py: -------------------------------------------------------------------------------- 1 | from . import gateway_pb2 as gateway_pb2 2 | from . import gateway_pb2_grpc as gateway_pb2_grpc 3 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /tests/unit/utils/grpc_utils.py: -------------------------------------------------------------------------------- 1 | import grpc 2 | 3 | 4 | class GRPCStatusCode: 5 | def __init__(self, code: grpc.StatusCode): 6 | self.code = code 7 | -------------------------------------------------------------------------------- /pyzeebe/errors/message_errors.py: -------------------------------------------------------------------------------- 1 | from pyzeebe.errors.pyzeebe_errors import PyZeebeError 2 | 3 | 4 | class MessageAlreadyExistsError(PyZeebeError): 5 | pass 6 | -------------------------------------------------------------------------------- /pyzeebe/errors/credentials_errors.py: -------------------------------------------------------------------------------- 1 | from pyzeebe.errors.pyzeebe_errors import PyZeebeError 2 | 3 | 4 | class InvalidOAuthCredentialsError(PyZeebeError): 5 | pass 6 | -------------------------------------------------------------------------------- /tests/unit/worker/conftest.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import pytest 4 | 5 | 6 | @pytest.fixture 7 | async def queue() -> asyncio.Queue: 8 | return asyncio.Queue() 9 | -------------------------------------------------------------------------------- /docs/zeebe_adapter.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | Zeebe Adapter 3 | ============= 4 | 5 | .. toctree:: 6 | :name: zeebe_adapter 7 | 8 | Reference 9 | -------------------------------------------------------------------------------- /docs/client.rst: -------------------------------------------------------------------------------- 1 | ====== 2 | Client 3 | ====== 4 | 5 | .. toctree:: 6 | :name: client 7 | 8 | Quickstart 9 | Reference 10 | -------------------------------------------------------------------------------- /pyzeebe/grpc_internals/grpc_utils.py: -------------------------------------------------------------------------------- 1 | import grpc 2 | 3 | 4 | def is_error_status(rpc_error: grpc.aio.AioRpcError, *status_codes: grpc.StatusCode) -> bool: 5 | return rpc_error.code() in status_codes 6 | -------------------------------------------------------------------------------- /tests/integration/utils/process_run.py: -------------------------------------------------------------------------------- 1 | class ProcessRun: 2 | def __init__(self, instance_key: int, variables: dict): 3 | self.instance_key = instance_key 4 | self.variables = variables 5 | -------------------------------------------------------------------------------- /pyzeebe/credentials/__init__.py: -------------------------------------------------------------------------------- 1 | from .oauth import Oauth2ClientCredentialsMetadataPlugin, OAuth2MetadataPlugin 2 | 3 | __all__ = ( 4 | "Oauth2ClientCredentialsMetadataPlugin", 5 | "OAuth2MetadataPlugin", 6 | ) 7 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | target-branch: "master" 6 | schedule: 7 | interval: "daily" 8 | labels: 9 | - "dependencies" 10 | -------------------------------------------------------------------------------- /docs/channels.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Channels 3 | ======== 4 | 5 | .. toctree:: 6 | :name: channels 7 | 8 | Quickstart 9 | Configuration 10 | Reference 11 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | image: latest 5 | python: 6 | version: 3.9 7 | install: 8 | - method: pip 9 | path: . 10 | 11 | sphinx: 12 | builder: html 13 | configuration: docs/conf.py 14 | fail_on_warning: true 15 | -------------------------------------------------------------------------------- /pyzeebe/job/job_status.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class JobStatus(Enum): 5 | Running = "Running" 6 | Completed = "Completed" 7 | RunningAfterDecorators = "RunningAfterDecorators" 8 | Failed = "Failed" 9 | ErrorThrown = "ErrorThrown" 10 | -------------------------------------------------------------------------------- /tests/unit/utils/function_tools.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | 3 | from pyzeebe.function_tools import async_tools 4 | 5 | 6 | def functions_are_all_async(functions: list[Callable]) -> bool: 7 | return all(async_tools.is_async_function(function) for function in functions) 8 | -------------------------------------------------------------------------------- /tests/integration/topology_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pyzeebe import ZeebeClient 4 | 5 | pytestmark = [pytest.mark.e2e, pytest.mark.anyio] 6 | 7 | 8 | async def test_topology(zeebe_client: ZeebeClient): 9 | topology = await zeebe_client.topology() 10 | 11 | assert topology.cluster_size == 1 12 | -------------------------------------------------------------------------------- /docs/client_reference.rst: -------------------------------------------------------------------------------- 1 | ================ 2 | Client Reference 3 | ================ 4 | 5 | .. autoclass:: pyzeebe.ZeebeClient 6 | :members: 7 | :undoc-members: 8 | :special-members: __init__ 9 | 10 | .. autoclass:: pyzeebe.SyncZeebeClient 11 | :members: 12 | :undoc-members: 13 | :special-members: __init__ 14 | -------------------------------------------------------------------------------- /docs/worker.rst: -------------------------------------------------------------------------------- 1 | ====== 2 | Worker 3 | ====== 4 | 5 | The page contains all information about the :py:class:`ZeebeWorker` class: 6 | 7 | 8 | .. toctree:: 9 | :name: worker 10 | 11 | Quickstart 12 | Tasks 13 | TaskRouter 14 | Reference 15 | -------------------------------------------------------------------------------- /tests/integration/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .process_run import ProcessRun 2 | from .process_stats import ProcessStats 3 | from .wait_for_process import wait_for_process, wait_for_process_with_variables 4 | 5 | __all__ = ( 6 | "ProcessRun", 7 | "ProcessStats", 8 | "wait_for_process", 9 | "wait_for_process_with_variables", 10 | ) 11 | -------------------------------------------------------------------------------- /tests/integration/healthcheck_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pyzeebe import ZeebeClient 4 | 5 | pytestmark = [pytest.mark.e2e, pytest.mark.anyio] 6 | 7 | 8 | async def test_ping(zeebe_client: ZeebeClient): 9 | healthcheck = await zeebe_client.healthcheck() 10 | 11 | assert healthcheck.status == healthcheck.ServingStatus.SERVING 12 | -------------------------------------------------------------------------------- /tests/unit/client/conftest.py: -------------------------------------------------------------------------------- 1 | from random import randint 2 | from uuid import uuid4 3 | 4 | import pytest 5 | 6 | 7 | @pytest.fixture 8 | def deployed_process(grpc_servicer): 9 | bpmn_process_id = str(uuid4()) 10 | version = randint(0, 10) 11 | grpc_servicer.mock_deploy_process(bpmn_process_id, version, []) 12 | return bpmn_process_id, version 13 | -------------------------------------------------------------------------------- /tests/integration/cancel_process_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pyzeebe import ZeebeClient 4 | 5 | pytestmark = [pytest.mark.e2e, pytest.mark.anyio] 6 | 7 | 8 | async def test_cancel_process(zeebe_client: ZeebeClient, process_name: str, process_variables: dict): 9 | response = await zeebe_client.run_process(process_name, process_variables) 10 | 11 | await zeebe_client.cancel_process_instance(response.process_instance_key) 12 | -------------------------------------------------------------------------------- /tests/unit/function_tools/dict_tools_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pyzeebe.function_tools import dict_tools 4 | 5 | 6 | @pytest.mark.anyio 7 | class TestConvertToDictFunction: 8 | async def test_converting_to_dict(self): 9 | async def original_function(x): 10 | return x 11 | 12 | dict_function = dict_tools.convert_to_dict_function(original_function, "x") 13 | 14 | assert {"x": 1} == await dict_function(1) 15 | -------------------------------------------------------------------------------- /pyzeebe/task/types.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Awaitable 2 | from typing import Callable, Union 3 | 4 | from pyzeebe import Job 5 | from pyzeebe.job.job import JobController 6 | 7 | DecoratorRunner = Callable[[Job], Awaitable[Job]] 8 | JobHandler = Callable[[Job, JobController], Awaitable[Job]] 9 | 10 | SyncTaskDecorator = Callable[[Job], Job] 11 | AsyncTaskDecorator = Callable[[Job], Awaitable[Job]] 12 | TaskDecorator = Union[SyncTaskDecorator, AsyncTaskDecorator] 13 | -------------------------------------------------------------------------------- /pyzeebe/types.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Mapping, Sequence 2 | from typing import Any, Union 3 | 4 | from typing_extensions import TypeAlias 5 | 6 | Headers: TypeAlias = Mapping[str, Any] 7 | Unset = "UNSET" 8 | 9 | ChannelArgumentType: TypeAlias = Sequence[tuple[str, Any]] 10 | 11 | JsonType: TypeAlias = Union[Mapping[str, "JsonType"], Sequence["JsonType"], str, int, float, bool, None] 12 | JsonDictType: TypeAlias = Mapping[str, JsonType] 13 | Variables: TypeAlias = JsonDictType 14 | -------------------------------------------------------------------------------- /pyzeebe/channel/__init__.py: -------------------------------------------------------------------------------- 1 | from pyzeebe.channel.insecure_channel import create_insecure_channel 2 | from pyzeebe.channel.oauth_channel import ( 3 | create_camunda_cloud_channel, 4 | create_oauth2_client_credentials_channel, 5 | ) 6 | from pyzeebe.channel.secure_channel import create_secure_channel 7 | 8 | __all__ = ( 9 | "create_insecure_channel", 10 | "create_camunda_cloud_channel", 11 | "create_oauth2_client_credentials_channel", 12 | "create_secure_channel", 13 | ) 14 | -------------------------------------------------------------------------------- /pyzeebe/grpc_internals/zeebe_adapter.py: -------------------------------------------------------------------------------- 1 | from pyzeebe.grpc_internals.zeebe_cluster_adapter import ZeebeClusterAdapter 2 | from pyzeebe.grpc_internals.zeebe_job_adapter import ZeebeJobAdapter 3 | from pyzeebe.grpc_internals.zeebe_message_adapter import ZeebeMessageAdapter 4 | from pyzeebe.grpc_internals.zeebe_process_adapter import ZeebeProcessAdapter 5 | 6 | 7 | # Mixin class 8 | class ZeebeAdapter(ZeebeClusterAdapter, ZeebeProcessAdapter, ZeebeJobAdapter, ZeebeMessageAdapter): 9 | pass 10 | -------------------------------------------------------------------------------- /tests/integration/utils/wait_for_process.py: -------------------------------------------------------------------------------- 1 | from anyio import sleep 2 | 3 | from .process_stats import ProcessStats 4 | 5 | 6 | async def wait_for_process(process_instance_key: int, process_stats: ProcessStats, interval: float = 0.2): 7 | while not process_stats.has_process_been_run(process_instance_key): 8 | await sleep(interval) 9 | 10 | 11 | async def wait_for_process_with_variables(process_stats: ProcessStats, variables: dict, interval: float = 0.2): 12 | while not process_stats.has_process_with_variables_been_run(variables): 13 | await sleep(interval) 14 | -------------------------------------------------------------------------------- /pyzeebe/function_tools/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Awaitable 4 | from typing import Any, Callable, Optional, TypeVar, Union 5 | 6 | from typing_extensions import ParamSpec 7 | 8 | Parameters = ParamSpec("Parameters") 9 | ReturnType = TypeVar("ReturnType") 10 | 11 | SyncFunction = Callable[Parameters, ReturnType] 12 | AsyncFunction = Callable[Parameters, Awaitable[ReturnType]] 13 | Function = Union[SyncFunction[Parameters, ReturnType], AsyncFunction[Parameters, ReturnType]] 14 | 15 | DictFunction = Callable[Parameters, Awaitable[Optional[dict[str, Any]]]] 16 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [created, prereleased] 6 | 7 | jobs: 8 | publish: 9 | 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v6 14 | - name: Set up Python 15 | uses: actions/setup-python@v6 16 | with: 17 | python-version: '3.14' 18 | - name: Install uv 19 | uses: astral-sh/setup-uv@v7 20 | with: 21 | version: "0.9.5" 22 | - name: Build and publish 23 | env: 24 | UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }} 25 | run: | 26 | uv build 27 | uv publish 28 | -------------------------------------------------------------------------------- /pyzeebe/function_tools/dict_tools.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import functools 4 | from typing import Any, TypeVar 5 | 6 | from typing_extensions import ParamSpec 7 | 8 | from pyzeebe.function_tools import AsyncFunction, DictFunction 9 | 10 | P = ParamSpec("P") 11 | R = TypeVar("R") 12 | 13 | 14 | def convert_to_dict_function(single_value_function: AsyncFunction[P, R], variable_name: str) -> DictFunction[P]: 15 | @functools.wraps(single_value_function) 16 | async def inner_fn(*args: P.args, **kwargs: P.kwargs) -> dict[str, Any]: 17 | return {variable_name: await single_value_function(*args, **kwargs)} 18 | 19 | return inner_fn 20 | -------------------------------------------------------------------------------- /pyzeebe/worker/task_state.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from pyzeebe import Job 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | 8 | class TaskState: 9 | def __init__(self) -> None: 10 | self._active_jobs: list[int] = [] 11 | 12 | def remove(self, job: Job) -> None: 13 | try: 14 | self._active_jobs.remove(job.key) 15 | except ValueError: 16 | logger.warning("Could not find Job key %s when trying to remove from TaskState", job.key) 17 | 18 | def add(self, job: Job) -> None: 19 | self._active_jobs.append(job.key) 20 | 21 | def count_active(self) -> int: 22 | return len(self._active_jobs) 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /tests/integration/utils/process_stats.py: -------------------------------------------------------------------------------- 1 | from .process_run import ProcessRun 2 | 3 | 4 | class ProcessStats: 5 | def __init__(self, process_name: str): 6 | self.process_name = process_name 7 | self.runs: list[ProcessRun] = [] 8 | 9 | def add_process_run(self, process: ProcessRun): 10 | self.runs.append(process) 11 | 12 | def has_process_been_run(self, process_instance_key: int) -> bool: 13 | return any(run.instance_key == process_instance_key for run in self.runs if run.instance_key) 14 | 15 | def has_process_with_variables_been_run(self, variables: dict) -> bool: 16 | return any(run.variables == variables for run in self.runs) 17 | 18 | def get_process_runs(self) -> int: 19 | return len(self.runs) 20 | -------------------------------------------------------------------------------- /tests/integration/publish_message_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pyzeebe import ZeebeClient 4 | from tests.integration.utils import ProcessStats 5 | from tests.integration.utils.wait_for_process import wait_for_process_with_variables 6 | 7 | pytestmark = [pytest.mark.e2e, pytest.mark.anyio] 8 | 9 | 10 | async def test_publish_message(zeebe_client: ZeebeClient, process_stats: ProcessStats, process_variables: dict): 11 | initial_amount_of_processes = process_stats.get_process_runs() 12 | 13 | await zeebe_client.publish_message("start_test_process", correlation_key="", variables=process_variables) 14 | await wait_for_process_with_variables(process_stats, process_variables) 15 | 16 | assert process_stats.get_process_runs() == initial_amount_of_processes + 1 17 | -------------------------------------------------------------------------------- /pyzeebe/task/exception_handler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from collections.abc import Awaitable 3 | from typing import Callable 4 | 5 | from pyzeebe.errors.pyzeebe_errors import BusinessError 6 | from pyzeebe.job.job import Job, JobController 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | ExceptionHandler = Callable[[Exception, Job, JobController], Awaitable[None]] 11 | 12 | 13 | async def default_exception_handler(e: Exception, job: Job, job_controller: JobController) -> None: 14 | logger.warning("Task type: %s - failed job %s. Error: %s.", job.type, job, e) 15 | if isinstance(e, BusinessError): 16 | await job_controller.set_error_status(f"Failed job. Recoverable error: {e}", error_code=e.error_code) 17 | else: 18 | await job_controller.set_failure_status(f"Failed job. Error: {e}") 19 | -------------------------------------------------------------------------------- /tests/unit/grpc_internals/grpc_utils_test.py: -------------------------------------------------------------------------------- 1 | import grpc 2 | 3 | from pyzeebe.grpc_internals import grpc_utils 4 | 5 | 6 | class TestIsErrorStatus: 7 | error = grpc.aio.AioRpcError(grpc.StatusCode.OK, None, None) 8 | matching_status_code = grpc.StatusCode.OK 9 | unmatching_status_code = grpc.StatusCode.UNKNOWN 10 | 11 | def test_with_matching_code_returns_true(self): 12 | assert grpc_utils.is_error_status(self.error, self.matching_status_code) 13 | 14 | def test_with_no_matching_code_returns_false(self): 15 | assert not grpc_utils.is_error_status(self.error, self.unmatching_status_code) 16 | 17 | def test_with_matching_and_unmatching_code_returns_true(self): 18 | assert grpc_utils.is_error_status(self.error, self.matching_status_code, self.unmatching_status_code) 19 | -------------------------------------------------------------------------------- /pyzeebe/task/task.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | 5 | from pyzeebe.function_tools import Function 6 | from pyzeebe.task.task_config import TaskConfig 7 | from pyzeebe.task.types import JobHandler 8 | 9 | 10 | class Task: 11 | def __init__(self, original_function: Function[..., Any], job_handler: JobHandler, config: TaskConfig) -> None: 12 | self.original_function = original_function 13 | self.job_handler = job_handler 14 | self.config = config 15 | 16 | @property 17 | def type(self) -> str: 18 | return self.config.type 19 | 20 | def __repr__(self) -> str: 21 | return ( 22 | f"Task(config= {self.config}, original_function={self.original_function}, " 23 | f"job_handler={self.job_handler})" 24 | ) 25 | -------------------------------------------------------------------------------- /tests/unit/grpc_internals/zeebe_cluster_adapter_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pyzeebe.grpc_internals.types import HealthCheckResponse, TopologyResponse 4 | from pyzeebe.grpc_internals.zeebe_adapter import ZeebeAdapter 5 | 6 | 7 | @pytest.mark.anyio 8 | class TestTopology: 9 | async def test_response_is_of_correct_type(self, zeebe_adapter: ZeebeAdapter): 10 | response = await zeebe_adapter.topology() 11 | 12 | assert isinstance(response, TopologyResponse) 13 | 14 | 15 | @pytest.mark.xfail(reason="Required GRPC health checking stubs") 16 | @pytest.mark.anyio 17 | class TestHealthCheck: 18 | async def test_response_is_of_correct_type(self, zeebe_adapter: ZeebeAdapter): 19 | response = await zeebe_adapter.healthcheck() 20 | 21 | assert isinstance(response, HealthCheckResponse) 22 | -------------------------------------------------------------------------------- /docs/worker_reference.rst: -------------------------------------------------------------------------------- 1 | ================ 2 | Worker Reference 3 | ================ 4 | 5 | The :py:class:`ZeebeWorker` class inherits from :py:class:`ZeebeTaskRouter` class. 6 | This means that all methods that :py:class:`ZeebeTaskRouter` has will also appear in :py:class:`ZeebeWorker`. 7 | 8 | .. autoclass:: pyzeebe.ZeebeTaskRouter 9 | :members: 10 | :undoc-members: 11 | :special-members: __init__ 12 | 13 | 14 | .. autoclass:: pyzeebe.ZeebeWorker 15 | :members: 16 | :undoc-members: 17 | :special-members: __init__ 18 | 19 | 20 | .. autoclass:: pyzeebe.Job 21 | :members: 22 | :undoc-members: 23 | :member-order: bysource 24 | 25 | 26 | .. autoclass:: pyzeebe.JobController 27 | :members: 28 | :undoc-members: 29 | 30 | 31 | .. autoclass:: pyzeebe.JobStatus 32 | :members: 33 | :undoc-members: 34 | -------------------------------------------------------------------------------- /tests/unit/channel/channel_options_test.py: -------------------------------------------------------------------------------- 1 | from pyzeebe.channel.channel_options import get_channel_options 2 | 3 | 4 | def test_get_channel_options_returns_tuple_of_tuple_with_options(): 5 | assert get_channel_options() == (("grpc.keepalive_time_ms", 45_000),) 6 | 7 | 8 | def test_overrides_default_values_if_provided(): 9 | grpc_options = (("grpc.keepalive_time_ms", 4_000),) 10 | 11 | assert get_channel_options(grpc_options) == (("grpc.keepalive_time_ms", 4_000),) 12 | 13 | 14 | def test_adds_custom_options(): 15 | grpc_options = ( 16 | ("grpc.keepalive_timeout_ms", 120_000), 17 | ("grpc.http2.min_time_between_pings_ms", 60_000), 18 | ) 19 | 20 | assert get_channel_options(grpc_options) == ( 21 | ("grpc.keepalive_timeout_ms", 120_000), 22 | ("grpc.http2.min_time_between_pings_ms", 60_000), 23 | ("grpc.keepalive_time_ms", 45_000), 24 | ) 25 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /tests/unit/utils/dummy_functions.py: -------------------------------------------------------------------------------- 1 | from pyzeebe.job.job import Job 2 | 3 | 4 | def no_param(): 5 | pass 6 | 7 | 8 | def one_param(x): 9 | pass 10 | 11 | 12 | def multiple_params(x, y, z): 13 | pass 14 | 15 | 16 | def one_keyword_param(x=0): 17 | pass 18 | 19 | 20 | def multiple_keyword_param(x=0, y=0, z=0): 21 | pass 22 | 23 | 24 | def positional_and_keyword_params(x, y=0): 25 | pass 26 | 27 | 28 | def args_param(*args): 29 | pass 30 | 31 | 32 | def kwargs_param(**kwargs): 33 | pass 34 | 35 | 36 | def standard_named_params(args, kwargs): 37 | pass 38 | 39 | 40 | def with_job_parameter(job: Job): 41 | pass 42 | 43 | 44 | def with_job_parameter_and_param(x, job: Job): 45 | pass 46 | 47 | 48 | def with_multiple_job_parameters(job: Job, job2: Job): 49 | pass 50 | 51 | 52 | lambda_no_params = lambda: None 53 | lambda_one_param = lambda x: None 54 | lambda_multiple_params = lambda x, y, z: None 55 | lambda_one_keyword_param = lambda x=0: None 56 | lambda_multiple_keyword_params = lambda x=0, y=0, z=0: None 57 | lambda_positional_and_keyword_params = lambda x, y=0: None 58 | -------------------------------------------------------------------------------- /pyzeebe/channel/insecure_channel.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import grpc 4 | 5 | from pyzeebe.channel.channel_options import get_channel_options 6 | from pyzeebe.channel.utils import get_zeebe_address 7 | from pyzeebe.types import ChannelArgumentType, Unset 8 | 9 | 10 | def create_insecure_channel( 11 | grpc_address: str = Unset, channel_options: ChannelArgumentType | None = None 12 | ) -> grpc.aio.Channel: 13 | """ 14 | Create an insecure channel 15 | 16 | Args: 17 | grpc_address (Optional[str]): Zeebe Gateway Address 18 | Default: None, alias the ZEEBE_ADDRESS environment variable or "localhost:26500" 19 | channel_options (Optional[ChannelArgumentType]): GRPC channel options. 20 | See https://grpc.github.io/grpc/python/glossary.html#term-channel_arguments 21 | 22 | Returns: 23 | grpc.aio.Channel: A GRPC Channel connected to the Zeebe gateway. 24 | """ 25 | if grpc_address is Unset: 26 | grpc_address = get_zeebe_address() 27 | 28 | return grpc.aio.insecure_channel(target=grpc_address, options=get_channel_options(channel_options)) 29 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Jonatan Martens 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/unit/worker/task_state_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pyzeebe.job.job import Job 4 | from pyzeebe.worker.task_state import TaskState 5 | 6 | 7 | @pytest.fixture 8 | def task_state(): 9 | return TaskState() 10 | 11 | 12 | def test_new_task_state_has_0_active_jobs(task_state: TaskState): 13 | assert task_state.count_active() == 0 14 | 15 | 16 | def test_add_counts_as_active(task_state: TaskState, job_from_task): 17 | task_state.add(job_from_task) 18 | assert task_state.count_active() == 1 19 | 20 | 21 | def test_add_then_remove_results_in_0_active(task_state: TaskState, job_from_task: Job): 22 | task_state.add(job_from_task) 23 | task_state.remove(job_from_task) 24 | assert task_state.count_active() == 0 25 | 26 | 27 | def test_remove_non_existing_job_dont_withdraw_from_active_jobs(task_state: TaskState, job_from_task: Job): 28 | task_state.remove(job_from_task) 29 | assert task_state.count_active() == 0 30 | 31 | 32 | def test_add_already_activated_job_does_not_raise_an_error(task_state: TaskState, job_from_task: Job): 33 | task_state.add(job_from_task) 34 | task_state.add(job_from_task) 35 | -------------------------------------------------------------------------------- /pyzeebe/errors/pyzeebe_errors.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | class PyZeebeError(Exception): 5 | pass 6 | 7 | 8 | class TaskNotFoundError(PyZeebeError): 9 | pass 10 | 11 | 12 | class SettingsError(PyZeebeError): 13 | pass 14 | 15 | 16 | class NoVariableNameGivenError(PyZeebeError): 17 | def __init__(self, task_type: str): 18 | super().__init__(f"No variable name given for single_value task {task_type}") 19 | self.task_type = task_type 20 | 21 | 22 | class DuplicateTaskTypeError(PyZeebeError): 23 | def __init__(self, task_type: str): 24 | super().__init__(f"Task with type {task_type} already exists") 25 | self.task_type = task_type 26 | 27 | 28 | class BusinessError(PyZeebeError): 29 | """ 30 | Exception that can be raised with a user defined code, 31 | to be caught later by an error event in the Zeebe process 32 | """ 33 | 34 | def __init__(self, error_code: str, msg: str | None = None) -> None: 35 | if msg is None: 36 | msg = f"Business error with code {error_code}" 37 | super().__init__(msg) 38 | self.error_code = error_code 39 | -------------------------------------------------------------------------------- /tests/unit/utils/random_utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from random import randint 4 | from uuid import uuid4 5 | 6 | from pyzeebe.job.job import Job 7 | from pyzeebe.task import task_builder 8 | from pyzeebe.task.task import Task 9 | from pyzeebe.task.task_config import TaskConfig 10 | 11 | RANDOM_RANGE = 1000000000 12 | 13 | 14 | def random_job( 15 | task: Task = task_builder.build_task( 16 | lambda x: {"x": x}, TaskConfig("test", lambda: None, 10000, 32, 32, [], False, "", [], []) 17 | ), 18 | variables: dict | None = None, 19 | ) -> Job: 20 | return Job( 21 | type=task.type, 22 | key=randint(0, RANDOM_RANGE), 23 | worker=str(uuid4()), 24 | retries=randint(0, 10), 25 | process_instance_key=randint(0, RANDOM_RANGE), 26 | bpmn_process_id=str(uuid4()), 27 | process_definition_version=randint(0, 100), 28 | process_definition_key=randint(0, RANDOM_RANGE), 29 | element_id=str(uuid4()), 30 | element_instance_key=randint(0, RANDOM_RANGE), 31 | variables=variables or {}, 32 | custom_headers={}, 33 | deadline=randint(0, RANDOM_RANGE), 34 | ) 35 | -------------------------------------------------------------------------------- /pyzeebe/function_tools/parameter_tools.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import inspect 4 | from typing import Any 5 | 6 | from pyzeebe.function_tools import Function 7 | from pyzeebe.job.job import Job 8 | 9 | 10 | def get_parameters_from_function(task_function: Function[..., Any]) -> list[str] | None: 11 | function_signature = inspect.signature(task_function) 12 | for _, parameter in function_signature.parameters.items(): 13 | if parameter.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD): 14 | return [] 15 | 16 | if not function_signature.parameters: 17 | return None 18 | 19 | if all(param.annotation == Job for param in function_signature.parameters.values()): 20 | return [] 21 | 22 | return [param.name for param in function_signature.parameters.values() if param.annotation != Job] 23 | 24 | 25 | def get_job_parameter_name(function: Function[..., Any]) -> str | None: 26 | function_signature = inspect.signature(function) 27 | params = list(function_signature.parameters.values()) 28 | for param in params: 29 | if param.annotation == Job: 30 | return param.name 31 | return None 32 | -------------------------------------------------------------------------------- /pyzeebe/__init__.py: -------------------------------------------------------------------------------- 1 | from pyzeebe import errors 2 | from pyzeebe.channel import ( 3 | create_camunda_cloud_channel, 4 | create_insecure_channel, 5 | create_oauth2_client_credentials_channel, 6 | create_secure_channel, 7 | ) 8 | from pyzeebe.client.client import ZeebeClient 9 | from pyzeebe.client.sync_client import SyncZeebeClient 10 | from pyzeebe.job.job import Job, JobController 11 | from pyzeebe.job.job_status import JobStatus 12 | from pyzeebe.task.exception_handler import ExceptionHandler, default_exception_handler 13 | from pyzeebe.task.task_config import TaskConfig 14 | from pyzeebe.task.types import TaskDecorator 15 | from pyzeebe.worker.task_router import ZeebeTaskRouter 16 | from pyzeebe.worker.worker import ZeebeWorker 17 | 18 | __all__ = ( 19 | "errors", 20 | "create_camunda_cloud_channel", 21 | "create_insecure_channel", 22 | "create_secure_channel", 23 | "create_oauth2_client_credentials_channel", 24 | "ZeebeClient", 25 | "SyncZeebeClient", 26 | "Job", 27 | "JobController", 28 | "JobStatus", 29 | "ExceptionHandler", 30 | "TaskConfig", 31 | "TaskDecorator", 32 | "ZeebeTaskRouter", 33 | "default_exception_handler", 34 | "ZeebeWorker", 35 | ) 36 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: check-toml 8 | - id: check-added-large-files 9 | - repo: local 10 | hooks: 11 | - id: black 12 | name: black 13 | entry: black 14 | language: python 15 | files: \.py$ 16 | exclude: ^(.*pb2.*|.*\.pyi)$ 17 | - repo: local 18 | hooks: 19 | - id: isort 20 | name: isort 21 | entry: isort 22 | language: python 23 | files: \.py$ 24 | exclude: ^(.*pb2.*|.*\.pyi)$ 25 | - repo: local 26 | hooks: 27 | - id: mypy 28 | name: mypy 29 | entry: mypy 30 | language: python 31 | pass_filenames: false 32 | - repo: https://github.com/asottile/pyupgrade 33 | rev: v3.17.0 34 | hooks: 35 | - id: pyupgrade 36 | args: [--py39-plus] 37 | exclude: ^(.*pb2.*|.*\.pyi)$ 38 | - repo: https://github.com/charliermarsh/ruff-pre-commit 39 | rev: v0.6.9 40 | hooks: 41 | - id: ruff 42 | args: 43 | - --fix 44 | exclude: ^(tests/.*|examples/.*|docs/.*|.*pb2.*|.*\.pyi)$ 45 | -------------------------------------------------------------------------------- /tests/integration/evaluate_decision_test.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | import pytest 4 | 5 | from pyzeebe import ZeebeClient 6 | from pyzeebe.errors import DecisionNotFoundError, InvalidJSONError 7 | 8 | pytestmark = [pytest.mark.e2e, pytest.mark.anyio] 9 | 10 | 11 | @pytest.mark.parametrize( 12 | ["input", "output"], 13 | ( 14 | pytest.param("1", "One"), 15 | pytest.param("2", {"foo": "bar"}), 16 | pytest.param("3", 3), 17 | ), 18 | ) 19 | async def test_evaluate_decision_by_id(input, output, zeebe_client: ZeebeClient, decision_id: str): 20 | response = await zeebe_client.evaluate_decision(None, decision_id, {"input": input}) 21 | 22 | assert response.decision_output == output 23 | assert response.evaluated_decisions[0].decision_output == output 24 | assert response.evaluated_decisions[0].matched_rules[0].evaluated_outputs[0].output_value == output 25 | 26 | 27 | async def test_evaluate_decision_by_key(zeebe_client: ZeebeClient, decision_key: int): 28 | response = await zeebe_client.evaluate_decision(decision_key, None, {"input": "1"}) 29 | 30 | assert response.decision_output == "One" 31 | 32 | 33 | async def test_non_existent_decision(zeebe_client: ZeebeClient): 34 | with pytest.raises(DecisionNotFoundError): 35 | await zeebe_client.evaluate_decision(1, str(uuid4())) 36 | -------------------------------------------------------------------------------- /.github/workflows/documentation.yml: -------------------------------------------------------------------------------- 1 | name: Documentation 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v6 14 | - uses: actions/setup-python@v6 15 | with: 16 | python-version: "3.14" 17 | - name: Install uv 18 | uses: astral-sh/setup-uv@v7 19 | with: 20 | version: "0.9.5" 21 | - name: Install the project 22 | run: uv sync --locked --all-extras --dev 23 | - name: Setup Pages 24 | id: pages 25 | uses: actions/configure-pages@v5 26 | - name: Sphinx build 27 | run: uv run sphinx-build --define html_baseurl="${{ steps.pages.outputs.base_url }}" docs _site/ 28 | - name: Upload artifact 29 | uses: actions/upload-pages-artifact@v4 30 | 31 | deploy: 32 | needs: build 33 | if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }} 34 | 35 | permissions: 36 | pages: write 37 | id-token: write 38 | 39 | environment: 40 | name: github-pages 41 | url: ${{ steps.deployment.outputs.page_url }} 42 | 43 | runs-on: ubuntu-latest 44 | steps: 45 | - name: Deploy to GitHub Pages 46 | id: deployment 47 | uses: actions/deploy-pages@v4 48 | -------------------------------------------------------------------------------- /docs/channels_reference.rst: -------------------------------------------------------------------------------- 1 | ================== 2 | Channels Reference 3 | ================== 4 | 5 | Channels 6 | -------- 7 | 8 | .. autofunction:: pyzeebe.create_insecure_channel 9 | 10 | .. autofunction:: pyzeebe.create_secure_channel 11 | 12 | .. autofunction:: pyzeebe.create_oauth2_client_credentials_channel 13 | 14 | .. autofunction:: pyzeebe.create_camunda_cloud_channel 15 | 16 | 17 | Credentials 18 | ----------- 19 | 20 | .. autoclass:: pyzeebe.credentials.OAuth2MetadataPlugin 21 | :members: 22 | :special-members: 23 | :private-members: 24 | 25 | .. autoclass:: pyzeebe.credentials.Oauth2ClientCredentialsMetadataPlugin 26 | :members: 27 | :special-members: 28 | :private-members: 29 | 30 | 31 | Utilities (Environment) 32 | ----------------------- 33 | 34 | .. autofunction:: pyzeebe.channel.utils.get_zeebe_address 35 | 36 | .. autofunction:: pyzeebe.channel.utils.get_camunda_oauth_url 37 | 38 | .. autofunction:: pyzeebe.channel.utils.get_camunda_client_id 39 | 40 | .. autofunction:: pyzeebe.channel.utils.get_camunda_client_secret 41 | 42 | .. autofunction:: pyzeebe.channel.utils.get_camunda_cluster_id 43 | 44 | .. autofunction:: pyzeebe.channel.utils.get_camunda_cluster_region 45 | 46 | .. autofunction:: pyzeebe.channel.utils.get_camunda_token_audience 47 | 48 | .. autofunction:: pyzeebe.channel.utils.get_camunda_address 49 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Description of PR... 2 | 3 | ## Changes 4 | 5 | - Item 1 6 | - Item 2 7 | 8 | ## API Updates 9 | 10 | ### New Features *(required)* 11 | 12 | This section should include details regarding new features added to the library. 13 | This should include both open and private APIs. If including both, create two 14 | sub headers underneath this one to label updates accordingly. 15 | 16 | ### Deprecations *(required)* 17 | 18 | All deprecations should be listed here in order to ensure the reviewer understands 19 | which sections of the codebase will affect contributors and users of the library. 20 | 21 | ### Enhancements *(optional)* 22 | 23 | The enhancements section should include code updates that were included with this 24 | pull request. This sections should detail refactoring that might have affected 25 | other parts of the library. 26 | 27 | ## Checklist 28 | 29 | - [ ] Unit tests 30 | - [ ] Documentation 31 | 32 | ## References 33 | 34 | (optional) 35 | 36 | Include __important__ links regarding the implementation of this PR. 37 | This usually includes and RFC or an aggregation of issues and/or individual conversations 38 | that helped put this solution together. This helps ensure there is a good aggregation 39 | of resources regarding the implementation. 40 | 41 | Fixes #85, Fixes #22, Fixes username/repo#123 42 | Connects #123 43 | -------------------------------------------------------------------------------- /tests/integration/run_process_test.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | import pytest 4 | 5 | from pyzeebe import ZeebeClient 6 | from pyzeebe.errors import ProcessDefinitionNotFoundError 7 | from tests.integration.utils import ProcessStats, wait_for_process 8 | 9 | pytestmark = [pytest.mark.e2e, pytest.mark.anyio] 10 | 11 | PROCESS_TIMEOUT_IN_MS = 60_000 12 | 13 | 14 | async def test_run_process( 15 | zeebe_client: ZeebeClient, process_name: str, process_variables: dict, process_stats: ProcessStats 16 | ): 17 | initial_amount_of_processes = process_stats.get_process_runs() 18 | 19 | process_instance_key = await zeebe_client.run_process(process_name, process_variables) 20 | await wait_for_process(process_instance_key.process_instance_key, process_stats) 21 | 22 | assert process_stats.get_process_runs() == initial_amount_of_processes + 1 23 | 24 | 25 | async def test_non_existent_process(zeebe_client: ZeebeClient): 26 | with pytest.raises(ProcessDefinitionNotFoundError): 27 | await zeebe_client.run_process(str(uuid4())) 28 | 29 | 30 | async def test_run_process_with_result(zeebe_client: ZeebeClient, process_name: str, process_variables: dict): 31 | response = await zeebe_client.run_process_with_result( 32 | process_name, process_variables, timeout=PROCESS_TIMEOUT_IN_MS 33 | ) 34 | 35 | assert response.variables["output"].startswith(process_variables["input"]) 36 | -------------------------------------------------------------------------------- /tests/unit/task/task_config_test.py: -------------------------------------------------------------------------------- 1 | from pyzeebe.job.job import Job 2 | from pyzeebe.task.task_config import TaskConfig 3 | from tests.unit.utils.function_tools import functions_are_all_async 4 | 5 | 6 | class TestConstructor: 7 | async def async_decorator(self, job: Job) -> Job: 8 | return job 9 | 10 | def sync_decorator(self, job: Job) -> Job: 11 | return job 12 | 13 | def exception_handler( 14 | self, 15 | ): 16 | pass 17 | 18 | def test_before_decorators_are_async(self, task_type: str): 19 | task_config = TaskConfig( 20 | task_type, 21 | self.exception_handler, 22 | 10000, 23 | 32, 24 | 32, 25 | [], 26 | False, 27 | "", 28 | [self.sync_decorator, self.async_decorator], 29 | [], 30 | ) 31 | 32 | assert functions_are_all_async(task_config.before) 33 | 34 | def test_after_decorators_are_async(self, task_type: str): 35 | task_config = TaskConfig( 36 | task_type, 37 | self.exception_handler, 38 | 10000, 39 | 32, 40 | 32, 41 | [], 42 | False, 43 | "", 44 | [], 45 | [self.sync_decorator, self.async_decorator], 46 | ) 47 | 48 | assert functions_are_all_async(task_config.after) 49 | -------------------------------------------------------------------------------- /docs/worker_taskrouter.rst: -------------------------------------------------------------------------------- 1 | =========== 2 | Task Router 3 | =========== 4 | 5 | The :py:class:`ZeebeTaskRouter` class is responsible for routing tasks to a :py:class:`ZeebeWorker` instance. 6 | This helps with organization of large projects, where you can't import the worker in each file. 7 | 8 | Create a Router 9 | --------------- 10 | 11 | .. code-block:: python 12 | 13 | from pyzeebe import ZeebeTaskRouter 14 | 15 | router = ZeebeTaskRouter() 16 | 17 | Create a task with a Router 18 | --------------------------- 19 | 20 | Creating a task with a router is the exact same process as wiht a :py:class:`.ZeebeWorker` instance. 21 | 22 | .. code-block:: python 23 | 24 | @router.task(task_type="my_task") 25 | async def my_task(x: int): 26 | return {"y": x + 1} 27 | 28 | 29 | .. note:: 30 | 31 | The :py:meth:`.ZeebeTaskRouter.task` decorator has all the capabities of the :py:class:`.ZeebeWorker` class. 32 | 33 | Merge Router tasks to a worker 34 | ------------------------------ 35 | 36 | To add the router tasks to the worker we use the :py:func:`include_router` method on the worker. 37 | 38 | .. code-block:: python 39 | 40 | from my_task import router 41 | 42 | worker.include_router(router) 43 | 44 | 45 | Or to add multiple routers at once: 46 | 47 | .. code-block:: python 48 | 49 | worker.include_router(router1, router2, router3, ...) 50 | 51 | 52 | 53 | That's it! 54 | -------------------------------------------------------------------------------- /pyzeebe/errors/zeebe_errors.py: -------------------------------------------------------------------------------- 1 | import grpc 2 | 3 | from pyzeebe.errors.pyzeebe_errors import PyZeebeError 4 | 5 | 6 | class ZeebeError(PyZeebeError): 7 | """Base exception for all Zeebe errors.""" 8 | 9 | def __init__(self, grpc_error: grpc.aio.AioRpcError): 10 | super().__init__() 11 | self.grpc_error = grpc_error 12 | 13 | def __str__(self) -> str: 14 | return "{}(grpc_error={}(code={}, details={}, debug_error_string={}))".format( 15 | self.__class__.__qualname__, 16 | self.grpc_error.__class__.__qualname__, 17 | self.grpc_error._code, 18 | self.grpc_error._details, 19 | self.grpc_error._debug_error_string, 20 | ) 21 | 22 | def __repr__(self) -> str: 23 | return self.__str__() 24 | 25 | 26 | class ZeebeBackPressureError(ZeebeError): 27 | """If Zeebe is currently in back pressure (too many requests) 28 | 29 | See: https://docs.camunda.io/docs/self-managed/zeebe-deployment/operations/backpressure/ 30 | """ 31 | 32 | 33 | class ZeebeGatewayUnavailableError(ZeebeError): 34 | pass 35 | 36 | 37 | class ZeebeInternalError(ZeebeError): 38 | pass 39 | 40 | 41 | class ZeebeDeadlineExceeded(ZeebeError): 42 | """If Zeebe hasn't responded after a certain timeout 43 | 44 | See: https://grpc.io/docs/guides/deadlines/ 45 | """ 46 | 47 | 48 | class UnknownGrpcStatusCodeError(ZeebeError): 49 | pass 50 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to pyzeebe's documentation! 2 | =================================== 3 | Python client for Zeebe workflow engine 4 | 5 | Current version is |version|. 6 | 7 | 8 | Library installation 9 | ==================== 10 | 11 | .. code-block:: bash 12 | 13 | $ pip install pyzeebe 14 | 15 | Getting Started 16 | =============== 17 | 18 | Creating a worker 19 | 20 | .. code-block:: python 21 | 22 | from pyzeebe import ZeebeWorker, create_insecure_channel 23 | 24 | channel = create_insecure_channel() 25 | worker = ZeebeWorker(channel) 26 | 27 | @worker.task(task_type="my_task") 28 | async def my_task(x: int): 29 | return {"y": x + 1} 30 | 31 | await worker.work() 32 | 33 | Creating a client 34 | 35 | .. code-block:: python 36 | 37 | from pyzeebe import ZeebeClient, create_insecure_channel 38 | 39 | channel = create_insecure_channel() 40 | client = ZeebeClient(channel) 41 | 42 | await client.run_process("my_process") 43 | 44 | # Run process with variables: 45 | await client.run_process("my_process", variables={"x": 0}) 46 | 47 | 48 | Dependencies 49 | ============ 50 | 51 | * python 3.9+ 52 | * grpcio 53 | * protobuf 54 | * oauthlib 55 | * requests-oauthlib 56 | 57 | 58 | Table Of Contents 59 | ================= 60 | .. toctree:: 61 | :maxdepth: 2 62 | 63 | Client 64 | Worker 65 | Channels 66 | Decorators 67 | Exceptions 68 | Zeebe Adapter 69 | -------------------------------------------------------------------------------- /pyzeebe/channel/secure_channel.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import grpc 4 | 5 | from pyzeebe.channel.channel_options import get_channel_options 6 | from pyzeebe.channel.utils import get_zeebe_address 7 | from pyzeebe.types import ChannelArgumentType, Unset 8 | 9 | 10 | def create_secure_channel( 11 | grpc_address: str = Unset, 12 | channel_options: ChannelArgumentType | None = None, 13 | channel_credentials: grpc.ChannelCredentials | None = None, 14 | ) -> grpc.aio.Channel: 15 | """ 16 | Create a secure channel 17 | 18 | Args: 19 | grpc_address (Optional[str]): Zeebe Gateway Address 20 | Default: None, alias the ZEEBE_ADDRESS environment variable or "localhost:26500" 21 | channel_options (Optional[ChannelArgumentType]): GRPC channel options. 22 | See https://grpc.github.io/grpc/python/glossary.html#term-channel_arguments 23 | channel_credentials (Optional[grpc.ChannelCredentials]): Channel credentials to use. 24 | Will use grpc.ssl_channel_credentials() if not provided. 25 | 26 | Returns: 27 | grpc.aio.Channel: A GRPC Channel connected to the Zeebe gateway. 28 | """ 29 | 30 | if grpc_address is Unset: 31 | grpc_address = get_zeebe_address() 32 | 33 | credentials = channel_credentials or grpc.ssl_channel_credentials() 34 | return grpc.aio.secure_channel( 35 | target=grpc_address, credentials=credentials, options=get_channel_options(channel_options) 36 | ) 37 | -------------------------------------------------------------------------------- /examples/client.py: -------------------------------------------------------------------------------- 1 | from pyzeebe import ( 2 | ZeebeClient, 3 | create_camunda_cloud_channel, 4 | create_insecure_channel, 5 | create_secure_channel, 6 | ) 7 | 8 | # Create a zeebe client without credentials 9 | grpc_channel = create_insecure_channel(grpc_address="localhost:26500") 10 | zeebe_client = ZeebeClient(grpc_channel) 11 | 12 | # Create a zeebe client with TLS 13 | grpc_channel = create_secure_channel() 14 | zeebe_client = ZeebeClient(grpc_channel) 15 | 16 | # Create a zeebe client for camunda cloud 17 | grpc_channel = create_camunda_cloud_channel( 18 | client_id="", 19 | client_secret="", 20 | cluster_id="", 21 | region="", # Default is bru-2 22 | ) 23 | zeebe_client = ZeebeClient(grpc_channel) 24 | 25 | # Run a Zeebe instance process 26 | process_instance_key = await zeebe_client.run_process(bpmn_process_id="My zeebe process", variables={}) 27 | 28 | # Run a Zeebe process instance and receive the result 29 | process_instance_key, process_result = await zeebe_client.run_process_with_result( 30 | bpmn_process_id="My zeebe process", timeout=10000 31 | ) # Will wait 10000 milliseconds (10 seconds) 32 | 33 | # Deploy a bpmn process definition 34 | await zeebe_client.deploy_resource("process.bpmn") 35 | 36 | # Cancel a running process 37 | await zeebe_client.cancel_process_instance(process_instance_key=12345) 38 | 39 | # Publish message 40 | await zeebe_client.publish_message(name="message_name", correlation_key="some_id") 41 | -------------------------------------------------------------------------------- /pyzeebe/channel/channel_options.py: -------------------------------------------------------------------------------- 1 | """gRPC Channel Options 2 | 3 | ``grpc.keepalive_time_ms`` 4 | -------------------------- 5 | 6 | Time between keepalive pings. Following the official Zeebe Java/Go client, sending pings every 45 seconds. 7 | 8 | https://docs.camunda.io/docs/product-manuals/zeebe/deployment-guide/operations/setting-up-a-cluster/#keep-alive-intervals 9 | """ 10 | 11 | from __future__ import annotations 12 | 13 | from pyzeebe.types import ChannelArgumentType 14 | 15 | GRPC_CHANNEL_OPTIONS_DEFAULT: ChannelArgumentType = (("grpc.keepalive_time_ms", 45_000),) 16 | 17 | 18 | def get_channel_options(options: ChannelArgumentType | None = None) -> ChannelArgumentType: 19 | """ 20 | Get default channel options for creating the gRPC channel. 21 | 22 | Args: 23 | options (Optional[ChannelArgumentType]): A tuple of gRPC channel arguments tuple. 24 | e.g. (("grpc.keepalive_time_ms", 45_000),) 25 | Default: None (will use library defaults) 26 | See https://grpc.github.io/grpc/python/glossary.html#term-channel_arguments 27 | 28 | Returns: 29 | ChannelArgumentType: Options for the gRPC channel 30 | """ 31 | if options is not None: 32 | existing = set() 33 | _options = [] 34 | 35 | for a, b in (*options, *GRPC_CHANNEL_OPTIONS_DEFAULT): 36 | if a not in existing: # NOTE: Remove duplicates, fist one wins 37 | existing.add(a) 38 | _options.append((a, b)) 39 | 40 | return tuple(_options) # (*options, GRPC_CHANNEL_OPTIONS) 41 | return GRPC_CHANNEL_OPTIONS_DEFAULT 42 | -------------------------------------------------------------------------------- /pyzeebe/function_tools/async_tools.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import functools 5 | import sys 6 | from collections.abc import Iterable 7 | from typing import Any, TypeVar 8 | 9 | from typing_extensions import ParamSpec, TypeIs 10 | 11 | from pyzeebe.function_tools import AsyncFunction, Function, SyncFunction 12 | 13 | if sys.version_info < (3, 14): 14 | from asyncio import iscoroutinefunction 15 | else: 16 | from inspect import iscoroutinefunction 17 | 18 | P = ParamSpec("P") 19 | R = TypeVar("R") 20 | 21 | 22 | def asyncify_all_functions(functions: Iterable[Function[..., Any]]) -> list[AsyncFunction[..., Any]]: 23 | async_functions: list[AsyncFunction[..., Any]] = [] 24 | for function in functions: 25 | if not is_async_function(function): 26 | async_functions.append(asyncify(function)) 27 | else: 28 | async_functions.append(function) 29 | return async_functions 30 | 31 | 32 | def asyncify(task_function: SyncFunction[P, R]) -> AsyncFunction[P, R]: 33 | @functools.wraps(task_function) 34 | async def async_function(*args: P.args, **kwargs: P.kwargs) -> R: 35 | loop = asyncio.get_event_loop() 36 | return await loop.run_in_executor(None, functools.partial(task_function, *args, **kwargs)) 37 | 38 | return async_function 39 | 40 | 41 | def is_async_function(function: Function[P, R]) -> TypeIs[AsyncFunction[P, R]]: 42 | # Not using inspect.iscoroutinefunction here because it doens't handle AsyncMock well 43 | # See: https://bugs.python.org/issue40573 44 | return iscoroutinefunction(function) 45 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | type-checking: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v6 14 | - uses: actions/setup-python@v6 15 | with: 16 | python-version: "3.14" 17 | - name: Install uv 18 | uses: astral-sh/setup-uv@v7 19 | with: 20 | version: "0.9.5" 21 | - name: Install the project 22 | run: uv sync --locked --all-extras --dev 23 | - name: Lint with mypy 24 | run: | 25 | uv run mypy pyzeebe 26 | 27 | import-checking: 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: actions/checkout@v6 31 | - uses: actions/setup-python@v6 32 | with: 33 | python-version: "3.14" 34 | - name: Install uv 35 | uses: astral-sh/setup-uv@v7 36 | with: 37 | version: "0.9.5" 38 | - name: Install the project 39 | run: uv sync --locked --all-extras --dev 40 | - name: Check imports 41 | run: | 42 | uv run isort . --check --diff 43 | 44 | format-checking: 45 | runs-on: ubuntu-latest 46 | steps: 47 | - uses: actions/checkout@v6 48 | - uses: actions/setup-python@v6 49 | with: 50 | python-version: "3.14" 51 | - name: Install uv 52 | uses: astral-sh/setup-uv@v7 53 | with: 54 | version: "0.9.5" 55 | - name: Install the project 56 | run: uv sync --locked --all-extras --dev 57 | - name: Check imports 58 | run: | 59 | uv run black --check . 60 | -------------------------------------------------------------------------------- /pyzeebe/errors/job_errors.py: -------------------------------------------------------------------------------- 1 | from pyzeebe.errors.pyzeebe_errors import PyZeebeError 2 | 3 | 4 | class ActivateJobsRequestInvalidError(PyZeebeError): 5 | def __init__(self, task_type: str, worker: str, timeout: int, max_jobs_to_activate: int) -> None: 6 | msg = "Failed to activate jobs. Reasons:" 7 | if task_type == "" or task_type is None: 8 | msg = msg + "task_type is empty, " 9 | if worker == "" or task_type is None: 10 | msg = msg + "worker is empty, " 11 | if timeout < 1: 12 | msg = msg + "job timeout is smaller than 0ms, " 13 | if max_jobs_to_activate < 1: 14 | msg = msg + "max_jobs_to_activate is smaller than 0ms, " 15 | 16 | super().__init__(msg) 17 | 18 | 19 | class StreamActivateJobsRequestInvalidError(PyZeebeError): 20 | def __init__(self, task_type: str, worker: str, timeout: int): 21 | msg = "Failed to activate jobs. Reasons:" 22 | if task_type == "" or task_type is None: 23 | msg = msg + "task_type is empty, " 24 | if worker == "" or worker is None: 25 | msg = msg + "worker is empty, " 26 | if timeout < 1: 27 | msg = msg + "job timeout is smaller than 0ms, " 28 | 29 | super().__init__(msg) 30 | 31 | 32 | class JobAlreadyDeactivatedError(PyZeebeError): 33 | def __init__(self, job_key: int) -> None: 34 | super().__init__(f"Job {job_key} was already stopped (Completed/Failed/Error)") 35 | self.job_key = job_key 36 | 37 | 38 | class JobNotFoundError(PyZeebeError): 39 | def __init__(self, job_key: int) -> None: 40 | super().__init__(f"Job {job_key} not found") 41 | self.job_key = job_key 42 | -------------------------------------------------------------------------------- /docs/client_quickstart.rst: -------------------------------------------------------------------------------- 1 | ================= 2 | Client QuickStart 3 | ================= 4 | 5 | Create a client 6 | --------------- 7 | 8 | To create a client with default configuration: 9 | 10 | .. code-block:: python 11 | 12 | from pyzeebe import ZeebeClient, create_insecure_channel 13 | 14 | channel = create_insecure_channel() # Will use ZEEBE_ADDRESS environment variable or localhost:26500 15 | client = ZeebeClient(channel) 16 | 17 | 18 | To change connection retries: 19 | 20 | .. code-block:: python 21 | 22 | client = ZeebeClient(grpc_channel, max_connection_retries=1) # Will only accept one failure and disconnect upon the second 23 | 24 | 25 | This means the client will disconnect upon two consecutive failures. Each time the client connects successfully the counter is reset. 26 | 27 | .. note:: 28 | 29 | The default behavior is 10 retries. If you want infinite retries just set to -1. 30 | 31 | 32 | 33 | Run a Zeebe process instance 34 | ---------------------------- 35 | 36 | .. code-block:: python 37 | 38 | process_instance_key = await client.run_process("bpmn_process_id") 39 | 40 | 41 | Run a process with result 42 | -------------------------- 43 | 44 | To run a process and receive the result directly: 45 | 46 | .. code-block:: python 47 | 48 | process_instance_key, result = await client.run_process_with_result("bpmn_process_id") 49 | 50 | # result will be a dict 51 | 52 | 53 | Deploy a process 54 | ----------------- 55 | 56 | .. code-block:: python 57 | 58 | await client.deploy_resource("process_file.bpmn") 59 | 60 | 61 | Publish a message 62 | ----------------- 63 | 64 | .. code-block:: python 65 | 66 | await client.publish_message(name="message_name", correlation_key="correlation_key") 67 | -------------------------------------------------------------------------------- /docs/errors.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | Errors 3 | ========== 4 | 5 | All ``pyzeebe`` errors inherit from :py:class:`PyZeebeError` 6 | 7 | .. autoexception:: pyzeebe.errors.PyZeebeError 8 | 9 | .. autoexception:: pyzeebe.errors.TaskNotFoundError 10 | 11 | .. autoexception:: pyzeebe.errors.NoVariableNameGivenError 12 | 13 | .. autoexception:: pyzeebe.errors.SettingsError 14 | 15 | .. autoexception:: pyzeebe.errors.DuplicateTaskTypeError 16 | 17 | .. autoexception:: pyzeebe.errors.ActivateJobsRequestInvalidError 18 | 19 | .. autoexception:: pyzeebe.errors.JobAlreadyDeactivatedError 20 | 21 | .. autoexception:: pyzeebe.errors.JobNotFoundError 22 | 23 | .. autoexception:: pyzeebe.errors.MessageAlreadyExistsError 24 | 25 | .. autoexception:: pyzeebe.errors.ProcessDefinitionNotFoundError 26 | 27 | .. autoexception:: pyzeebe.errors.ProcessInstanceNotFoundError 28 | 29 | .. autoexception:: pyzeebe.errors.ProcessDefinitionHasNoStartEventError 30 | 31 | .. autoexception:: pyzeebe.errors.ProcessTimeoutError 32 | 33 | .. autoexception:: pyzeebe.errors.ProcessInvalidError 34 | 35 | .. autoexception:: pyzeebe.errors.DecisionNotFoundError 36 | 37 | .. autoexception:: pyzeebe.errors.InvalidJSONError 38 | 39 | .. autoexception:: pyzeebe.errors.ZeebeError 40 | 41 | .. autoexception:: pyzeebe.errors.ZeebeBackPressureError 42 | 43 | .. autoexception:: pyzeebe.errors.ZeebeGatewayUnavailableError 44 | 45 | .. autoexception:: pyzeebe.errors.ZeebeInternalError 46 | 47 | .. autoexception:: pyzeebe.errors.ZeebeDeadlineExceeded 48 | 49 | .. autoexception:: pyzeebe.errors.InvalidOAuthCredentialsError 50 | 51 | .. autoexception:: pyzeebe.errors.UnknownGrpcStatusCodeError 52 | 53 | 54 | ================= 55 | Exception Handler 56 | ================= 57 | 58 | .. autofunction:: pyzeebe.default_exception_handler 59 | -------------------------------------------------------------------------------- /pyzeebe/errors/process_errors.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pyzeebe.errors.pyzeebe_errors import PyZeebeError 4 | 5 | 6 | class ProcessDefinitionNotFoundError(PyZeebeError): 7 | def __init__(self, bpmn_process_id: str, version: int): 8 | super().__init__(f"Process definition: {bpmn_process_id} with {version} was not found") 9 | self.bpmn_process_id = bpmn_process_id 10 | self.version = version 11 | 12 | 13 | class ProcessInstanceNotFoundError(PyZeebeError): 14 | def __init__(self, process_instance_key: int): 15 | super().__init__(f"Process instance key: {process_instance_key} was not found") 16 | self.process_instance_key = process_instance_key 17 | 18 | 19 | class ProcessDefinitionHasNoStartEventError(PyZeebeError): 20 | def __init__(self, bpmn_process_id: str): 21 | super().__init__(f"Process {bpmn_process_id} has no start event that can be called manually") 22 | self.bpmn_process_id = bpmn_process_id 23 | 24 | 25 | class ProcessInvalidError(PyZeebeError): 26 | pass 27 | 28 | 29 | class InvalidJSONError(PyZeebeError): 30 | pass 31 | 32 | 33 | class ProcessTimeoutError(PyZeebeError, TimeoutError): 34 | def __init__(self, bpmn_process_id: str): 35 | super().__init__(f"Timeout while waiting for process {bpmn_process_id} to complete") 36 | self.bpmn_process_id = bpmn_process_id 37 | 38 | 39 | class DecisionNotFoundError(PyZeebeError): 40 | def __init__(self, decision_key: int | None, decision_id: str | None): 41 | if decision_id is not None: 42 | msg = f"Decision with id '{decision_id}' was not found" 43 | else: 44 | msg = f"Decision with key '{decision_key}' was not found" 45 | super().__init__(msg) 46 | self.decision_key = decision_key 47 | self.decision_id = decision_id 48 | -------------------------------------------------------------------------------- /tests/unit/channel/insecure_channel_test.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from unittest.mock import Mock, patch 4 | 5 | import grpc 6 | import pytest 7 | 8 | from pyzeebe import create_insecure_channel 9 | from pyzeebe.channel.channel_options import get_channel_options 10 | from pyzeebe.channel.utils import get_zeebe_address 11 | 12 | 13 | @pytest.mark.anyio 14 | class TestCreateInsecureChannel: 15 | @pytest.fixture(autouse=True) 16 | async def insecure_channel_mock(self, anyio_backend, aio_grpc_channel: grpc.aio.Channel): 17 | with patch("grpc.aio.insecure_channel", return_value=aio_grpc_channel) as mock: 18 | yield mock 19 | 20 | async def test_returns_aio_grpc_channel(self): 21 | channel = create_insecure_channel() 22 | 23 | assert isinstance(channel, grpc.aio.Channel) 24 | 25 | async def test_calls_using_default_grpc_options(self, insecure_channel_mock: Mock): 26 | create_insecure_channel() 27 | 28 | insecure_channel_call = insecure_channel_mock.mock_calls[0] 29 | assert insecure_channel_call.kwargs["options"] == get_channel_options() 30 | 31 | async def test_uses_default_address(self, insecure_channel_mock: Mock): 32 | create_insecure_channel() 33 | 34 | insecure_channel_call = insecure_channel_mock.mock_calls[0] 35 | assert insecure_channel_call.kwargs["target"] == get_zeebe_address() 36 | 37 | @patch.dict( 38 | os.environ, 39 | {"ZEEBE_ADDRESS": "ZEEBE_ADDRESS"}, 40 | ) 41 | @pytest.mark.xfail(sys.version_info < (3, 10), reason="https://github.com/python/cpython/issues/98086") 42 | async def test_uses_zeebe_address_environment_variable(self, insecure_channel_mock: Mock): 43 | create_insecure_channel() 44 | 45 | insecure_channel_call = insecure_channel_mock.mock_calls[0] 46 | assert insecure_channel_call.kwargs["target"] == "ZEEBE_ADDRESS" 47 | -------------------------------------------------------------------------------- /pyzeebe/errors/__init__.py: -------------------------------------------------------------------------------- 1 | from .credentials_errors import InvalidOAuthCredentialsError 2 | from .job_errors import ( 3 | ActivateJobsRequestInvalidError, 4 | JobAlreadyDeactivatedError, 5 | JobNotFoundError, 6 | StreamActivateJobsRequestInvalidError, 7 | ) 8 | from .message_errors import MessageAlreadyExistsError 9 | from .process_errors import ( 10 | DecisionNotFoundError, 11 | InvalidJSONError, 12 | ProcessDefinitionHasNoStartEventError, 13 | ProcessDefinitionNotFoundError, 14 | ProcessInstanceNotFoundError, 15 | ProcessInvalidError, 16 | ProcessTimeoutError, 17 | ) 18 | from .pyzeebe_errors import ( 19 | BusinessError, 20 | DuplicateTaskTypeError, 21 | NoVariableNameGivenError, 22 | PyZeebeError, 23 | SettingsError, 24 | TaskNotFoundError, 25 | ) 26 | from .zeebe_errors import ( 27 | UnknownGrpcStatusCodeError, 28 | ZeebeBackPressureError, 29 | ZeebeDeadlineExceeded, 30 | ZeebeError, 31 | ZeebeGatewayUnavailableError, 32 | ZeebeInternalError, 33 | ) 34 | 35 | __all__ = ( 36 | "InvalidOAuthCredentialsError", 37 | "ActivateJobsRequestInvalidError", 38 | "StreamActivateJobsRequestInvalidError", 39 | "JobAlreadyDeactivatedError", 40 | "JobNotFoundError", 41 | "MessageAlreadyExistsError", 42 | "InvalidJSONError", 43 | "ProcessDefinitionHasNoStartEventError", 44 | "ProcessDefinitionNotFoundError", 45 | "ProcessInstanceNotFoundError", 46 | "ProcessInvalidError", 47 | "ProcessTimeoutError", 48 | "DecisionNotFoundError", 49 | "BusinessError", 50 | "DuplicateTaskTypeError", 51 | "NoVariableNameGivenError", 52 | "PyZeebeError", 53 | "SettingsError", 54 | "TaskNotFoundError", 55 | "UnknownGrpcStatusCodeError", 56 | "ZeebeBackPressureError", 57 | "ZeebeDeadlineExceeded", 58 | "ZeebeGatewayUnavailableError", 59 | "ZeebeInternalError", 60 | "ZeebeError", 61 | ) 62 | -------------------------------------------------------------------------------- /tests/unit/function_tools/async_tools_test.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | 3 | import pytest 4 | 5 | from pyzeebe.function_tools import async_tools 6 | from tests.unit.utils.function_tools import functions_are_all_async 7 | 8 | 9 | class TestAsyncify: 10 | def test_returns_async_function(self): 11 | async_function = async_tools.asyncify(lambda x: x) 12 | 13 | assert inspect.iscoroutinefunction(async_function) 14 | 15 | @pytest.mark.anyio 16 | async def test_returned_function_returns_expected_value(self): 17 | expected_result = 5 18 | async_function = async_tools.asyncify(lambda: expected_result) 19 | 20 | assert await async_function() == expected_result 21 | 22 | @pytest.mark.anyio 23 | async def test_returned_function_accepts_keyword_arguments(self): 24 | async_function = async_tools.asyncify(lambda x, y, z: x + y + z) 25 | 26 | assert await async_function(x=1, y=1, z=1) == 3 27 | 28 | 29 | class TestAsyncifyAllFunctions: 30 | def sync_function(self): 31 | return 32 | 33 | async def async_function(self): 34 | return 35 | 36 | def test_changes_sync_function(self): 37 | functions = [self.sync_function] 38 | 39 | async_functions = async_tools.asyncify_all_functions(functions) 40 | 41 | assert functions_are_all_async(async_functions) 42 | 43 | def test_async_function_remains_unchanged(self): 44 | functions = [self.async_function] 45 | 46 | async_functions = async_tools.asyncify_all_functions(functions) 47 | 48 | assert async_functions[0] == functions[0] 49 | 50 | 51 | class TestIsAsyncFunction: 52 | def test_with_normal_function(self): 53 | def normal_function(): 54 | return 1 55 | 56 | assert not async_tools.is_async_function(normal_function) 57 | 58 | def test_with_async_function(self): 59 | async def async_function(): 60 | return 1 61 | 62 | assert async_tools.is_async_function(async_function) 63 | -------------------------------------------------------------------------------- /tests/integration/test.dmn: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | input 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | "1" 18 | 19 | 20 | "One" 21 | 22 | 23 | 24 | 25 | "2" 26 | 27 | 28 | {"foo": "bar"} 29 | 30 | 31 | 32 | 33 | "3" 34 | 35 | 36 | 3 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /tests/unit/channel/secure_channel_test.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from unittest.mock import Mock, patch 4 | 5 | import grpc 6 | import pytest 7 | 8 | from pyzeebe import create_secure_channel 9 | from pyzeebe.channel.channel_options import get_channel_options 10 | from pyzeebe.channel.utils import get_zeebe_address 11 | 12 | 13 | @pytest.mark.anyio 14 | class TestCreateSecureChannel: 15 | @pytest.fixture(autouse=True) 16 | async def secure_channel_mock(self, anyio_backend, aio_grpc_channel: grpc.aio.Channel): 17 | with patch("grpc.aio.secure_channel", return_value=aio_grpc_channel) as mock: 18 | yield mock 19 | 20 | async def test_returns_grpc_channel(self): 21 | channel = create_secure_channel() 22 | 23 | assert isinstance(channel, grpc.aio.Channel) 24 | 25 | async def test_uses_ssl_credentials_if_no_channel_credentials_provided(self): 26 | with patch("grpc.ssl_channel_credentials") as ssl_mock: 27 | create_secure_channel() 28 | 29 | ssl_mock.assert_called_once() 30 | 31 | async def test_calls_using_default_grpc_options(self, secure_channel_mock: Mock): 32 | create_secure_channel() 33 | 34 | secure_channel_call = secure_channel_mock.mock_calls[0] 35 | assert secure_channel_call.kwargs["options"] == get_channel_options() 36 | 37 | async def test_uses_default_address(self, secure_channel_mock: Mock): 38 | create_secure_channel() 39 | 40 | secure_channel_call = secure_channel_mock.mock_calls[0] 41 | assert secure_channel_call.kwargs["target"] == get_zeebe_address() 42 | 43 | @patch.dict( 44 | os.environ, 45 | {"ZEEBE_ADDRESS": "ZEEBE_ADDRESS"}, 46 | ) 47 | @pytest.mark.xfail(sys.version_info < (3, 10), reason="https://github.com/python/cpython/issues/98086") 48 | async def test_uses_zeebe_address_environment_variable(self, secure_channel_mock: Mock): 49 | create_secure_channel() 50 | 51 | secure_channel_call = secure_channel_mock.mock_calls[0] 52 | assert secure_channel_call.kwargs["target"] == "ZEEBE_ADDRESS" 53 | -------------------------------------------------------------------------------- /pyzeebe/task/task_config.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Iterable 4 | 5 | from pyzeebe.errors import NoVariableNameGivenError 6 | from pyzeebe.function_tools import async_tools 7 | from pyzeebe.task.exception_handler import ExceptionHandler 8 | from pyzeebe.task.types import AsyncTaskDecorator, TaskDecorator 9 | 10 | 11 | class TaskConfig: 12 | before: list[AsyncTaskDecorator] 13 | after: list[AsyncTaskDecorator] 14 | 15 | def __init__( 16 | self, 17 | type: str, 18 | exception_handler: ExceptionHandler | None, 19 | timeout_ms: int, 20 | max_jobs_to_activate: int, 21 | max_running_jobs: int, 22 | variables_to_fetch: Iterable[str] | None, 23 | single_value: bool, 24 | variable_name: str, 25 | before: list[TaskDecorator], 26 | after: list[TaskDecorator], 27 | ) -> None: 28 | if single_value and not variable_name: 29 | raise NoVariableNameGivenError(type) 30 | 31 | self.type = type 32 | self.exception_handler = exception_handler 33 | self.timeout_ms = timeout_ms 34 | self.max_jobs_to_activate = max_jobs_to_activate 35 | self.max_running_jobs = max_running_jobs 36 | self.variables_to_fetch = variables_to_fetch 37 | self.single_value = single_value 38 | self.variable_name = variable_name 39 | self.before = async_tools.asyncify_all_functions(before) 40 | self.after = async_tools.asyncify_all_functions(after) 41 | self.job_parameter_name: str | None = None 42 | 43 | def __repr__(self) -> str: 44 | return ( 45 | f"TaskConfig(type={self.type}, exception_handler={self.exception_handler}, " 46 | f"timeout_ms={self.timeout_ms}, max_jobs_to_activate={self.max_jobs_to_activate}, " 47 | f"max_running_jobs={self.max_running_jobs}, variables_to_fetch={self.variables_to_fetch}," 48 | f"single_value={self.single_value}, variable_name={self.variable_name}," 49 | f"before={self.before}, after={self.after})" 50 | ) 51 | -------------------------------------------------------------------------------- /docs/zeebe_adapter_reference.rst: -------------------------------------------------------------------------------- 1 | ========================== 2 | Zeebe Adapter Reference 3 | ========================== 4 | 5 | .. autoclass:: pyzeebe.grpc_internals.zeebe_adapter.ZeebeAdapter 6 | :members: 7 | :undoc-members: 8 | :special-members: __init__ 9 | :inherited-members: 10 | 11 | ========================== 12 | Zeebe GRPC Responses 13 | ========================== 14 | 15 | .. autoclass:: pyzeebe.grpc_internals.types.CreateProcessInstanceResponse 16 | :members: 17 | :undoc-members: 18 | :member-order: bysource 19 | 20 | .. autoclass:: pyzeebe.grpc_internals.types.CreateProcessInstanceWithResultResponse 21 | :members: 22 | :undoc-members: 23 | :member-order: bysource 24 | 25 | .. autoclass:: pyzeebe.grpc_internals.types.CancelProcessInstanceResponse 26 | :members: 27 | :undoc-members: 28 | :member-order: bysource 29 | 30 | .. autoclass:: pyzeebe.grpc_internals.types.DeployResourceResponse 31 | :members: 32 | :undoc-members: 33 | :member-order: bysource 34 | 35 | .. autoclass:: pyzeebe.grpc_internals.types.EvaluateDecisionResponse 36 | :members: 37 | :undoc-members: 38 | :member-order: bysource 39 | 40 | .. autoclass:: pyzeebe.grpc_internals.types.BroadcastSignalResponse 41 | :members: 42 | :undoc-members: 43 | :member-order: bysource 44 | 45 | .. autoclass:: pyzeebe.grpc_internals.types.PublishMessageResponse 46 | :members: 47 | :undoc-members: 48 | :member-order: bysource 49 | 50 | .. autoclass:: pyzeebe.grpc_internals.types.CompleteJobResponse 51 | :members: 52 | :undoc-members: 53 | :member-order: bysource 54 | 55 | .. autoclass:: pyzeebe.grpc_internals.types.FailJobResponse 56 | :members: 57 | :undoc-members: 58 | :member-order: bysource 59 | 60 | .. autoclass:: pyzeebe.grpc_internals.types.ThrowErrorResponse 61 | :members: 62 | :undoc-members: 63 | :member-order: bysource 64 | 65 | .. autoclass:: pyzeebe.grpc_internals.types.TopologyResponse 66 | :members: 67 | :undoc-members: 68 | :member-order: bysource 69 | 70 | .. autoclass:: pyzeebe.grpc_internals.types.HealthCheckResponse 71 | :members: 72 | :undoc-members: 73 | :member-order: bysource 74 | -------------------------------------------------------------------------------- /pyzeebe/worker/job_executor.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import logging 5 | from typing import Callable 6 | 7 | from pyzeebe.errors import JobAlreadyDeactivatedError 8 | from pyzeebe.grpc_internals.zeebe_adapter import ZeebeAdapter 9 | from pyzeebe.job.job import Job, JobController 10 | from pyzeebe.task.task import Task 11 | from pyzeebe.worker.task_state import TaskState 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | AsyncTaskCallback = Callable[["asyncio.Future[None]"], None] 16 | 17 | 18 | class JobExecutor: 19 | def __init__(self, task: Task, jobs: asyncio.Queue[Job], task_state: TaskState, zeebe_adapter: ZeebeAdapter): 20 | self.task = task 21 | self.jobs = jobs 22 | self.task_state = task_state 23 | self.stop_event = asyncio.Event() 24 | self.zeebe_adapter = zeebe_adapter 25 | 26 | async def execute(self) -> None: 27 | while self.should_execute(): 28 | job = await self.get_next_job() 29 | task = asyncio.create_task(self.execute_one_job(job, JobController(job, self.zeebe_adapter))) 30 | task.add_done_callback(create_job_callback(self, job)) 31 | 32 | async def get_next_job(self) -> Job: 33 | return await self.jobs.get() 34 | 35 | async def execute_one_job(self, job: Job, job_controller: JobController) -> None: 36 | try: 37 | await self.task.job_handler(job, job_controller) 38 | except JobAlreadyDeactivatedError as error: 39 | logger.warning("Job was already deactivated. Job key: %s", error.job_key) 40 | 41 | def should_execute(self) -> bool: 42 | return not self.stop_event.is_set() 43 | 44 | async def stop(self) -> None: 45 | self.stop_event.set() 46 | await self.jobs.join() 47 | 48 | 49 | def create_job_callback(job_executor: JobExecutor, job: Job) -> AsyncTaskCallback: 50 | def callback(fut: asyncio.Future[None]) -> None: 51 | err = fut.done() and not fut.cancelled() and fut.exception() 52 | if err: 53 | logger.exception("Error in job executor. Task: %s. Error: %s.", job.type, err, exc_info=err) 54 | 55 | job_executor.jobs.task_done() 56 | job_executor.task_state.remove(job) 57 | 58 | return callback 59 | -------------------------------------------------------------------------------- /pyzeebe/grpc_internals/zeebe_cluster_adapter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import grpc 4 | from grpc_health.v1.health_pb2 import HealthCheckRequest 5 | 6 | from pyzeebe.grpc_internals.zeebe_adapter_base import ZeebeAdapterBase 7 | from pyzeebe.proto.gateway_pb2 import TopologyRequest 8 | 9 | from .types import HealthCheckResponse, TopologyResponse 10 | 11 | 12 | class ZeebeClusterAdapter(ZeebeAdapterBase): 13 | async def topology(self) -> TopologyResponse: 14 | try: 15 | response = await self._gateway_stub.Topology(TopologyRequest()) 16 | except grpc.aio.AioRpcError as grpc_error: 17 | await self._handle_grpc_error(grpc_error) 18 | 19 | return TopologyResponse( 20 | brokers=[ 21 | TopologyResponse.BrokerInfo( 22 | node_id=broker.nodeId, 23 | host=broker.host, 24 | port=broker.port, 25 | partitions=[ 26 | TopologyResponse.BrokerInfo.Partition( 27 | partition_id=partition.partitionId, 28 | role=TopologyResponse.BrokerInfo.Partition.PartitionBrokerRole(partition.role), 29 | health=TopologyResponse.BrokerInfo.Partition.PartitionBrokerHealth(partition.health), 30 | ) 31 | for partition in broker.partitions 32 | ], 33 | version=broker.version, 34 | ) 35 | for broker in response.brokers 36 | ], 37 | cluster_size=response.clusterSize, 38 | partitions_count=response.partitionsCount, 39 | replication_factor=response.replicationFactor, 40 | gateway_version=response.gatewayVersion, 41 | ) 42 | 43 | async def healthcheck(self) -> HealthCheckResponse: 44 | try: 45 | response = await self._health_stub.Check(HealthCheckRequest(service="gateway_protocol.Gateway")) 46 | except grpc.aio.AioRpcError as grpc_error: 47 | pyzeebe_error = self._create_pyzeebe_error_from_grpc_error(grpc_error) 48 | raise pyzeebe_error from grpc_error 49 | 50 | return HealthCheckResponse(status=response.status) 51 | -------------------------------------------------------------------------------- /tests/unit/function_tools/parameter_tools_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Callable 4 | 5 | import pytest 6 | 7 | from pyzeebe.function_tools import parameter_tools 8 | from tests.unit.utils import dummy_functions 9 | 10 | 11 | class TestGetFunctionParameters: 12 | @pytest.mark.parametrize( 13 | "fn,expected", 14 | [ 15 | (dummy_functions.no_param, None), 16 | (dummy_functions.one_param, ["x"]), 17 | (dummy_functions.multiple_params, ["x", "y", "z"]), 18 | (dummy_functions.one_keyword_param, ["x"]), 19 | (dummy_functions.multiple_keyword_param, ["x", "y", "z"]), 20 | (dummy_functions.positional_and_keyword_params, ["x", "y"]), 21 | (dummy_functions.args_param, []), 22 | (dummy_functions.kwargs_param, []), 23 | (dummy_functions.standard_named_params, ["args", "kwargs"]), 24 | (dummy_functions.with_job_parameter, []), 25 | (dummy_functions.with_job_parameter_and_param, ["x"]), 26 | (dummy_functions.with_multiple_job_parameters, []), 27 | (dummy_functions.lambda_no_params, None), 28 | (dummy_functions.lambda_one_param, ["x"]), 29 | (dummy_functions.lambda_multiple_params, ["x", "y", "z"]), 30 | (dummy_functions.lambda_one_keyword_param, ["x"]), 31 | (dummy_functions.lambda_multiple_keyword_params, ["x", "y", "z"]), 32 | (dummy_functions.lambda_positional_and_keyword_params, ["x", "y"]), 33 | ], 34 | ) 35 | def test_get_params(self, fn: Callable, expected: list[str] | None): 36 | assert parameter_tools.get_parameters_from_function(fn) == expected 37 | 38 | 39 | class TestGetJobParameter: 40 | def test_returns_none_when_there_are_no_parameters_annotated_with_job(self): 41 | job_parameter = parameter_tools.get_job_parameter_name(dummy_functions.multiple_params) 42 | 43 | assert job_parameter == None 44 | 45 | def test_returns_parameter_name_when_annotated(self): 46 | job_parameter = parameter_tools.get_job_parameter_name(dummy_functions.with_job_parameter) 47 | 48 | assert job_parameter == "job" 49 | 50 | def test_returns_first_parameter_annotated_with_job(self): 51 | job_parameter = parameter_tools.get_job_parameter_name(dummy_functions.with_multiple_job_parameters) 52 | 53 | assert job_parameter == "job" 54 | -------------------------------------------------------------------------------- /pyzeebe/grpc_internals/zeebe_message_adapter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | 5 | import grpc 6 | 7 | from pyzeebe.errors import MessageAlreadyExistsError 8 | from pyzeebe.grpc_internals.grpc_utils import is_error_status 9 | from pyzeebe.grpc_internals.zeebe_adapter_base import ZeebeAdapterBase 10 | from pyzeebe.proto.gateway_pb2 import BroadcastSignalRequest, PublishMessageRequest 11 | from pyzeebe.types import Variables 12 | 13 | from .types import BroadcastSignalResponse, PublishMessageResponse 14 | 15 | 16 | class ZeebeMessageAdapter(ZeebeAdapterBase): 17 | async def broadcast_signal( 18 | self, 19 | signal_name: str, 20 | variables: Variables, 21 | tenant_id: str | None = None, 22 | ) -> BroadcastSignalResponse: 23 | try: 24 | response = await self._gateway_stub.BroadcastSignal( 25 | BroadcastSignalRequest( 26 | signalName=signal_name, 27 | variables=json.dumps(variables), 28 | tenantId=tenant_id, # type: ignore[arg-type] 29 | ) 30 | ) 31 | except grpc.aio.AioRpcError as grpc_error: 32 | await self._handle_grpc_error(grpc_error) 33 | 34 | return BroadcastSignalResponse(key=response.key, tenant_id=response.tenantId) 35 | 36 | async def publish_message( 37 | self, 38 | name: str, 39 | correlation_key: str, 40 | time_to_live_in_milliseconds: int, 41 | variables: Variables, 42 | message_id: str | None = None, 43 | tenant_id: str | None = None, 44 | ) -> PublishMessageResponse: 45 | try: 46 | response = await self._gateway_stub.PublishMessage( 47 | PublishMessageRequest( 48 | name=name, 49 | correlationKey=correlation_key, 50 | messageId=message_id, # type: ignore[arg-type] 51 | timeToLive=time_to_live_in_milliseconds, 52 | variables=json.dumps(variables), 53 | tenantId=tenant_id, # type: ignore[arg-type] 54 | ) 55 | ) 56 | except grpc.aio.AioRpcError as grpc_error: 57 | if is_error_status(grpc_error, grpc.StatusCode.ALREADY_EXISTS): 58 | raise MessageAlreadyExistsError() from grpc_error 59 | await self._handle_grpc_error(grpc_error) 60 | 61 | return PublishMessageResponse(key=response.key, tenant_id=response.tenantId) 62 | -------------------------------------------------------------------------------- /update_proto.py: -------------------------------------------------------------------------------- 1 | # /// script 2 | # requires-python = ">=3.11,<3.14" 3 | # dependencies = [ 4 | # # pin version for code generation, see https://protobuf.dev/support/cross-version-runtime-guarantee/#major 5 | # "protobuf>=5.28,<6.0", 6 | # "grpcio-tools>=1.66", 7 | # "mypy-protobuf>=3.6", 8 | # ] 9 | # /// 10 | 11 | import argparse 12 | import os 13 | import pathlib 14 | from http.client import HTTPResponse 15 | from urllib import error 16 | from urllib.request import urlopen 17 | 18 | from grpc_tools.protoc import main as grpc_tools_protoc_main 19 | 20 | DEFAULT_PROTO_VERSION: str = "8.7.10" 21 | 22 | 23 | def main(): 24 | parser = argparse.ArgumentParser(description="Download Zeebe proto file and generate protoctol buffers.") 25 | parser.add_argument( 26 | "-pv", 27 | "--proto-version", 28 | default=[DEFAULT_PROTO_VERSION], 29 | nargs=1, 30 | type=str, 31 | help=f"zeebe proto version, default is {DEFAULT_PROTO_VERSION}", 32 | required=False, 33 | # NOTE: The default value is set to the latest version of Zeebe proto file. 34 | ) 35 | args = parser.parse_args() 36 | 37 | print(f"Zeebe Proto version: {args.proto_version[0]}") 38 | proto_version = args.proto_version[0] 39 | generate_proto(proto_version) 40 | 41 | 42 | def generate_proto(zeebe_proto_version: str): 43 | proto_dir = pathlib.Path("pyzeebe/proto") 44 | proto_file = proto_dir / "gateway.proto" 45 | for path in proto_dir.glob("*pb2*"): 46 | os.remove(path) 47 | 48 | proto_url = f"https://raw.githubusercontent.com/camunda/camunda/refs/tags/{zeebe_proto_version}/zeebe/gateway-protocol/src/main/proto/gateway.proto" 49 | 50 | try: 51 | print(f"Downloading proto file from {proto_url}") 52 | proto_content: HTTPResponse = urlopen(proto_url, timeout=5) 53 | 54 | with proto_file.open("wb") as tmpfile: 55 | tmpfile.write(proto_content.read()) 56 | 57 | grpc_tools_protoc_main( 58 | [ 59 | "--proto_path=.", 60 | "--python_out=.", 61 | "--mypy_out=.", 62 | "--grpc_python_out=.", 63 | "--mypy_grpc_out=.", 64 | os.path.relpath(tmpfile.name), 65 | ] 66 | ) 67 | proto_file.unlink() 68 | 69 | except error.HTTPError as err: 70 | print(f"HTTP Error occurred: {err}") 71 | except error.URLError as err: 72 | print(f"Error occurred: {err}") 73 | 74 | 75 | if __name__ == "__main__": 76 | main() 77 | -------------------------------------------------------------------------------- /docs/worker_quickstart.rst: -------------------------------------------------------------------------------- 1 | ================= 2 | Worker Quickstart 3 | ================= 4 | 5 | Create and start a worker 6 | ------------------------- 7 | 8 | Run using event loop 9 | 10 | .. code-block:: python 11 | 12 | import asyncio 13 | 14 | from pyzeebe import ZeebeWorker, create_insecure_channel 15 | 16 | channel = create_insecure_channel() 17 | worker = ZeebeWorker(channel) 18 | 19 | 20 | @worker.task(task_type="my_task") 21 | async def my_task(x: int): 22 | return {"y": x + 1} 23 | 24 | loop = asyncio.get_event_loop() 25 | loop.run_until_complete(worker.work()) 26 | 27 | .. warning:: 28 | 29 | Calling ``worker.work`` directly using ``asyncio.run`` will not work. When you create an async grpc channel a new event loop will automatically be created, which causes problems when running the worker (see: https://github.com/camunda-community-hub/pyzeebe/issues/198). 30 | 31 | An easy workaround: 32 | 33 | .. code-block:: python 34 | 35 | async def main(): 36 | channel = create_insecure_channel() 37 | worker = ZeebeWorker(channel) 38 | await worker.work() 39 | 40 | asyncio.run(main()) 41 | 42 | This does make it somewhat harder to add tasks to a worker. The recommended way to deal with this is using a :ref:`Task Router`. 43 | 44 | 45 | Worker connection options 46 | ------------------------- 47 | 48 | To change connection retries: 49 | 50 | .. code-block:: python 51 | 52 | worker = ZeebeWorker(grpc_channel, max_connection_retries=1) # Will only accept one failure and disconnect upon the second 53 | 54 | This means the worker will disconnect upon two consecutive failures. Each time the worker connects successfully the counter is reset. 55 | 56 | .. note:: 57 | 58 | The default behavior is 10 retries. If you want infinite retries just set to -1. 59 | 60 | ..note :: 61 | 62 | After the number of retries you set, the grpc channel closes and the ZeebeWorker stop. 63 | 64 | 65 | Add a task 66 | ---------- 67 | 68 | 69 | To add a task to the worker: 70 | 71 | .. code-block:: python 72 | 73 | @worker.task(task_type="my_task") 74 | async def my_task(x: int): 75 | return {"y": x + 1} 76 | 77 | # Or using a non-async function: 78 | 79 | @worker.task(task_type="my_task") 80 | def second_task(x: int): 81 | return {"y": x + 1} 82 | 83 | Stopping a worker 84 | ----------------- 85 | 86 | To stop a running worker: 87 | 88 | .. code-block:: python 89 | 90 | # Trigger this on some event (SIGTERM for example) 91 | async def shutdown(): 92 | await worker.stop() 93 | -------------------------------------------------------------------------------- /tests/unit/grpc_internals/zeebe_message_adapter_test.py: -------------------------------------------------------------------------------- 1 | from random import randint 2 | from uuid import uuid4 3 | 4 | import pytest 5 | 6 | from pyzeebe.errors import MessageAlreadyExistsError 7 | from pyzeebe.grpc_internals.types import BroadcastSignalResponse, PublishMessageResponse 8 | from pyzeebe.grpc_internals.zeebe_message_adapter import ZeebeMessageAdapter 9 | from tests.unit.utils.random_utils import RANDOM_RANGE 10 | 11 | 12 | @pytest.mark.anyio 13 | class TestPublishMessage: 14 | zeebe_message_adapter: ZeebeMessageAdapter 15 | 16 | @pytest.fixture(autouse=True) 17 | def set_up(self, zeebe_adapter: ZeebeMessageAdapter): 18 | self.zeebe_message_adapter = zeebe_adapter 19 | 20 | async def publish_message( 21 | self, 22 | name=str(uuid4()), 23 | variables={}, 24 | correlation_key=str(uuid4()), 25 | time_to_live_in_milliseconds=randint(0, RANDOM_RANGE), 26 | message_id=str(uuid4()), 27 | ): 28 | return await self.zeebe_message_adapter.publish_message( 29 | name, correlation_key, time_to_live_in_milliseconds, variables, message_id 30 | ) 31 | 32 | async def test_response_is_of_correct_type(self): 33 | response = await self.publish_message() 34 | 35 | assert isinstance(response, PublishMessageResponse) 36 | 37 | async def test_raises_on_invalid_name(self): 38 | with pytest.raises(TypeError): 39 | await self.publish_message(name=randint(0, RANDOM_RANGE)) 40 | 41 | async def test_raises_on_invalid_correlation_key(self): 42 | with pytest.raises(TypeError): 43 | await self.publish_message(correlation_key=randint(0, RANDOM_RANGE)) 44 | 45 | async def test_raises_on_invalid_time_to_live(self): 46 | with pytest.raises(TypeError): 47 | await self.publish_message(time_to_live_in_milliseconds=str(uuid4())) 48 | 49 | async def test_raises_on_duplicate_message(self): 50 | message_id = str(uuid4()) 51 | 52 | with pytest.raises(MessageAlreadyExistsError): 53 | await self.publish_message(message_id=message_id) 54 | await self.publish_message(message_id=message_id) 55 | 56 | 57 | @pytest.mark.anyio 58 | class TestBroadcastSignal: 59 | zeebe_message_adapter: ZeebeMessageAdapter 60 | 61 | @pytest.fixture(autouse=True) 62 | def set_up(self, zeebe_adapter: ZeebeMessageAdapter): 63 | self.zeebe_message_adapter = zeebe_adapter 64 | 65 | async def broadcast_signal( 66 | self, 67 | name=str(uuid4()), 68 | variables={}, 69 | ): 70 | return await self.zeebe_message_adapter.broadcast_signal(name, variables) 71 | 72 | async def test_response_is_of_correct_type(self): 73 | response = await self.broadcast_signal() 74 | 75 | assert isinstance(response, BroadcastSignalResponse) 76 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | import importlib.metadata 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | import os 16 | import sys 17 | 18 | import sphinx_rtd_theme 19 | 20 | sys.path.insert(0, os.path.abspath("..")) 21 | 22 | # -- Project information ----------------------------------------------------- 23 | 24 | sphinx_rtd_theme # So optimize imports doens't erase it 25 | 26 | project = "pyzeebe" 27 | copyright = "2020, Jonatan Martens" 28 | author = "Jonatan Martens" 29 | 30 | # The full version, including alpha/beta/rc tags 31 | release = importlib.metadata.version(project) 32 | 33 | # -- General configuration --------------------------------------------------- 34 | 35 | # Add any Sphinx extension module names here, as strings. They can be 36 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 37 | # ones. 38 | extensions = [ 39 | "sphinx_rtd_theme", 40 | "sphinx.ext.autodoc", 41 | "sphinx.ext.napoleon", 42 | "sphinx.ext.autosectionlabel", 43 | "sphinx.ext.intersphinx", 44 | ] 45 | 46 | # Add any paths that contain templates here, relative to this directory. 47 | templates_path = ["_templates"] 48 | 49 | # List of patterns, relative to source directory, that match files and 50 | # directories to ignore when looking for source files. 51 | # This pattern also affects html_static_path and html_extra_path. 52 | exclude_patterns = [] 53 | 54 | # -- Options for HTML output ------------------------------------------------- 55 | 56 | # The theme to use for HTML and HTML Help pages. See the documentation for 57 | # a list of builtin themes. 58 | # 59 | html_theme = "sphinx_rtd_theme" 60 | 61 | # Add any paths that contain custom static files (such as style sheets) here, 62 | # relative to this directory. They are copied after the builtin static files, 63 | # so a file named "default.css" will overwrite the builtin "default.css". 64 | html_static_path = ["_static"] 65 | 66 | version = importlib.metadata.version(project) 67 | 68 | master_doc = "index" 69 | 70 | # Looks for objects in external projects 71 | intersphinx_mapping = { 72 | "grpc": ("https://grpc.github.io/grpc/python/", None), 73 | "requests": ("https://requests.readthedocs.io/en/latest/", None), 74 | "requests_oauthlib": ("https://requests-oauthlib.readthedocs.io/en/latest/", None), 75 | "python": ("https://docs.python.org/3/", None), 76 | } 77 | -------------------------------------------------------------------------------- /.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 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 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 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | 140 | # vscode 141 | .vscode 142 | 143 | # pytest 144 | .pytest_cache 145 | 146 | # pycharm 147 | .idea 148 | -------------------------------------------------------------------------------- /docs/decorators.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | Decorators 3 | ========== 4 | 5 | A ``pyzeebe`` decorator is an async/sync function that receives a :py:class:`Job` instance and returns a :py:class:`Job`. 6 | 7 | .. code-block:: python 8 | 9 | Union[ 10 | Callable[[Job], Job], 11 | Callable[[Job], Awaitable[Job]] 12 | ] 13 | 14 | An example decorator: 15 | 16 | .. code-block:: python 17 | 18 | def logging_decorator(job: Job) -> Job: 19 | logging.info(job) 20 | return job 21 | 22 | # Or: 23 | 24 | async def logging_decorator(job: Job) -> Job: 25 | await async_logger.info(job) 26 | return job 27 | 28 | If a decorator raises an :class:`Exception` ``pyzeebe`` will just ignore it and continue the task/other decorators. 29 | 30 | Task Decorators 31 | --------------- 32 | 33 | To add a decorator to a :py:class:`Task`: 34 | 35 | .. code-block:: python 36 | 37 | from pyzeebe import Job 38 | 39 | 40 | def my_decorator(job: Job) -> Job: 41 | print(job) 42 | return job 43 | 44 | 45 | @worker.task(task_type="my_task", before=[my_decorator], after=[my_decorator]) 46 | def my_task(): 47 | return {} 48 | 49 | Now before and after a job is performed ``my_decorator`` will be called. 50 | 51 | TaskRouter Decorators 52 | --------------------- 53 | 54 | You can also add a decorator to a :py:class:`ZeebeTaskRouter`. All tasks registered under the router will then have the decorator. 55 | 56 | 57 | .. code-block:: python 58 | 59 | from pyzeebe import ZeebeTaskRouter, Job 60 | 61 | router = ZeebeTaskRouter() 62 | 63 | def my_decorator(job: Job) -> Job: 64 | print(job) 65 | return job 66 | 67 | 68 | router.before(my_decorator) 69 | router.after(my_decorator) 70 | 71 | Now all tasks registered to the router will have ``my_decorator``. 72 | 73 | Worker Decorators 74 | ----------------- 75 | 76 | You can also add a decorator to a :py:class:`ZeebeWorker`. All tasks registered under the worker will then have the decorator. 77 | 78 | 79 | .. code-block:: python 80 | 81 | from pyzeebe import ZeebeWorker, Job 82 | 83 | worker = ZeebeWorker() 84 | 85 | def my_decorator(job: Job) -> Job: 86 | print(job) 87 | return job 88 | 89 | 90 | worker.before(my_decorator) 91 | worker.after(my_decorator) 92 | 93 | Now all tasks registered to the worker will have ``my_decorator``. 94 | 95 | 96 | Decorator order 97 | --------------- 98 | 99 | ``Worker`` -> ``Router`` -> ``Task`` -> Actual task function -> ``Task`` -> ``Router`` -> ``Worker`` 100 | 101 | ``Worker`` - Decorators registered via the :py:class:`ZeebeWorker` class. 102 | 103 | ``Router`` - Decorators registered via the :py:class:`ZeebeTaskRouter` class and included in the worker with ``include_router``. 104 | 105 | ``Task`` - Decorators registered to the :py:class:`Task` class (with the worker/router ``task`` decorator). 106 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test pyzeebe 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | # 3.6, 3.7 and 3.8 only for passing branch rules 11 | # https://github.com/camunda-community-hub/pyzeebe/issues/381#issuecomment-2107430780 12 | unit-test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] 17 | 18 | steps: 19 | - uses: actions/checkout@v6 20 | - uses: actions/setup-python@v6 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | if: ${{ matrix.python-version != '3.6' && matrix.python-version != '3.7' && matrix.python-version != '3.8' }} 24 | - name: Install uv 25 | uses: astral-sh/setup-uv@v7 26 | with: 27 | version: "0.9.5" 28 | if: ${{ matrix.python-version != '3.6' && matrix.python-version != '3.7' && matrix.python-version != '3.8' }} 29 | - name: Install the project 30 | run: uv sync --locked --all-extras --dev 31 | if: ${{ matrix.python-version != '3.6' && matrix.python-version != '3.7' && matrix.python-version != '3.8' }} 32 | - name: Test with pytest 33 | run: | 34 | uv run coverage run --source=pyzeebe -m pytest tests/unit 35 | if: ${{ matrix.python-version != '3.6' && matrix.python-version != '3.7' && matrix.python-version != '3.8' }} 36 | - name: Upload to coveralls 37 | run: | 38 | uv run coveralls --service=github 39 | env: 40 | COVERALLS_PARALLEL: "true" 41 | COVERALLS_SERVICE_JOB_ID: ${{ github.run_id }} 42 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 43 | if: ${{ matrix.python-version != '3.6' && matrix.python-version != '3.7' && matrix.python-version != '3.8' }} 44 | 45 | integration-test: 46 | env: 47 | ZEEBE_IMAGE_VERSION: ${{ matrix.zeebe-version }} 48 | runs-on: ubuntu-latest 49 | strategy: 50 | matrix: 51 | zeebe-version: ["8.5.25", "8.6.29", "8.7.15", "8.8.0"] 52 | 53 | steps: 54 | - uses: actions/checkout@v6 55 | - uses: actions/setup-python@v6 56 | with: 57 | python-version: "3.14" 58 | - name: Install uv 59 | uses: astral-sh/setup-uv@v7 60 | with: 61 | version: "0.9.5" 62 | - name: Install the project 63 | run: uv sync --locked --all-extras --dev 64 | - name: Run integration tests 65 | run: | 66 | uv run pytest tests/integration 67 | 68 | finish-coveralls: 69 | runs-on: ubuntu-latest 70 | needs: unit-test 71 | steps: 72 | - uses: actions/checkout@v6 73 | - uses: actions/setup-python@v6 74 | with: 75 | python-version: "3.14" 76 | - name: Install uv 77 | uses: astral-sh/setup-uv@v7 78 | with: 79 | version: "0.9.5" 80 | - name: Install the project 81 | run: uv sync --locked --all-extras --dev 82 | - name: finish coveralls 83 | env: 84 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 85 | run: | 86 | uv run coveralls --service=github --finish 87 | -------------------------------------------------------------------------------- /tests/unit/job/job_test.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock 2 | from uuid import uuid4 3 | 4 | import pytest 5 | 6 | from pyzeebe import Job, JobController, JobStatus 7 | 8 | 9 | @pytest.mark.anyio 10 | class TestSetSuccessStatus: 11 | async def test_updates_job_in_zeebe(self, job: Job, job_controller: JobController): 12 | complete_job_mock = AsyncMock() 13 | job_controller._zeebe_adapter.complete_job = complete_job_mock 14 | 15 | await job_controller.set_success_status() 16 | 17 | complete_job_mock.assert_called_with(job_key=job.key, variables=job.variables) 18 | 19 | async def test_status_is_set(self, job: Job, job_controller: JobController): 20 | complete_job_mock = AsyncMock() 21 | job_controller._zeebe_adapter.complete_job = complete_job_mock 22 | 23 | await job_controller.set_success_status() 24 | 25 | assert job.status == JobStatus.Completed 26 | 27 | 28 | @pytest.mark.anyio 29 | class TestSetErrorStatus: 30 | async def test_updates_job_in_zeebe(self, job: Job, job_controller: JobController): 31 | throw_error_mock = AsyncMock() 32 | job_controller._zeebe_adapter.throw_error = throw_error_mock 33 | message = str(uuid4()) 34 | 35 | await job_controller.set_error_status(message) 36 | 37 | throw_error_mock.assert_called_with(job_key=job.key, message=message, error_code="", variables={}) 38 | 39 | async def test_updates_job_in_zeebe_with_code(self, job: Job, job_controller: JobController): 40 | throw_error_mock = AsyncMock() 41 | job_controller._zeebe_adapter.throw_error = throw_error_mock 42 | message = str(uuid4()) 43 | error_code = "custom-error-code" 44 | 45 | await job_controller.set_error_status(message, error_code) 46 | 47 | throw_error_mock.assert_called_with(job_key=job.key, message=message, error_code=error_code, variables={}) 48 | 49 | async def test_status_is_set(self, job: Job, job_controller: JobController): 50 | throw_error_mock = AsyncMock() 51 | job_controller._zeebe_adapter.throw_error = throw_error_mock 52 | message = str(uuid4()) 53 | 54 | await job_controller.set_error_status(message) 55 | 56 | assert job.status == JobStatus.ErrorThrown 57 | 58 | 59 | @pytest.mark.anyio 60 | class TestSetFailureStatus: 61 | async def test_updates_job_in_zeebe(self, job: Job, job_controller: JobController): 62 | fail_job_mock = AsyncMock() 63 | job_controller._zeebe_adapter.fail_job = fail_job_mock 64 | message = str(uuid4()) 65 | 66 | await job_controller.set_failure_status(message) 67 | 68 | fail_job_mock.assert_called_with( 69 | job_key=job.key, 70 | retries=job.retries - 1, 71 | message=message, 72 | retry_back_off_ms=0, 73 | variables={}, 74 | ) 75 | 76 | async def test_status_is_set(self, job: Job, job_controller: JobController): 77 | fail_job_mock = AsyncMock() 78 | job_controller._zeebe_adapter.fail_job = fail_job_mock 79 | message = str(uuid4()) 80 | 81 | await job_controller.set_failure_status(message) 82 | 83 | assert job.status == JobStatus.Failed 84 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "pyzeebe" 3 | version = "4.7.0" 4 | description = "Zeebe client api" 5 | requires-python = ">=3.9" 6 | readme = "README.md" 7 | license = "MIT" 8 | authors = [{ name = "Jonatan Martens", email = "jonatanmartenstav@gmail.com" }] 9 | maintainers = [{ name = "Dmitriy", email = "dimastbk@proton.me" }] 10 | keywords = ["zeebe", "workflow", "workflow-engine"] 11 | classifiers = [ 12 | "Programming Language :: Python :: 3", 13 | "License :: OSI Approved :: MIT License", 14 | "Operating System :: OS Independent", 15 | ] 16 | dependencies = [ 17 | "anyio>=4.6.0", 18 | "grpcio>=1.66", 19 | "grpcio-health-checking>=1.66", 20 | "protobuf>=5.28,<7.0", 21 | "oauthlib>=3.1.0", 22 | "requests-oauthlib>=1.3.0,<3.0.0", 23 | "typing-extensions>=4.11.0", 24 | ] 25 | 26 | [project.urls] 27 | homepage = "https://github.com/camunda-community-hub/pyzeebe" 28 | repository = "https://github.com/camunda-community-hub/pyzeebe" 29 | documentation = "https://camunda-community-hub.github.io/pyzeebe/" 30 | 31 | [dependency-groups] 32 | dev = [ 33 | "pytest>=7.4,<9.0", 34 | "pytest-grpc>=0.8.0", 35 | "pytest-mock>=3.11.1", 36 | "pylint>=2.17.5,<4.0.0", 37 | "black>=25.0.0", 38 | "mypy>=1.10.0", 39 | "coveralls>=3.3.1", 40 | "responses>=0.23.2,<0.26.0", 41 | "sphinx-rtd-theme>=3.0.0,<3.1.0", 42 | "sphinx>=6,<8", 43 | "testcontainers>=4.13.0", 44 | ] 45 | stubs = [ 46 | "types-oauthlib>=3.1.0", 47 | "types-requests-oauthlib>=1.3.0,<3.0.0", 48 | "types-protobuf>=5.29.1.20241207,<7.0.0.0", 49 | ] 50 | 51 | [tool.uv] 52 | default-groups = ["dev", "stubs"] 53 | 54 | [tool.mypy] 55 | python_version = "3.9" 56 | packages = ["pyzeebe"] 57 | strict = true 58 | 59 | [[tool.mypy.overrides]] 60 | module = "grpc" 61 | ignore_missing_imports = true 62 | 63 | [[tool.mypy.overrides]] 64 | module = "grpc_health.*" 65 | ignore_missing_imports = true 66 | 67 | [[tool.mypy.overrides]] 68 | module = "pyzeebe.proto.*" 69 | disable_error_code = ["import-untyped", "unused-ignore"] # "type-arg" 70 | 71 | [tool.pylint.master] 72 | max-line-length = 120 73 | disable = ["C0114", "C0115", "C0116"] 74 | 75 | [tool.black] 76 | line-length = 120 77 | extend-exclude = ''' 78 | ( 79 | .*_pb2.py # exclude autogenerated Protocol Buffer files anywhere in the project 80 | | .*_pb2_grpc.py 81 | ) 82 | ''' 83 | 84 | [tool.isort] 85 | profile = "black" 86 | extend_skip_glob = ["*_pb2.py", "*_pb2_grpc.py", "*.pyi"] 87 | 88 | [tool.pytest.ini_options] 89 | markers = ["e2e: end to end tests"] 90 | 91 | [tool.coverage.run] 92 | omit = ["pyzeebe/proto/*"] 93 | 94 | [tool.ruff] 95 | target-version = "py39" 96 | 97 | [tool.ruff.lint] 98 | select = [ 99 | "E", # pycodestyle errors 100 | "W", # pycodestyle warnings 101 | "F", # pyflakes 102 | "C", # flake8-comprehensions 103 | "B", # flake8-bugbear 104 | "TID", # flake8-tidy-imports 105 | "T20", # flake8-print 106 | "ASYNC", # flake8-async 107 | "FA", # flake8-future-annotations 108 | ] 109 | ignore = [ 110 | "E501", # line too long, handled by black 111 | ] 112 | 113 | [tool.ruff.lint.per-file-ignores] 114 | "update_proto.py" = ["T201"] 115 | 116 | [build-system] 117 | requires = ["hatchling"] 118 | build-backend = "hatchling.build" 119 | -------------------------------------------------------------------------------- /pyzeebe/grpc_internals/zeebe_adapter_base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from collections.abc import Callable 5 | from typing import TYPE_CHECKING, NoReturn, cast 6 | 7 | import grpc 8 | from grpc_health.v1.health_pb2_grpc import HealthStub 9 | from typing_extensions import TypeAlias 10 | 11 | from pyzeebe.errors import ( 12 | UnknownGrpcStatusCodeError, 13 | ZeebeBackPressureError, 14 | ZeebeDeadlineExceeded, 15 | ZeebeGatewayUnavailableError, 16 | ZeebeInternalError, 17 | ) 18 | from pyzeebe.errors.pyzeebe_errors import PyZeebeError 19 | from pyzeebe.grpc_internals.grpc_utils import is_error_status 20 | from pyzeebe.proto.gateway_pb2_grpc import GatewayStub 21 | 22 | if TYPE_CHECKING: 23 | from pyzeebe.proto.gateway_pb2_grpc import GatewayAsyncStub 24 | 25 | Callback: TypeAlias = Callable[[], None] 26 | logger = logging.getLogger(__name__) 27 | 28 | 29 | class ZeebeAdapterBase: 30 | def __init__(self, grpc_channel: grpc.aio.Channel, max_connection_retries: int = -1): 31 | self._channel = grpc_channel 32 | self._gateway_stub = cast("GatewayAsyncStub", GatewayStub(grpc_channel)) 33 | self._health_stub = HealthStub(grpc_channel) 34 | self._connected = True 35 | self.retrying_connection = False 36 | self._max_connection_retries = max_connection_retries 37 | self._current_connection_retries = 0 38 | self._on_disconnect_callbacks: list[Callback] = [] 39 | 40 | @property 41 | def connected(self) -> bool: 42 | return self._connected 43 | 44 | def add_disconnect_callback(self, callback: Callback) -> None: 45 | self._on_disconnect_callbacks.append(callback) 46 | 47 | def _should_retry(self) -> bool: 48 | return self._max_connection_retries == -1 or self._current_connection_retries < self._max_connection_retries 49 | 50 | async def _handle_grpc_error(self, grpc_error: grpc.aio.AioRpcError) -> NoReturn: 51 | try: 52 | pyzeebe_error = self._create_pyzeebe_error_from_grpc_error(grpc_error) 53 | raise pyzeebe_error 54 | except (ZeebeGatewayUnavailableError, ZeebeInternalError, ZeebeDeadlineExceeded): 55 | self._current_connection_retries += 1 56 | if not self._should_retry(): 57 | await self._close() 58 | raise 59 | 60 | async def _close(self) -> None: 61 | try: 62 | await self._channel.close() 63 | except Exception as exception: 64 | logger.exception("Failed to close channel, %s exception was raised", type(exception).__name__) 65 | finally: 66 | logger.info(f"Closing grpc channel after {self._max_connection_retries} retries.") 67 | self._connected = False 68 | for callback in self._on_disconnect_callbacks: 69 | callback() 70 | 71 | def _create_pyzeebe_error_from_grpc_error(self, grpc_error: grpc.aio.AioRpcError) -> PyZeebeError: 72 | if is_error_status(grpc_error, grpc.StatusCode.RESOURCE_EXHAUSTED): 73 | return ZeebeBackPressureError(grpc_error) 74 | if is_error_status(grpc_error, grpc.StatusCode.UNAVAILABLE, grpc.StatusCode.CANCELLED): 75 | return ZeebeGatewayUnavailableError(grpc_error) 76 | if is_error_status(grpc_error, grpc.StatusCode.INTERNAL): 77 | return ZeebeInternalError(grpc_error) 78 | elif is_error_status(grpc_error, grpc.StatusCode.DEADLINE_EXCEEDED): 79 | return ZeebeDeadlineExceeded(grpc_error) 80 | return UnknownGrpcStatusCodeError(grpc_error) 81 | -------------------------------------------------------------------------------- /tests/integration/test.bpmn: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Flow_1o209vx 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | Flow_1o209vx 18 | Flow_1andhz2 19 | Flow_00xikey 20 | 21 | 22 | Flow_00xikey 23 | 24 | 25 | 26 | Flow_1andhz2 27 | 28 | 29 | 30 | 31 | 32 | 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 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at jonatanmartenstav@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /tests/unit/channel/oauth_channel_test.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from unittest import mock 4 | 5 | import grpc 6 | import pytest 7 | 8 | from pyzeebe.channel.oauth_channel import ( 9 | create_camunda_cloud_channel, 10 | create_oauth2_client_credentials_channel, 11 | ) 12 | 13 | 14 | @pytest.fixture 15 | def mock_oauth2metadataplugin(): 16 | with mock.patch("pyzeebe.credentials.oauth.OAuth2MetadataPlugin") as mock_credentials: 17 | yield mock_credentials 18 | 19 | 20 | @pytest.mark.anyio 21 | async def test_create_oauth2_client_credentials_channel( 22 | mock_oauth2metadataplugin, 23 | ): 24 | 25 | grpc_address = "zeebe-gateway:26500" 26 | client_id = "client_id" 27 | client_secret = "client_secret" 28 | authorization_server = "https://authorization.server" 29 | scope = "scope" 30 | audience = "audience" 31 | 32 | channel = create_oauth2_client_credentials_channel( 33 | grpc_address=grpc_address, 34 | client_id=client_id, 35 | client_secret=client_secret, 36 | authorization_server=authorization_server, 37 | scope=scope, 38 | audience=audience, 39 | channel_credentials=None, 40 | channel_options=None, 41 | leeway=60, 42 | expire_in=None, 43 | ) 44 | 45 | assert isinstance(channel, grpc.aio.Channel) 46 | 47 | 48 | @mock.patch.dict( 49 | os.environ, 50 | { 51 | "ZEEBE_ADDRESS": "ZEEBE_ADDRESS", 52 | "CAMUNDA_CLIENT_ID": "CAMUNDA_CLIENT_ID", 53 | "CAMUNDA_CLIENT_SECRET": "CAMUNDA_CLIENT_SECRET", 54 | "CAMUNDA_OAUTH_URL": "CAMUNDA_OAUTH_URL", 55 | "CAMUNDA_TOKEN_AUDIENCE": "CAMUNDA_TOKEN_AUDIENCE", 56 | }, 57 | ) 58 | @pytest.mark.anyio 59 | @pytest.mark.xfail(sys.version_info < (3, 10), reason="https://github.com/python/cpython/issues/98086") 60 | async def test_create_oauth2_client_credentials_channel_using_environment_variables( 61 | mock_oauth2metadataplugin, 62 | ): 63 | channel = create_oauth2_client_credentials_channel() 64 | 65 | assert isinstance(channel, grpc.aio.Channel) 66 | 67 | 68 | @pytest.mark.anyio 69 | async def test_create_camunda_cloud_channel( 70 | mock_oauth2metadataplugin, 71 | ): 72 | client_id = "client_id" 73 | client_secret = "client_secret" 74 | cluster_id = "cluster_id" 75 | region = "bru-2" 76 | authorization_server = "https://login.cloud.camunda.io/oauth/token" 77 | scope = None 78 | audience = "zeebe.camunda.io" 79 | 80 | channel = create_camunda_cloud_channel( 81 | client_id=client_id, 82 | client_secret=client_secret, 83 | cluster_id=cluster_id, 84 | region=region, 85 | authorization_server=authorization_server, 86 | scope=scope, 87 | audience=audience, 88 | channel_credentials=None, 89 | channel_options=None, 90 | leeway=60, 91 | expire_in=None, 92 | ) 93 | 94 | assert isinstance(channel, grpc.aio.Channel) 95 | 96 | 97 | @mock.patch.dict( 98 | os.environ, 99 | { 100 | "CAMUNDA_CLUSTER_ID": "CAMUNDA_CLUSTER_ID", 101 | "CAMUNDA_CLUSTER_REGION": "CAMUNDA_CLUSTER_REGION", 102 | "CAMUNDA_CLIENT_ID": "CAMUNDA_CLIENT_ID", 103 | "CAMUNDA_CLIENT_SECRET": "CAMUNDA_CLIENT_SECRET", 104 | }, 105 | ) 106 | @pytest.mark.anyio 107 | @pytest.mark.xfail(sys.version_info < (3, 10), reason="https://github.com/python/cpython/issues/98086") 108 | async def test_create_camunda_cloud_channel_using_environment_variables( 109 | mock_oauth2metadataplugin, 110 | ): 111 | channel = create_camunda_cloud_channel() 112 | 113 | assert isinstance(channel, grpc.aio.Channel) 114 | -------------------------------------------------------------------------------- /examples/worker.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from pyzeebe import ( 4 | Job, 5 | ZeebeWorker, 6 | create_camunda_cloud_channel, 7 | create_insecure_channel, 8 | create_secure_channel, 9 | ) 10 | from pyzeebe.errors import BusinessError 11 | from pyzeebe.job.job import JobController 12 | 13 | 14 | # Use decorators to add functionality before and after tasks. These will not fail the task 15 | async def example_logging_task_decorator(job: Job) -> Job: 16 | print(job) 17 | return job 18 | 19 | 20 | # Will use environment variable ZEEBE_ADDRESS or localhost:26500 and NOT use TLS 21 | # create_insecure_channel returns a grpc.aio.Channel instance. If needed you 22 | # can build one on your own 23 | grpc_channel = create_insecure_channel() 24 | worker = ZeebeWorker(grpc_channel) 25 | 26 | # With custom grpc_address 27 | grpc_channel = create_insecure_channel(grpc_address="zeebe-gateway.mydomain:443") 28 | worker = ZeebeWorker(grpc_channel) 29 | 30 | # Will use environment variable ZEEBE_ADDRESS or localhost:26500 and use TLS 31 | grpc_channel = create_secure_channel() 32 | worker = ZeebeWorker(grpc_channel) 33 | 34 | # With custom grpc_address 35 | grpc_channel = create_secure_channel(grpc_address="zeebe-gateway.mydomain:443") 36 | worker = ZeebeWorker(grpc_channel) 37 | 38 | # Connect to zeebe cluster in camunda cloud 39 | grpc_channel = create_camunda_cloud_channel( 40 | client_id="", 41 | client_secret="", 42 | cluster_id="", 43 | region="", # Default value is bru-2 44 | ) 45 | worker = ZeebeWorker(grpc_channel) 46 | 47 | # Decorators allow us to add functionality before and after each job 48 | worker.before(example_logging_task_decorator) 49 | worker.after(example_logging_task_decorator) 50 | 51 | 52 | # Create a task like this: 53 | @worker.task(task_type="test") 54 | def example_task() -> dict: 55 | return {"output": "Hello world, test!"} 56 | 57 | 58 | # Or like this: 59 | @worker.task(task_type="test2") 60 | async def second_example_task() -> dict: 61 | return {"output": "Hello world, test2!"} 62 | 63 | 64 | # Create a task that will return a single value (not a dict) like this: 65 | # This task will return to zeebe: { y: x + 1 } 66 | @worker.task(task_type="add_one", single_value=True, variable_name="y") 67 | async def add_one(x: int) -> int: 68 | return x + 1 69 | 70 | 71 | # The default exception handler will call job_controller.set_error_status 72 | # on raised BusinessError, and propagate its error_code 73 | # so the specific business error can be caught in the Zeebe process 74 | @worker.task(task_type="business_exception_task") 75 | def exception_task(): 76 | raise BusinessError("invalid-credit-card") 77 | 78 | 79 | # Define a custom exception_handler for a task like so: 80 | async def example_exception_handler(exception: Exception, job: Job, job_controller: JobController) -> None: 81 | print(exception) 82 | print(job) 83 | await job_controller.set_failure_status(f"Failed to run task {job.type}. Reason: {exception}") 84 | 85 | 86 | @worker.task(task_type="exception_task", exception_handler=example_exception_handler) 87 | async def exception_task(): 88 | raise Exception("Oh no!") 89 | 90 | 91 | # We can also add decorators to tasks. 92 | # The order of the decorators will be as follows: 93 | # Worker decorators -> Task decorators -> Task -> Task decorators -> Worker decorators 94 | # Here is how: 95 | @worker.task( 96 | task_type="decorator_task", 97 | before=[example_logging_task_decorator], 98 | after=[example_logging_task_decorator], 99 | ) 100 | async def decorator_task() -> dict: 101 | return {"output": "Hello world, test!"} 102 | 103 | 104 | if __name__ == "__main__": 105 | loop = asyncio.get_running_loop() 106 | loop.run_until_complete(worker.work()) 107 | -------------------------------------------------------------------------------- /tests/integration/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | from uuid import uuid4 3 | 4 | import anyio 5 | import grpc 6 | import pytest 7 | from testcontainers.core.container import DockerContainer 8 | from testcontainers.core.wait_strategies import LogMessageWaitStrategy 9 | 10 | from pyzeebe import Job, ZeebeClient, ZeebeWorker, create_insecure_channel 11 | from pyzeebe.job.job import JobController 12 | from tests.integration.utils import ProcessRun, ProcessStats 13 | 14 | ZEEBE_IMAGE_VERSION = os.getenv("ZEEBE_IMAGE_VERSION", "8.5.25") 15 | 16 | 17 | @pytest.fixture(scope="package") 18 | def anyio_backend(): 19 | return "asyncio" 20 | 21 | 22 | @pytest.fixture(scope="session") 23 | def zeebe_container(): 24 | zeebe = ( 25 | DockerContainer(f"camunda/zeebe:{ZEEBE_IMAGE_VERSION}") 26 | # envs for camunda 8.8+ due to https://github.com/camunda/camunda/issues/31904 27 | .with_envs( 28 | CAMUNDA_SECURITY_AUTHENTICATION_UNPROTECTEDAPI="true", 29 | CAMUNDA_SECURITY_AUTHORIZATIONS_ENABLED="false", 30 | ) 31 | .with_exposed_ports(26500) 32 | .waiting_for(LogMessageWaitStrategy(r"Partition-1 recovered, marking it as healthy").with_startup_timeout(30)) 33 | ) 34 | 35 | with zeebe: 36 | yield zeebe 37 | 38 | 39 | @pytest.fixture(scope="package") 40 | async def grpc_channel(anyio_backend, zeebe_container: DockerContainer): 41 | return create_insecure_channel( 42 | grpc_address=f"{zeebe_container.get_container_host_ip()}:{zeebe_container.get_exposed_port(26500)}" 43 | ) 44 | 45 | 46 | @pytest.fixture(scope="package") 47 | def zeebe_client(grpc_channel: grpc.aio.Channel): 48 | return ZeebeClient(grpc_channel) 49 | 50 | 51 | @pytest.fixture(scope="package") 52 | def zeebe_worker(grpc_channel): 53 | return ZeebeWorker(grpc_channel) 54 | 55 | 56 | @pytest.fixture(scope="package") 57 | def task(zeebe_worker: ZeebeWorker, process_stats: ProcessStats): 58 | async def exception_handler(exc: Exception, job: Job, job_controller: JobController) -> None: 59 | await job_controller.set_error_status(f"Failed to run task {job.type}. Reason: {exc}") 60 | 61 | @zeebe_worker.task("test", exception_handler) 62 | async def task_handler(should_throw: bool, input: str, job: Job) -> dict: 63 | process_stats.add_process_run(ProcessRun(job.process_instance_key, job.variables)) 64 | if should_throw: 65 | raise Exception("Error thrown") 66 | else: 67 | return {"output": input + str(uuid4())} 68 | 69 | 70 | @pytest.fixture(autouse=True, scope="package") 71 | async def deploy_process(zeebe_client: ZeebeClient): 72 | try: 73 | integration_tests_path = os.path.join("tests", "integration") 74 | await zeebe_client.deploy_resource(os.path.join(integration_tests_path, "test.bpmn")) 75 | except FileNotFoundError: 76 | await zeebe_client.deploy_resource("test.bpmn") 77 | 78 | 79 | @pytest.fixture(autouse=True, scope="package") 80 | async def deploy_dmn(zeebe_client: ZeebeClient): 81 | try: 82 | integration_tests_path = os.path.join("tests", "integration") 83 | response = await zeebe_client.deploy_resource(os.path.join(integration_tests_path, "test.dmn")) 84 | except FileNotFoundError: 85 | response = await zeebe_client.deploy_resource("test.dmn") 86 | 87 | return response.deployments[0].decision_key 88 | 89 | 90 | @pytest.fixture(autouse=True, scope="package") 91 | async def start_worker(task, zeebe_worker: ZeebeWorker): 92 | async with anyio.create_task_group() as tg: 93 | tg.start_soon(zeebe_worker.work) 94 | yield 95 | tg.start_soon(zeebe_worker.stop) 96 | 97 | 98 | @pytest.fixture(scope="package") 99 | def process_name() -> str: 100 | return "test" 101 | 102 | 103 | @pytest.fixture 104 | def process_variables() -> dict: 105 | return {"input": str(uuid4()), "should_throw": False} 106 | 107 | 108 | @pytest.fixture(scope="package") 109 | def process_stats(process_name: str) -> ProcessStats: 110 | return ProcessStats(process_name) 111 | 112 | 113 | @pytest.fixture(scope="package") 114 | def decision_id() -> str: 115 | return "test" 116 | 117 | 118 | @pytest.fixture(scope="package") 119 | def decision_key(deploy_dmn) -> int: 120 | return deploy_dmn 121 | -------------------------------------------------------------------------------- /docs/channels_configuration.rst: -------------------------------------------------------------------------------- 1 | ====================== 2 | Channels Configuration 3 | ====================== 4 | 5 | This document describes the environment variables and configurations used for establishing different gRPC channel connections to Camunda (Zeebe) instances, either with or without authentication. 6 | 7 | Environment Variables 8 | --------------------- 9 | 10 | The following environment variables are used to configure channels. The variables are grouped according to their relevance and usage context in each type of channel. 11 | 12 | These variables are only considered if a corresponding argument was not passed (Unset) during initialization of a channel. 13 | 14 | Common Variables 15 | ---------------- 16 | 17 | This variables is used across all types of channels: 18 | 19 | **ZEEBE_ADDRESS** 20 | :Description: 21 | The default address of the Zeebe Gateway. 22 | 23 | :Usage: 24 | Used in both secure and insecure channel configurations. 25 | :func:`pyzeebe.create_insecure_channel` 26 | :func:`pyzeebe.create_secure_channel` 27 | 28 | :Default: 29 | ``"localhost:26500"`` 30 | 31 | Common OAuth2 Variables 32 | ----------------------- 33 | 34 | These variables are specifically for connecting to generic OAuth2 or Camunda Cloud instances. 35 | 36 | **CAMUNDA_CLIENT_ID** / **ZEEBE_CLIENT_ID** 37 | :Description: 38 | The client ID required for OAuth2 client credential authentication. 39 | 40 | :Usage: 41 | Required for OAuth2 and Camunda Cloud channels. 42 | :func:`pyzeebe.create_oauth2_client_credentials_channel` 43 | :func:`pyzeebe.create_camunda_cloud_channel` 44 | 45 | **CAMUNDA_CLIENT_SECRET** / **ZEEBE_CLIENT_SECRET** 46 | :Description: 47 | The client secret for the OAuth2 client. 48 | 49 | :Usage: 50 | Required for OAuth2 and Camunda Cloud channels. 51 | :func:`pyzeebe.create_oauth2_client_credentials_channel` 52 | :func:`pyzeebe.create_camunda_cloud_channel` 53 | 54 | OAuth2 Variables (Self-Managed) 55 | ------------------------------- 56 | 57 | These variables are primarily used for OAuth2 authentication in self-managed Camunda 8 instances. 58 | 59 | **CAMUNDA_OAUTH_URL** / **ZEEBE_AUTHORIZATION_SERVER_URL** 60 | :Description: 61 | Specifies the URL of the authorization server issuing access tokens to the client. 62 | 63 | :Usage: 64 | Required if channel initialization argument was not specified. 65 | :func:`pyzeebe.create_oauth2_client_credentials_channel` 66 | 67 | **CAMUNDA_TOKEN_AUDIENCE** / **ZEEBE_TOKEN_AUDIENCE** 68 | :Description: 69 | Specifies the audience for the OAuth2 token. 70 | 71 | :Usage: 72 | Used when creating OAuth2 or Camunda Cloud channels. 73 | :func:`pyzeebe.create_oauth2_client_credentials_channel` 74 | 75 | :Default: 76 | ``None`` if not provided. 77 | 78 | Camunda Cloud Variables (SaaS) 79 | ------------------------------ 80 | 81 | These variables are specifically for connecting to Camunda Cloud instances. 82 | 83 | **CAMUNDA_OAUTH_URL** / **ZEEBE_AUTHORIZATION_SERVER_URL** 84 | :Description: 85 | Specifies the URL of the authorization server issuing access tokens to the client. 86 | 87 | :Usage: 88 | Used in the OAuth2 and Camunda Cloud channel configurations. 89 | :func:`pyzeebe.create_camunda_cloud_channel` 90 | 91 | :Default: 92 | ``"https://login.cloud.camunda.io/oauth/token"`` if not specified. 93 | 94 | **CAMUNDA_CLUSTER_ID** 95 | :Description: 96 | The unique identifier for the Camunda Cloud cluster to connect to. 97 | 98 | :Usage: 99 | Required if channel initialization argument was not specified. 100 | :func:`pyzeebe.create_camunda_cloud_channel` 101 | 102 | **CAMUNDA_CLUSTER_REGION** 103 | :Description: 104 | The region where the Camunda Cloud cluster is hosted. 105 | 106 | :Usage: 107 | Required for Camunda Cloud channels. 108 | :func:`pyzeebe.create_camunda_cloud_channel` 109 | 110 | :Default: 111 | ``"bru-2"`` if not provided. 112 | 113 | **CAMUNDA_TOKEN_AUDIENCE** / **ZEEBE_TOKEN_AUDIENCE** 114 | :Description: 115 | Specifies the audience for the OAuth2 token. 116 | 117 | :Usage: 118 | Used when creating OAuth2 or Camunda Cloud channels. 119 | :func:`pyzeebe.create_camunda_cloud_channel` 120 | 121 | :Default: 122 | ``"zeebe.camunda.io"`` if not provided. 123 | -------------------------------------------------------------------------------- /tests/unit/worker/job_executor_test.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | from unittest.mock import AsyncMock, Mock 4 | 5 | import pytest 6 | 7 | from pyzeebe.grpc_internals.zeebe_adapter import ZeebeAdapter 8 | from pyzeebe.job.job import Job, JobController 9 | from pyzeebe.task.task import Task 10 | from pyzeebe.worker.job_executor import JobExecutor, create_job_callback 11 | from pyzeebe.worker.task_state import TaskState 12 | 13 | 14 | @pytest.fixture 15 | async def job_executor(task: Task, queue: asyncio.Queue, task_state: TaskState, zeebe_adapter: ZeebeAdapter): 16 | return JobExecutor(task, queue, task_state, zeebe_adapter) 17 | 18 | 19 | @pytest.fixture(autouse=True) 20 | def mock_job_handler(task: Task): 21 | task.job_handler = AsyncMock() 22 | 23 | 24 | @pytest.mark.anyio 25 | class TestExecuteOneJob: 26 | async def test_executes_jobs( 27 | self, job_executor: JobExecutor, job_from_task: Job, task: Task, job_controller: JobController 28 | ): 29 | await job_executor.execute_one_job(job_from_task, job_controller) 30 | 31 | task.job_handler.assert_called_with(job_from_task, job_controller) 32 | 33 | async def test_continues_on_deactivated_job( 34 | self, job_executor: JobExecutor, job_from_task: Job, job_controller: JobController 35 | ): 36 | await job_executor.execute_one_job(job_from_task, job_controller) 37 | await job_executor.execute_one_job(job_from_task, job_controller) 38 | 39 | 40 | @pytest.mark.anyio 41 | class TestGetNextJob: 42 | async def test_returns_expected_job(self, job_executor: JobExecutor, job_from_task: Job): 43 | await job_executor.jobs.put(job_from_task) 44 | 45 | assert await job_executor.get_next_job() == job_from_task 46 | 47 | 48 | @pytest.mark.anyio 49 | class TestShouldExecute: 50 | async def test_returns_true_on_default(self, job_executor: JobExecutor): 51 | assert job_executor.should_execute() 52 | 53 | async def test_returns_false_when_executor_is_stopped(self, job_executor: JobExecutor): 54 | await job_executor.stop() 55 | 56 | assert not job_executor.should_execute() 57 | 58 | 59 | @pytest.mark.anyio 60 | class TestStop: 61 | async def test_stops_executor(self, job_executor: JobExecutor): 62 | await job_executor.stop() 63 | 64 | await job_executor.execute() # Implicitly test that execute returns immediately 65 | 66 | 67 | @pytest.mark.anyio 68 | class TestCreateJobCallback: 69 | async def test_returns_callable(self, job_executor: JobExecutor, job_from_task: Job): 70 | callback = create_job_callback(job_executor, job_from_task) 71 | 72 | assert callable(callback) 73 | 74 | async def test_signals_that_job_is_done(self, job_executor: JobExecutor, job_from_task: Job): 75 | task_done_mock = Mock() 76 | remove_from_task_state_mock = Mock() 77 | job_executor.jobs.task_done = task_done_mock 78 | job_executor.task_state.remove = remove_from_task_state_mock 79 | 80 | callback = create_job_callback(job_executor, job_from_task) 81 | callback(asyncio.Future()) 82 | 83 | task_done_mock.assert_called_once() 84 | remove_from_task_state_mock.assert_called_once_with(job_from_task) 85 | 86 | async def test_signals_that_job_is_done_with_exception( 87 | self, job_executor: JobExecutor, job_from_task: Job, caplog: pytest.LogCaptureFixture 88 | ): 89 | task_done_mock = Mock() 90 | remove_from_task_state_mock = Mock() 91 | job_executor.jobs.task_done = task_done_mock 92 | job_executor.task_state.remove = remove_from_task_state_mock 93 | 94 | callback = create_job_callback(job_executor, job_from_task) 95 | 96 | exception = None 97 | try: 98 | json.dumps({"foo": object}) 99 | except TypeError as err: 100 | exception = err 101 | 102 | assert exception 103 | 104 | fut = asyncio.Future() 105 | fut.set_exception(exception) 106 | callback(fut) 107 | 108 | task_done_mock.assert_called_once() 109 | remove_from_task_state_mock.assert_called_once_with(job_from_task) 110 | 111 | assert len(caplog.records) == 1 112 | assert caplog.records[0].getMessage().startswith("Error in job executor. Task:") 113 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to this repository, please first discuss the change you wish to make via issue, 4 | email, or any other method with the owners of this repository before making a change. 5 | 6 | Please note we have a code of conduct, please follow it in all your interactions with the project. 7 | 8 | ## Pull Request Process 9 | 10 | 1. Ensure any install or build dependencies are removed before the end of the layer when doing a 11 | build. 12 | 2. Update the README.md with details of changes to the interface, this includes new environment 13 | variables, useful file locations etc. 14 | 3. Increase the version numbers in any examples files and the README.md to the new version that this 15 | Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/). 16 | 4. You may merge the Pull Request in once you have the sign-off of two other developers, or if you 17 | do not have permission to do that, you may request the second reviewer to merge it for you. 18 | 19 | ## Code of Conduct 20 | 21 | ### Our Pledge 22 | 23 | In the interest of fostering an open and welcoming environment, we as 24 | contributors and maintainers pledge to making participation in our project and 25 | our community a harassment-free experience for everyone, regardless of age, body 26 | size, disability, ethnicity, gender identity and expression, level of experience, 27 | nationality, personal appearance, race, religion, or sexual identity and 28 | orientation. 29 | 30 | ### Our Standards 31 | 32 | Examples of behavior that contributes to creating a positive environment 33 | include: 34 | 35 | * Using welcoming and inclusive language 36 | * Being respectful of differing viewpoints and experiences 37 | * Gracefully accepting constructive criticism 38 | * Focusing on what is best for the community 39 | * Showing empathy towards other community members 40 | 41 | Examples of unacceptable behavior by participants include: 42 | 43 | * The use of sexualized language or imagery and unwelcome sexual attention or 44 | advances 45 | * Trolling, insulting/derogatory comments, and personal or political attacks 46 | * Public or private harassment 47 | * Publishing others' private information, such as a physical or electronic 48 | address, without explicit permission 49 | * Other conduct which could reasonably be considered inappropriate in a 50 | professional setting 51 | 52 | ### Our Responsibilities 53 | 54 | Project maintainers are responsible for clarifying the standards of acceptable 55 | behavior and are expected to take appropriate and fair corrective action in 56 | response to any instances of unacceptable behavior. 57 | 58 | Project maintainers have the right and responsibility to remove, edit, or 59 | reject comments, commits, code, wiki edits, issues, and other contributions 60 | that are not aligned to this Code of Conduct, or to ban temporarily or 61 | permanently any contributor for other behaviors that they deem inappropriate, 62 | threatening, offensive, or harmful. 63 | 64 | ### Scope 65 | 66 | This Code of Conduct applies both within project spaces and in public spaces 67 | when an individual is representing the project or its community. Examples of 68 | representing a project or community include using an official project e-mail 69 | address, posting via an official social media account, or acting as an appointed 70 | representative at an online or offline event. Representation of a project may be 71 | further defined and clarified by project maintainers. 72 | 73 | ### Enforcement 74 | 75 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 76 | reported by contacting the project team at [INSERT EMAIL ADDRESS]. All 77 | complaints will be reviewed and investigated and will result in a response that 78 | is deemed necessary and appropriate to the circumstances. The project team is 79 | obligated to maintain confidentiality with regard to the reporter of an incident. 80 | Further details of specific enforcement policies may be posted separately. 81 | 82 | Project maintainers who do not follow or enforce the Code of Conduct in good 83 | faith may face temporary or permanent repercussions as determined by other 84 | members of the project's leadership. 85 | 86 | ### Attribution 87 | 88 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 89 | available at [http://contributor-covenant.org/version/1/4][version] 90 | 91 | [homepage]: http://contributor-covenant.org 92 | [version]: http://contributor-covenant.org/version/1/4/ 93 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![](https://img.shields.io/badge/Community%20Extension-An%20open%20source%20community%20maintained%20project-FF4700)](https://github.com/camunda-community-hub/community) 2 | [![](https://img.shields.io/badge/Lifecycle-Stable-brightgreen)](https://github.com/Camunda-Community-Hub/community/blob/main/extension-lifecycle.md#stable-) 3 | ![Compatible with: Camunda Platform 8](https://img.shields.io/badge/Compatible%20with-Camunda%20Platform%208-0072Ce) 4 | 5 | [![Coverage Status](https://coveralls.io/repos/github/JonatanMartens/pyzeebe/badge.svg?branch=master)](https://coveralls.io/github/JonatanMartens/pyzeebe?branch=master) 6 | ![Test pyzeebe](https://github.com/camunda-community-hub/pyzeebe/workflows/Test%20pyzeebe/badge.svg) 7 | ![Integration test pyzeebe](https://github.com/camunda-community-hub/pyzeebe/workflows/Integration%20test%20pyzeebe/badge.svg) 8 | 9 | ![GitHub tag (latest by date)](https://img.shields.io/github/v/tag/camunda-community-hub/pyzeebe) 10 | ![PyPI](https://img.shields.io/pypi/v/pyzeebe) 11 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pyzeebe) 12 | 13 | 14 | # Pyzeebe 15 | 16 | pyzeebe is a python grpc client for Zeebe. 17 | 18 | Zeebe version support: 19 | 20 | | Pyzeebe version | Tested Zeebe versions | 21 | | :-------------: | ---------------------- | 22 | | 4.x.x | 8.5, 8.6, 8.7, 8.8 | 23 | | 3.x.x | 1.0.0 | 24 | | 2.x.x | 0.23, 0.24, 0.25, 0.26 | 25 | | 1.x.x | 0.23, 0.24 | 26 | 27 | ## Getting Started 28 | 29 | To install: 30 | 31 | `pip install pyzeebe` 32 | 33 | For full documentation please visit: https://camunda-community-hub.github.io/pyzeebe/ 34 | 35 | ## Usage 36 | 37 | ### Worker 38 | 39 | The `ZeebeWorker` class gets jobs from the gateway and runs them. 40 | 41 | ```python 42 | import asyncio 43 | 44 | from pyzeebe import ZeebeWorker, Job, JobController, create_insecure_channel 45 | 46 | 47 | channel = create_insecure_channel(grpc_address="localhost:26500") # Create grpc channel 48 | worker = ZeebeWorker(channel) # Create a zeebe worker 49 | 50 | 51 | async def on_error(exception: Exception, job: Job, job_controller: JobController): 52 | """ 53 | on_error will be called when the task fails 54 | """ 55 | print(exception) 56 | await job_controller.set_error_status(job, f"Failed to handle job {job}. Error: {str(exception)}") 57 | 58 | 59 | @worker.task(task_type="example", exception_handler=on_error) 60 | def example_task(input: str) -> dict: 61 | return {"output": f"Hello world, {input}!"} 62 | 63 | 64 | @worker.task(task_type="example2", exception_handler=on_error) 65 | async def another_example_task(name: str) -> dict: # Tasks can also be async 66 | return {"output": f"Hello world, {name} from async task!"} 67 | 68 | loop = asyncio.get_running_loop() 69 | loop.run_until_complete(worker.work()) # Now every time that a task with type `example` or `example2` is called, the corresponding function will be called 70 | ``` 71 | 72 | Stop a worker: 73 | 74 | ```python 75 | await zeebe_worker.stop() # Stops worker after all running jobs have been completed 76 | ``` 77 | 78 | ### Client 79 | 80 | ```python 81 | from pyzeebe import ZeebeClient, create_insecure_channel 82 | 83 | # Create a zeebe client 84 | channel = create_insecure_channel(grpc_address="localhost:26500") 85 | zeebe_client = ZeebeClient(channel) 86 | 87 | # Run a Zeebe process instance 88 | process_instance_key = await zeebe_client.run_process(bpmn_process_id="My zeebe process", variables={}) 89 | 90 | # Run a process and receive the result 91 | process_instance_key, process_result = await zeebe_client.run_process_with_result( 92 | bpmn_process_id="My zeebe process", 93 | timeout=10000 94 | ) 95 | 96 | # Deploy a BPMN process definition 97 | await zeebe_client.deploy_resource("process.bpmn") 98 | 99 | # Cancel a running process 100 | await zeebe_client.cancel_process_instance(process_instance_key=12345) 101 | 102 | # Publish message 103 | await zeebe_client.publish_message(name="message_name", correlation_key="some_id") 104 | 105 | ``` 106 | 107 | ## Tests 108 | 109 | Use the package manager [pip](https://pip.pypa.io/en/stable/) to install pyzeebe 110 | 111 | `pytest tests/unit` 112 | 113 | ## Contributing 114 | 115 | Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. 116 | 117 | Please make sure to update tests as appropriate. 118 | 119 | ## Versioning 120 | 121 | We use [SemVer](semver.org) for versioning. For the versions available, see the tags on this repository. 122 | 123 | ## License 124 | 125 | We use the MIT license, see [LICENSE.md](LICENSE.md) for details 126 | -------------------------------------------------------------------------------- /tests/unit/client/client_test.py: -------------------------------------------------------------------------------- 1 | from random import randint 2 | from unittest.mock import AsyncMock 3 | from uuid import uuid4 4 | 5 | import pytest 6 | 7 | from pyzeebe import ZeebeClient 8 | from pyzeebe.errors import ProcessDefinitionNotFoundError 9 | from pyzeebe.grpc_internals.types import ( 10 | CancelProcessInstanceResponse, 11 | CreateProcessInstanceResponse, 12 | CreateProcessInstanceWithResultResponse, 13 | EvaluateDecisionResponse, 14 | ) 15 | from tests.unit.utils.gateway_mock import GatewayMock 16 | 17 | 18 | @pytest.mark.anyio 19 | async def test_run_process(zeebe_client: ZeebeClient, grpc_servicer: GatewayMock): 20 | bpmn_process_id = str(uuid4()) 21 | version = randint(0, 10) 22 | grpc_servicer.mock_deploy_process(bpmn_process_id, version, []) 23 | assert isinstance( 24 | await zeebe_client.run_process(bpmn_process_id=bpmn_process_id, variables={}, version=version), 25 | CreateProcessInstanceResponse, 26 | ) 27 | 28 | 29 | @pytest.mark.anyio 30 | class TestRunProcessWithResult: 31 | async def test_run_process_with_result_type(self, zeebe_client: ZeebeClient, deployed_process): 32 | bpmn_process_id, version = deployed_process 33 | 34 | response = await zeebe_client.run_process_with_result(bpmn_process_id, {}, version) 35 | 36 | assert isinstance(response, CreateProcessInstanceWithResultResponse) 37 | 38 | async def test_run_process_with_result_instance_key_is_int(self, zeebe_client: ZeebeClient, deployed_process): 39 | bpmn_process_id, version = deployed_process 40 | 41 | response = await zeebe_client.run_process_with_result(bpmn_process_id, {}, version) 42 | 43 | assert isinstance(response.process_instance_key, int) 44 | 45 | async def test_run_process_with_result_output_variables_are_as_expected( 46 | self, zeebe_client: ZeebeClient, deployed_process 47 | ): 48 | expected = {} 49 | bpmn_process_id, version = deployed_process 50 | 51 | response = await zeebe_client.run_process_with_result(bpmn_process_id, {}, version) 52 | 53 | assert response.variables == expected 54 | 55 | 56 | @pytest.mark.anyio 57 | async def test_deploy_resource(zeebe_client: ZeebeClient): 58 | zeebe_client.zeebe_adapter.deploy_resource = AsyncMock() 59 | file_path = str(uuid4()) 60 | await zeebe_client.deploy_resource(file_path) 61 | zeebe_client.zeebe_adapter.deploy_resource.assert_called_with(file_path, tenant_id=None) 62 | 63 | 64 | @pytest.mark.anyio 65 | async def test_run_non_existent_process(zeebe_client: ZeebeClient): 66 | with pytest.raises(ProcessDefinitionNotFoundError): 67 | await zeebe_client.run_process(bpmn_process_id=str(uuid4())) 68 | 69 | 70 | @pytest.mark.anyio 71 | async def test_run_non_existent_process_with_result(zeebe_client: ZeebeClient): 72 | with pytest.raises(ProcessDefinitionNotFoundError): 73 | await zeebe_client.run_process_with_result(bpmn_process_id=str(uuid4())) 74 | 75 | 76 | @pytest.mark.anyio 77 | async def test_cancel_process_instance(zeebe_client: ZeebeClient, grpc_servicer: GatewayMock): 78 | bpmn_process_id = str(uuid4()) 79 | version = randint(0, 10) 80 | grpc_servicer.mock_deploy_process(bpmn_process_id, version, []) 81 | response = await zeebe_client.run_process(bpmn_process_id=bpmn_process_id, variables={}, version=version) 82 | assert isinstance( 83 | await zeebe_client.cancel_process_instance(process_instance_key=response.process_instance_key), 84 | CancelProcessInstanceResponse, 85 | ) 86 | 87 | 88 | @pytest.mark.anyio 89 | async def test_evaluate_decision(zeebe_client: ZeebeClient, grpc_servicer: GatewayMock): 90 | decision_id = str(uuid4()) 91 | decision_key = randint(0, 10) 92 | grpc_servicer.mock_deploy_decision(decision_key, decision_id) 93 | assert isinstance( 94 | await zeebe_client.evaluate_decision(decision_key, decision_id), 95 | EvaluateDecisionResponse, 96 | ) 97 | 98 | 99 | @pytest.mark.anyio 100 | async def test_broadcast_signal(zeebe_client: ZeebeClient): 101 | await zeebe_client.broadcast_signal(signal_name=str(uuid4())) 102 | 103 | 104 | @pytest.mark.anyio 105 | async def test_publish_message(zeebe_client: ZeebeClient): 106 | await zeebe_client.publish_message(name=str(uuid4()), correlation_key=str(uuid4())) 107 | 108 | 109 | @pytest.mark.anyio 110 | async def test_topology(zeebe_client: ZeebeClient): 111 | await zeebe_client.topology() 112 | 113 | 114 | @pytest.mark.xfail(reason="Required GRPC health checking stubs") 115 | @pytest.mark.anyio 116 | async def test_healthcheck(zeebe_client: ZeebeClient): 117 | await zeebe_client.healthcheck() 118 | -------------------------------------------------------------------------------- /pyzeebe/client/sync_client.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import os 5 | 6 | import grpc 7 | 8 | from pyzeebe import ZeebeClient 9 | from pyzeebe.grpc_internals.types import ( 10 | BroadcastSignalResponse, 11 | CancelProcessInstanceResponse, 12 | CreateProcessInstanceResponse, 13 | CreateProcessInstanceWithResultResponse, 14 | DeployResourceResponse, 15 | EvaluateDecisionResponse, 16 | HealthCheckResponse, 17 | PublishMessageResponse, 18 | TopologyResponse, 19 | ) 20 | from pyzeebe.types import Variables 21 | 22 | 23 | class SyncZeebeClient: 24 | def __init__(self, grpc_channel: grpc.aio.Channel, max_connection_retries: int = 10) -> None: 25 | self.loop = asyncio.get_event_loop() 26 | self.client = ZeebeClient(grpc_channel, max_connection_retries) 27 | 28 | def run_process( 29 | self, 30 | bpmn_process_id: str, 31 | variables: Variables | None = None, 32 | version: int = -1, 33 | tenant_id: str | None = None, 34 | ) -> CreateProcessInstanceResponse: 35 | return self.loop.run_until_complete(self.client.run_process(bpmn_process_id, variables, version, tenant_id)) 36 | 37 | run_process.__doc__ = ZeebeClient.publish_message.__doc__ 38 | 39 | def run_process_with_result( 40 | self, 41 | bpmn_process_id: str, 42 | variables: Variables | None = None, 43 | version: int = -1, 44 | timeout: int = 0, 45 | variables_to_fetch: list[str] | None = None, 46 | tenant_id: str | None = None, 47 | ) -> CreateProcessInstanceWithResultResponse: 48 | return self.loop.run_until_complete( 49 | self.client.run_process_with_result( 50 | bpmn_process_id, variables, version, timeout, variables_to_fetch, tenant_id 51 | ) 52 | ) 53 | 54 | run_process_with_result.__doc__ = ZeebeClient.publish_message.__doc__ 55 | 56 | def cancel_process_instance(self, process_instance_key: int) -> CancelProcessInstanceResponse: 57 | return self.loop.run_until_complete(self.client.cancel_process_instance(process_instance_key)) 58 | 59 | cancel_process_instance.__doc__ = ZeebeClient.cancel_process_instance.__doc__ 60 | 61 | def deploy_resource( 62 | self, *resource_file_path: str | os.PathLike[str], tenant_id: str | None = None 63 | ) -> DeployResourceResponse: 64 | return self.loop.run_until_complete(self.client.deploy_resource(*resource_file_path, tenant_id=tenant_id)) 65 | 66 | deploy_resource.__doc__ = ZeebeClient.deploy_resource.__doc__ 67 | 68 | def evaluate_decision( 69 | self, 70 | decision_key: int | None, 71 | decision_id: str | None, 72 | variables: Variables | None = None, 73 | tenant_id: str | None = None, 74 | ) -> EvaluateDecisionResponse: 75 | return self.loop.run_until_complete( 76 | self.client.evaluate_decision(decision_key, decision_id, variables=variables, tenant_id=tenant_id) 77 | ) 78 | 79 | evaluate_decision.__doc__ = ZeebeClient.evaluate_decision.__doc__ 80 | 81 | def broadcast_signal( 82 | self, 83 | signal_name: str, 84 | variables: Variables | None = None, 85 | tenant_id: str | None = None, 86 | ) -> BroadcastSignalResponse: 87 | return self.loop.run_until_complete( 88 | self.client.broadcast_signal( 89 | signal_name, 90 | variables, 91 | tenant_id, 92 | ) 93 | ) 94 | 95 | broadcast_signal.__doc__ = ZeebeClient.broadcast_signal.__doc__ 96 | 97 | def publish_message( 98 | self, 99 | name: str, 100 | correlation_key: str, 101 | variables: Variables | None = None, 102 | time_to_live_in_milliseconds: int = 60000, 103 | message_id: str | None = None, 104 | tenant_id: str | None = None, 105 | ) -> PublishMessageResponse: 106 | return self.loop.run_until_complete( 107 | self.client.publish_message( 108 | name, 109 | correlation_key, 110 | variables, 111 | time_to_live_in_milliseconds, 112 | message_id, 113 | tenant_id, 114 | ) 115 | ) 116 | 117 | publish_message.__doc__ = ZeebeClient.publish_message.__doc__ 118 | 119 | def topology(self) -> TopologyResponse: 120 | return self.loop.run_until_complete(self.client.topology()) 121 | 122 | topology.__doc__ = ZeebeClient.topology.__doc__ 123 | 124 | def healthcheck(self) -> HealthCheckResponse: 125 | return self.loop.run_until_complete(self.client.healthcheck()) 126 | 127 | healthcheck.__doc__ = ZeebeClient.healthcheck.__doc__ 128 | -------------------------------------------------------------------------------- /pyzeebe/task/task_builder.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import functools 4 | import inspect 5 | import logging 6 | from collections.abc import Sequence 7 | from typing import Any, TypeVar 8 | 9 | from typing_extensions import ParamSpec 10 | 11 | from pyzeebe import Job 12 | from pyzeebe.function_tools import DictFunction, Function 13 | from pyzeebe.function_tools.async_tools import asyncify, is_async_function 14 | from pyzeebe.function_tools.dict_tools import convert_to_dict_function 15 | from pyzeebe.function_tools.parameter_tools import get_job_parameter_name 16 | from pyzeebe.job.job import JobController 17 | from pyzeebe.task.exception_handler import default_exception_handler 18 | from pyzeebe.task.task import Task 19 | from pyzeebe.task.task_config import TaskConfig 20 | from pyzeebe.task.types import AsyncTaskDecorator, DecoratorRunner, JobHandler 21 | from pyzeebe.types import Variables 22 | 23 | P = ParamSpec("P") 24 | R = TypeVar("R") 25 | 26 | logger = logging.getLogger(__name__) 27 | 28 | 29 | def build_task(task_function: Function[..., Any], task_config: TaskConfig) -> Task: 30 | task_config.job_parameter_name = get_job_parameter_name(task_function) 31 | return Task(task_function, build_job_handler(task_function, task_config), task_config) 32 | 33 | 34 | def build_job_handler(task_function: Function[..., Any], task_config: TaskConfig) -> JobHandler: 35 | prepared_task_function = prepare_task_function(task_function, task_config) 36 | 37 | before_decorator_runner = create_decorator_runner(task_config.before) 38 | after_decorator_runner = create_decorator_runner(task_config.after) 39 | 40 | @functools.wraps(task_function) 41 | async def job_handler(job: Job, job_controller: JobController) -> Job: 42 | job = await before_decorator_runner(job) 43 | return_variables, succeeded = await run_original_task_function( 44 | prepared_task_function, task_config, job, job_controller 45 | ) 46 | job.set_task_result(return_variables) 47 | await job_controller.set_running_after_decorators_status() 48 | job = await after_decorator_runner(job) 49 | if succeeded: 50 | await job_controller.set_success_status(variables=return_variables) 51 | return job 52 | 53 | return job_handler 54 | 55 | 56 | def prepare_task_function(task_function: Function[P, R], task_config: TaskConfig) -> DictFunction[P]: 57 | if not is_async_function(task_function): 58 | task_function = asyncify(task_function) 59 | 60 | if task_config.single_value: 61 | return convert_to_dict_function(task_function, task_config.variable_name) 62 | # we check return type in task decorator 63 | return task_function # type: ignore[return-value] 64 | 65 | 66 | async def run_original_task_function( 67 | task_function: DictFunction[...], task_config: TaskConfig, job: Job, job_controller: JobController 68 | ) -> tuple[Variables, bool]: 69 | try: 70 | if task_config.variables_to_fetch is None: 71 | variables: dict[str, Any] = {} 72 | elif task_wants_all_variables(task_config): 73 | if only_job_is_required_in_task_function(task_function): 74 | variables = {} 75 | else: 76 | variables = {**job.variables} 77 | else: 78 | variables = { 79 | k: v 80 | for k, v in job.variables.items() 81 | if k in task_config.variables_to_fetch or k == task_config.job_parameter_name 82 | } 83 | 84 | if task_config.job_parameter_name: 85 | variables[task_config.job_parameter_name] = job 86 | 87 | returned_value = await task_function(**variables) 88 | 89 | if returned_value is None: 90 | returned_value = {} 91 | 92 | return returned_value, True 93 | except Exception as e: 94 | logger.debug("Failed job: %s. Error: %s.", job, e) 95 | exception_handler = task_config.exception_handler or default_exception_handler 96 | await exception_handler(e, job, job_controller) 97 | return job.variables, False 98 | 99 | 100 | def only_job_is_required_in_task_function(task_function: DictFunction[...]) -> bool: 101 | function_signature = inspect.signature(task_function) 102 | return all(param.annotation == Job for param in function_signature.parameters.values()) 103 | 104 | 105 | def task_wants_all_variables(task_config: TaskConfig) -> bool: 106 | return task_config.variables_to_fetch == [] 107 | 108 | 109 | def create_decorator_runner(decorators: Sequence[AsyncTaskDecorator]) -> DecoratorRunner: 110 | async def decorator_runner(job: Job) -> Job: 111 | for decorator in decorators: 112 | job = await run_decorator(decorator, job) 113 | return job 114 | 115 | return decorator_runner 116 | 117 | 118 | async def run_decorator(decorator: AsyncTaskDecorator, job: Job) -> Job: 119 | try: 120 | return await decorator(job) 121 | except Exception as e: 122 | logger.warning("Failed to run decorator %s. Exception: %s", decorator, e, exc_info=True) 123 | return job 124 | -------------------------------------------------------------------------------- /tests/unit/conftest.py: -------------------------------------------------------------------------------- 1 | from random import randint 2 | from unittest.mock import AsyncMock, MagicMock 3 | from uuid import uuid4 4 | 5 | import grpc 6 | import pytest 7 | 8 | from pyzeebe import Job, ZeebeClient, ZeebeWorker 9 | from pyzeebe.grpc_internals.zeebe_adapter import ZeebeAdapter 10 | from pyzeebe.job.job import JobController 11 | from pyzeebe.job.job_status import JobStatus 12 | from pyzeebe.task import task_builder 13 | from pyzeebe.task.task_config import TaskConfig 14 | from pyzeebe.worker.task_router import ZeebeTaskRouter 15 | from pyzeebe.worker.task_state import TaskState 16 | from tests.unit.utils.gateway_mock import GatewayMock 17 | from tests.unit.utils.random_utils import random_job 18 | 19 | 20 | @pytest.fixture 21 | def anyio_backend(): 22 | return "asyncio" 23 | 24 | 25 | @pytest.fixture 26 | def job_controller(job): 27 | return JobController(job=job, zeebe_adapter=AsyncMock()) 28 | 29 | 30 | @pytest.fixture 31 | def mocked_job_controller(job): 32 | job_controller = JobController(job, MagicMock()) 33 | job_controller.set_running_after_decorators_status = AsyncMock() 34 | job_controller.set_success_status = AsyncMock() 35 | job_controller.set_failure_status = AsyncMock() 36 | job_controller.set_error_status = AsyncMock() 37 | return job_controller 38 | 39 | 40 | @pytest.fixture 41 | def job(): 42 | return random_job() 43 | 44 | 45 | @pytest.fixture 46 | def job_from_task(task): 47 | job = random_job(task, variables=dict(x=str(uuid4()))) 48 | return job 49 | 50 | 51 | @pytest.fixture 52 | async def zeebe_adapter(aio_grpc_channel: grpc.aio.Channel): 53 | adapter = ZeebeAdapter(aio_grpc_channel) 54 | return adapter 55 | 56 | 57 | @pytest.fixture 58 | async def zeebe_client(aio_grpc_channel: grpc.aio.Channel): 59 | client = ZeebeClient(aio_grpc_channel) 60 | return client 61 | 62 | 63 | @pytest.fixture 64 | async def zeebe_worker(aio_grpc_channel: grpc.aio.Channel): 65 | worker = ZeebeWorker(aio_grpc_channel) 66 | return worker 67 | 68 | 69 | @pytest.fixture 70 | def task(original_task_function, task_config): 71 | return task_builder.build_task(original_task_function, task_config) 72 | 73 | 74 | @pytest.fixture 75 | def first_active_job(task, job_from_task, grpc_servicer) -> str: 76 | grpc_servicer.active_jobs[job_from_task.key] = job_from_task 77 | return job_from_task 78 | 79 | 80 | @pytest.fixture 81 | def deactivated_job(task, job_from_task, grpc_servicer) -> str: 82 | job_from_task._set_status(JobStatus.Completed) 83 | grpc_servicer.active_jobs[job_from_task.key] = job_from_task 84 | return job_from_task 85 | 86 | 87 | @pytest.fixture 88 | def task_config(task_type, variables_to_fetch=None): 89 | return TaskConfig( 90 | type=task_type, 91 | exception_handler=AsyncMock(), 92 | timeout_ms=10000, 93 | max_jobs_to_activate=32, 94 | max_running_jobs=32, 95 | variables_to_fetch=variables_to_fetch or [], 96 | single_value=False, 97 | variable_name="", 98 | before=[], 99 | after=[], 100 | ) 101 | 102 | 103 | @pytest.fixture 104 | def task_type(): 105 | return str(uuid4()) 106 | 107 | 108 | @pytest.fixture 109 | def original_task_function(): 110 | def original_function(): 111 | pass 112 | 113 | mock = MagicMock(wraps=original_function) 114 | mock.__code__ = original_function.__code__ 115 | return mock 116 | 117 | 118 | @pytest.fixture 119 | def router(): 120 | return ZeebeTaskRouter() 121 | 122 | 123 | @pytest.fixture 124 | def routers(): 125 | return [ZeebeTaskRouter() for _ in range(0, randint(2, 100))] 126 | 127 | 128 | @pytest.fixture 129 | def decorator(): 130 | async def simple_decorator(job: Job) -> Job: 131 | return job 132 | 133 | return AsyncMock(wraps=simple_decorator) 134 | 135 | 136 | @pytest.fixture 137 | def exception_handler(): 138 | async def simple_exception_handler(e: Exception, job: Job, job_controller: JobController) -> None: 139 | return None 140 | 141 | return AsyncMock(wraps=simple_exception_handler) 142 | 143 | 144 | @pytest.fixture(scope="module") 145 | def grpc_add_to_server(): 146 | from pyzeebe.proto.gateway_pb2_grpc import add_GatewayServicer_to_server 147 | 148 | return add_GatewayServicer_to_server 149 | 150 | 151 | @pytest.fixture(scope="module") 152 | def grpc_servicer(): 153 | return GatewayMock() 154 | 155 | 156 | @pytest.fixture(scope="module") 157 | def grpc_stub_cls(grpc_channel): 158 | from pyzeebe.proto.gateway_pb2_grpc import GatewayStub 159 | 160 | return GatewayStub 161 | 162 | 163 | @pytest.fixture 164 | async def aio_create_grpc_channel(request, grpc_addr, grpc_server): 165 | return grpc.aio.insecure_channel(grpc_addr) 166 | 167 | 168 | @pytest.fixture 169 | async def aio_grpc_channel(aio_create_grpc_channel): 170 | async with aio_create_grpc_channel as channel: 171 | yield channel 172 | 173 | 174 | @pytest.fixture() 175 | def aio_grpc_channel_mock(): 176 | return AsyncMock(spec_set=grpc.aio.Channel) 177 | 178 | 179 | @pytest.fixture 180 | def task_state() -> TaskState: 181 | return TaskState() 182 | -------------------------------------------------------------------------------- /pyzeebe/channel/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | 5 | from pyzeebe.errors import SettingsError 6 | 7 | DEFAULT_ZEEBE_ADDRESS = "localhost:26500" 8 | 9 | 10 | def get_zeebe_address(default: str | None = None) -> str: 11 | """ 12 | Get the Zeebe Gateway Address. 13 | 14 | Args: 15 | default (str, optional): Default value to be used if no other value was discovered. 16 | 17 | Returns: 18 | str: ZEEBE_ADDRESS environment variable or provided default or "localhost:26500" 19 | """ 20 | return os.getenv("ZEEBE_ADDRESS") or default or DEFAULT_ZEEBE_ADDRESS 21 | 22 | 23 | def get_camunda_oauth_url(default: str | None = None) -> str: 24 | """ 25 | Get the Camunda OAuth URL or Zeebe Authorization Server URL. 26 | 27 | Args: 28 | default (str, optional): Default value to be used if no other value was discovered. 29 | 30 | Returns: 31 | str: CAMUNDA_OAUTH_URL or ZEEBE_AUTHORIZATION_SERVER_URL environment variable or provided default 32 | 33 | Raises: 34 | SettingsError: If neither CAMUNDA_OAUTH_URL nor ZEEBE_AUTHORIZATION_SERVER_URL is provided. 35 | """ 36 | r = os.getenv("CAMUNDA_OAUTH_URL") or os.getenv("ZEEBE_AUTHORIZATION_SERVER_URL") or default 37 | 38 | if r is None: 39 | raise SettingsError("No CAMUNDA_OAUTH_URL or ZEEBE_AUTHORIZATION_SERVER_URL provided!") 40 | 41 | return r 42 | 43 | 44 | def get_camunda_client_id() -> str: 45 | """ 46 | Get the Camunda Client ID. 47 | 48 | Returns: 49 | str: CAMUNDA_CLIENT_ID or ZEEBE_CLIENT_ID environment variable 50 | 51 | Raises: 52 | SettingsError: If neither CAMUNDA_CLIENT_ID nor ZEEBE_CLIENT_ID is provided. 53 | """ 54 | r = os.getenv("CAMUNDA_CLIENT_ID") or os.getenv("ZEEBE_CLIENT_ID") 55 | 56 | if r is None: 57 | raise SettingsError("No CAMUNDA_CLIENT_ID or ZEEBE_CLIENT_ID provided!") 58 | 59 | return r 60 | 61 | 62 | def get_camunda_client_secret() -> str: 63 | """ 64 | Get the Camunda Client Secret. 65 | 66 | Returns: 67 | str: CAMUNDA_CLIENT_SECRET or ZEEBE_CLIENT_SECRET environment variable 68 | 69 | Raises: 70 | SettingsError: If neither CAMUNDA_CLIENT_SECRET nor ZEEBE_CLIENT_SECRET is provided. 71 | """ 72 | r = os.getenv("CAMUNDA_CLIENT_SECRET") or os.getenv("ZEEBE_CLIENT_SECRET") 73 | 74 | if r is None: 75 | raise SettingsError("No CAMUNDA_CLIENT_SECRET or ZEEBE_CLIENT_SECRET provided!") 76 | 77 | return r 78 | 79 | 80 | def get_camunda_cluster_id() -> str: 81 | """ 82 | Get the Camunda Cluster ID. 83 | 84 | Returns: 85 | str: CAMUNDA_CLUSTER_ID environment variable 86 | 87 | Raises: 88 | SettingsError: If CAMUNDA_CLUSTER_ID is not provided. 89 | """ 90 | r = os.getenv("CAMUNDA_CLUSTER_ID") 91 | 92 | if r is None: 93 | raise SettingsError("No CAMUNDA_CLUSTER_ID provided!") 94 | 95 | return r 96 | 97 | 98 | def get_camunda_cluster_region(default: str | None = None) -> str: 99 | """ 100 | Get the Camunda Cluster Region. 101 | 102 | Args: 103 | default (str, optional): Default value to be used if no other value was discovered. 104 | 105 | Returns: 106 | str: CAMUNDA_CLUSTER_REGION environment variable or provided default 107 | 108 | Raises: 109 | SettingsError: If CAMUNDA_CLUSTER_REGION is not provided. 110 | """ 111 | r = os.getenv("CAMUNDA_CLUSTER_REGION") or default 112 | 113 | if r is None: 114 | raise SettingsError("No CAMUNDA_CLUSTER_REGION provided!") 115 | 116 | return r 117 | 118 | 119 | def get_camunda_token_audience(default: str | None = None) -> str: 120 | """ 121 | Get the Camunda Token Audience. 122 | 123 | Args: 124 | default (str, optional): Default value to be used if no other value was discovered. 125 | 126 | Returns: 127 | str: CAMUNDA_TOKEN_AUDIENCE or ZEEBE_TOKEN_AUDIENCE environment variable or provided default 128 | 129 | Raises: 130 | SettingsError: If neither CAMUNDA_TOKEN_AUDIENCE nor ZEEBE_TOKEN_AUDIENCE is provided. 131 | """ 132 | r = os.getenv("CAMUNDA_TOKEN_AUDIENCE") or os.getenv("ZEEBE_TOKEN_AUDIENCE") or default 133 | 134 | if r is None: 135 | raise SettingsError("No CAMUNDA_TOKEN_AUDIENCE or ZEEBE_TOKEN_AUDIENCE provided!") 136 | 137 | return r 138 | 139 | 140 | def get_camunda_address(cluster_id: str | None = None, cluster_region: str | None = None) -> str: 141 | """ 142 | Get the Camunda Cloud gRPC server address. 143 | 144 | Args: 145 | cluster_id (str, optional): The Camunda cluster ID provided as parameter. 146 | cluster_region (str, optional): The Camunda cluster region provided as parameter. 147 | 148 | Returns: 149 | str: The Camunda Cloud gRPC server address. 150 | 151 | Raises: 152 | SettingsError: If either cluster_id or cluster_region is not provided. 153 | """ 154 | if (cluster_id is None) or (cluster_region is None): 155 | raise SettingsError("The cluster_id and cluster_region must be provided!") 156 | 157 | return f"{cluster_id}.{cluster_region}.zeebe.camunda.io:443" 158 | -------------------------------------------------------------------------------- /pyzeebe/job/job.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import TYPE_CHECKING, Any 5 | 6 | from pyzeebe.job.job_status import JobStatus 7 | from pyzeebe.types import Headers, Variables 8 | 9 | if TYPE_CHECKING: 10 | from pyzeebe.grpc_internals.zeebe_adapter import ZeebeAdapter 11 | 12 | 13 | @dataclass(frozen=True) 14 | class Job: 15 | key: int 16 | type: str 17 | process_instance_key: int 18 | bpmn_process_id: str 19 | process_definition_version: int 20 | process_definition_key: int 21 | element_id: str 22 | element_instance_key: int 23 | custom_headers: Headers 24 | worker: str 25 | retries: int 26 | deadline: int 27 | variables: Variables 28 | tenant_id: str | None = None 29 | status: JobStatus = JobStatus.Running 30 | task_result = None 31 | 32 | def set_task_result(self, task_result: Any) -> None: 33 | object.__setattr__(self, "task_result", task_result) 34 | 35 | def _set_status(self, value: JobStatus) -> None: 36 | object.__setattr__(self, "status", value) 37 | 38 | def __eq__(self, other: object) -> bool: 39 | if not isinstance(other, Job): 40 | return NotImplemented 41 | return self.key == other.key 42 | 43 | 44 | class JobController: 45 | def __init__(self, job: Job, zeebe_adapter: ZeebeAdapter) -> None: 46 | self._job = job 47 | self._zeebe_adapter = zeebe_adapter 48 | 49 | async def set_running_after_decorators_status(self) -> None: 50 | """ 51 | RunningAfterDecorators status means that the task has been completed as intended and the after decorators will now run. 52 | """ 53 | self._job._set_status(JobStatus.RunningAfterDecorators) 54 | 55 | async def set_success_status(self, variables: Variables | None = None) -> None: 56 | """ 57 | Success status means that the job has been completed as intended. 58 | 59 | Raises: 60 | ZeebeBackPressureError: If Zeebe is currently in back pressure (too many requests) 61 | ZeebeGatewayUnavailableError: If the Zeebe gateway is unavailable 62 | ZeebeInternalError: If Zeebe experiences an internal error 63 | 64 | """ 65 | self._job._set_status(JobStatus.Completed) 66 | await self._zeebe_adapter.complete_job(job_key=self._job.key, variables=variables or {}) 67 | 68 | async def set_failure_status( 69 | self, 70 | message: str, 71 | retry_back_off_ms: int = 0, 72 | variables: Variables | None = None, 73 | ) -> None: 74 | """ 75 | Failure status means a technical error has occurred. If retried the job may succeed. 76 | For example: connection to DB lost 77 | 78 | Args: 79 | message (str): The failure message that Zeebe will receive 80 | retry_back_off_ms (int): The backoff timeout (in ms) for the next retry. New in Zeebe 8.1. 81 | variables (dict): A dictionary containing variables that will instantiate the variables at 82 | the local scope of the job's associated task. Must be JSONable. New in Zeebe 8.2. 83 | 84 | Raises: 85 | ZeebeBackPressureError: If Zeebe is currently in back pressure (too many requests) 86 | ZeebeGatewayUnavailableError: If the Zeebe gateway is unavailable 87 | ZeebeInternalError: If Zeebe experiences an internal error 88 | 89 | """ 90 | self._job._set_status(JobStatus.Failed) 91 | await self._zeebe_adapter.fail_job( 92 | job_key=self._job.key, 93 | retries=self._job.retries - 1, 94 | message=message, 95 | retry_back_off_ms=retry_back_off_ms, 96 | variables=variables or {}, 97 | ) 98 | 99 | async def set_error_status( 100 | self, 101 | message: str, 102 | error_code: str = "", 103 | variables: Variables | None = None, 104 | ) -> None: 105 | """ 106 | Error status means that the job could not be completed because of a business error and won't ever be able to be completed. 107 | For example: a required parameter was not given 108 | An error code can be added to handle the error in the Zeebe process 109 | 110 | Args: 111 | message (str): The error message 112 | error_code (str): The error code that Zeebe will receive 113 | variables (dict): A dictionary containing variables that will instantiate the variables at 114 | the local scope of the job's associated task. Must be JSONable. New in Zeebe 8.2. 115 | 116 | Raises: 117 | ZeebeBackPressureError: If Zeebe is currently in back pressure (too many requests) 118 | ZeebeGatewayUnavailableError: If the Zeebe gateway is unavailable 119 | ZeebeInternalError: If Zeebe experiences an internal error 120 | 121 | """ 122 | self._job._set_status(JobStatus.ErrorThrown) 123 | await self._zeebe_adapter.throw_error( 124 | job_key=self._job.key, message=message, error_code=error_code, variables=variables or {} 125 | ) 126 | -------------------------------------------------------------------------------- /docs/worker_tasks.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Tasks 3 | ===== 4 | 5 | Tasks are the building blocks of processes 6 | 7 | Creating a Task 8 | --------------- 9 | 10 | To create a task you must first create a :py:class:`ZeebeWorker` or :py:class:`ZeebeTaskRouter` instance. 11 | 12 | .. code-block:: python 13 | 14 | @worker.task(task_type="my_task") 15 | async def my_task(): 16 | return {} 17 | 18 | This is a task that does nothing. It receives no parameters and also doesn't return any. 19 | 20 | 21 | .. note:: 22 | 23 | While this task indeed returns a python dictionary, it doesn't return anything to Zeebe. To do that we have to fill the dictionary with values. 24 | 25 | 26 | Async/Sync Tasks 27 | ---------------- 28 | 29 | Tasks can be regular or async functions. If given a regular function, pyzeebe will convert it into an async one by running :py:meth:`asyncio.loop.run_in_executor` 30 | 31 | .. note:: 32 | 33 | Make sure not to call any blocking function in an async task. This would slow the entire worker down. 34 | 35 | Do: 36 | 37 | .. code-block:: python 38 | 39 | @worker.task(task_type="my_task") 40 | def my_task(): 41 | time.sleep(10) # Blocking call 42 | return {} 43 | 44 | Don't: 45 | 46 | .. code-block:: python 47 | 48 | @worker.task(task_type="my_task") 49 | async def my_task(): 50 | time.sleep(10) # Blocking call 51 | return {} 52 | 53 | Task Exception Handler 54 | ---------------------- 55 | 56 | An exception handler's signature: 57 | 58 | .. code-block:: python 59 | 60 | Callable[[Exception, Job, JobController], Awaitable[None]] 61 | 62 | In other words: an exception handler is a function that receives an :class:`Exception`, 63 | :py:class:`.Job` instance and :py:class:`.JobController` (a pyzeebe class). 64 | 65 | The exception handler is called when the task has failed. 66 | 67 | To add an exception handler to a task: 68 | 69 | .. code-block:: python 70 | 71 | from pyzeebe import Job, JobController 72 | 73 | 74 | async def my_exception_handler(exception: Exception, job: Job, job_controller: JobController) -> None: 75 | print(exception) 76 | await job_controller.set_failure_status(job, message=str(exception)) 77 | 78 | 79 | @worker.task(task_type="my_task", exception_handler=my_exception_handler) 80 | def my_task(): 81 | raise Exception() 82 | 83 | Now every time ``my_task`` is called (and then fails), ``my_exception_handler`` is called. 84 | 85 | *What does job_controller.set_failure_status do?* 86 | 87 | This tells Zeebe that the job failed. The job will then be retried (if configured in process definition). 88 | 89 | .. note:: 90 | The exception handler can also be set via :py:class:`pyzeebe.ZeebeWorker` or :py:class:`pyzeebe.ZeebeTaskRouter`. 91 | Pyzeebe will try to find the exception handler in the following order: 92 | ``Worker`` -> ``Router`` -> ``Task`` -> :py:func:`pyzeebe.default_exception_handler` 93 | 94 | 95 | Task timeout 96 | ------------ 97 | When creating a task one of the parameters we can specify is ``timeout_ms``. 98 | 99 | .. code-block:: python 100 | 101 | @worker.task(task_type="my_task", timeout_ms=20000) 102 | def my_task(input: str): 103 | return {"output": f"Hello World, {input}!"} 104 | 105 | Here we specify a timeout of 20000 milliseconds (20 seconds). 106 | If the job is not completed within this timeout, Zeebe will reactivate the job and another worker will take over. 107 | 108 | The default value is 10000 milliseconds or 10 seconds. 109 | 110 | **Be sure to test your task's time and adjust the timeout accordingly.** 111 | 112 | Tasks that don't return a dictionary 113 | ------------------------------------ 114 | 115 | Sometimes we want a task to return a singular JSON value (not a dictionary). 116 | To do this we can set the ``single_value`` parameter to ``True``. 117 | 118 | .. code-block:: python 119 | 120 | @worker.task(task_type="my_task", single_value=True, variable_name="y") 121 | def my_task(x: int) -> int: 122 | return x + 1 123 | 124 | This will create a task that receives parameter ``x`` and returns an integer called ``y``. 125 | 126 | So the above task is in fact equal to: 127 | 128 | .. code-block:: python 129 | 130 | @worker.task(task_type="my_task") 131 | def my_task(x: int) -> dict: 132 | return {"y": x + 1} 133 | 134 | 135 | This can be helpful when we don't want to read return values from a dictionary each time we call the task (in tests for example). 136 | 137 | .. note:: 138 | 139 | The parameter ``variable_name`` must be supplied if ``single_value`` is true. If not given a :class:`NoVariableNameGiven` will be raised. 140 | 141 | Accessing the job object directly 142 | --------------------------------- 143 | 144 | It is possible to receive the job object as a parameter inside a task function. Simply annotate the parameter with the :py:class:`pyzeebe.Job` type. 145 | 146 | Example: 147 | 148 | .. code-block:: python 149 | 150 | from pyzeebe import Job 151 | 152 | 153 | @worker.task(task_type="my_task") 154 | async def my_task(job: Job): 155 | print(job.process_instance_key) 156 | return {**job.custom_headers} 157 | -------------------------------------------------------------------------------- /tests/unit/worker/task_router_test.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | from uuid import uuid4 3 | 4 | import pytest 5 | 6 | from pyzeebe import TaskDecorator 7 | from pyzeebe.errors import BusinessError, DuplicateTaskTypeError, TaskNotFoundError 8 | from pyzeebe.job.job import Job, JobController 9 | from pyzeebe.task.exception_handler import ExceptionHandler, default_exception_handler 10 | from pyzeebe.task.task import Task 11 | from pyzeebe.worker.task_router import ZeebeTaskRouter 12 | from tests.unit.utils.random_utils import randint 13 | 14 | 15 | def test_get_task(router: ZeebeTaskRouter, task: Task): 16 | router.tasks.append(task) 17 | 18 | found_task = router.get_task(task.type) 19 | 20 | assert found_task == task 21 | 22 | 23 | def test_task_inherits_exception_handler(router: ZeebeTaskRouter, task: Task): 24 | router._exception_handler = str 25 | router.task(task.type)(task.original_function) 26 | 27 | found_task = router.get_task(task.type) 28 | found_handler = found_task.config.exception_handler 29 | 30 | assert found_handler == str 31 | 32 | 33 | def test_get_fake_task(router: ZeebeTaskRouter): 34 | with pytest.raises(TaskNotFoundError): 35 | router.get_task(str(uuid4())) 36 | 37 | 38 | def test_get_task_index(router: ZeebeTaskRouter, task: Task): 39 | router.tasks.append(task) 40 | 41 | index = router._get_task_index(task.type) 42 | 43 | assert router.tasks[index] == task 44 | 45 | 46 | def test_get_task_and_index(router: ZeebeTaskRouter, task: Task): 47 | router.tasks.append(task) 48 | 49 | found_task, index = router._get_task_and_index(task.type) 50 | 51 | assert router.tasks[index] == task 52 | assert found_task == task 53 | 54 | 55 | def test_remove_task(router: ZeebeTaskRouter, task: Task): 56 | router.tasks.append(task) 57 | 58 | router.remove_task(task.type) 59 | 60 | assert task not in router.tasks 61 | 62 | 63 | def test_remove_task_from_many(router: ZeebeTaskRouter, task: Task): 64 | router.tasks.append(task) 65 | 66 | for _ in range(1, randint(0, 100)): 67 | 68 | @router.task(str(uuid4())) 69 | def dummy_function(): 70 | pass 71 | 72 | router.remove_task(task.type) 73 | 74 | assert task not in router.tasks 75 | 76 | 77 | def test_remove_fake_task(router: ZeebeTaskRouter): 78 | with pytest.raises(TaskNotFoundError): 79 | router.remove_task(str(uuid4())) 80 | 81 | 82 | def test_check_is_task_duplicate_with_duplicate(router: ZeebeTaskRouter, task: Task): 83 | router.tasks.append(task) 84 | with pytest.raises(DuplicateTaskTypeError): 85 | router._is_task_duplicate(task.type) 86 | 87 | 88 | def test_no_duplicate_task_type_error_is_raised(router: ZeebeTaskRouter, task: Task): 89 | router._is_task_duplicate(task.type) 90 | 91 | 92 | def test_add_before_decorator(router: ZeebeTaskRouter, decorator: TaskDecorator): 93 | router.before(decorator) 94 | 95 | assert len(router._before) == 1 96 | 97 | 98 | def test_add_after_decorator(router: ZeebeTaskRouter, decorator: TaskDecorator): 99 | router.after(decorator) 100 | 101 | assert len(router._after) == 1 102 | 103 | 104 | def test_set_exception_handler(router: ZeebeTaskRouter, exception_handler: ExceptionHandler): 105 | router.exception_handler(exception_handler) 106 | 107 | assert router._exception_handler is exception_handler 108 | 109 | 110 | def test_add_before_decorator_through_constructor(decorator: TaskDecorator): 111 | router = ZeebeTaskRouter(before=[decorator]) 112 | 113 | assert len(router._before) == 1 114 | 115 | 116 | def test_add_after_decorator_through_constructor(decorator: TaskDecorator): 117 | router = ZeebeTaskRouter(after=[decorator]) 118 | 119 | assert len(router._after) == 1 120 | 121 | 122 | def test_set_exception_handler_through_constructor(exception_handler: ExceptionHandler): 123 | router = ZeebeTaskRouter(exception_handler=exception_handler) 124 | 125 | assert router._exception_handler is exception_handler 126 | 127 | 128 | @pytest.mark.anyio 129 | async def test_default_exception_handler_logs_a_warning(job: Job, mocked_job_controller: JobController): 130 | with mock.patch("pyzeebe.task.exception_handler.logger.warning") as logging_mock: 131 | await default_exception_handler(Exception(), job, mocked_job_controller) 132 | 133 | mocked_job_controller.set_failure_status.assert_called() 134 | logging_mock.assert_called() 135 | 136 | 137 | @pytest.mark.anyio 138 | async def test_default_exception_handler_uses_business_error(job: Job, mocked_job_controller: JobController): 139 | error_code = "custom-error-code" 140 | exception = BusinessError(error_code) 141 | await default_exception_handler(exception, job, mocked_job_controller) 142 | mocked_job_controller.set_error_status.assert_called_with(mock.ANY, error_code=error_code) 143 | 144 | 145 | @pytest.mark.anyio 146 | async def test_default_exception_handler_warns_of_job_failure(job: Job, mocked_job_controller: JobController): 147 | with mock.patch("pyzeebe.task.exception_handler.logger.warning") as logging_mock: 148 | exception = BusinessError("custom-error-code") 149 | await default_exception_handler(exception, job, mocked_job_controller) 150 | logging_mock.assert_called() 151 | -------------------------------------------------------------------------------- /tests/unit/client/sync_client_test.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock 2 | from uuid import uuid4 3 | 4 | import grpc 5 | import pytest 6 | 7 | from pyzeebe import SyncZeebeClient 8 | from pyzeebe.errors import ProcessDefinitionNotFoundError 9 | from pyzeebe.grpc_internals.types import ( 10 | CancelProcessInstanceResponse, 11 | CreateProcessInstanceResponse, 12 | CreateProcessInstanceWithResultResponse, 13 | ) 14 | 15 | 16 | @pytest.fixture 17 | def sync_zeebe_client(anyio_backend, aio_grpc_channel: grpc.aio.Channel) -> SyncZeebeClient: 18 | # NOTE: anyio_backend: pytest doesn't play well with loop.run_until_complete unless the test has a 19 | # running asyncio loop 20 | client = SyncZeebeClient(aio_grpc_channel) 21 | return client 22 | 23 | 24 | class TestRunProcess: 25 | def test_run_process_returns(self, sync_zeebe_client: SyncZeebeClient, deployed_process): 26 | bpmn_process_id, version = deployed_process 27 | 28 | response = sync_zeebe_client.run_process(bpmn_process_id, version=version) 29 | 30 | assert isinstance(response, CreateProcessInstanceResponse) 31 | 32 | def test_raises_process_definition_not_found_error_for_invalid_process_id(self, sync_zeebe_client: SyncZeebeClient): 33 | with pytest.raises(ProcessDefinitionNotFoundError): 34 | sync_zeebe_client.run_process(bpmn_process_id=str(uuid4())) 35 | 36 | 37 | class TestRunProcessWithResult: 38 | def test_run_process_with_result_returns(self, sync_zeebe_client: SyncZeebeClient, deployed_process): 39 | bpmn_process_id, version = deployed_process 40 | 41 | response = sync_zeebe_client.run_process_with_result(bpmn_process_id, {}, version) 42 | 43 | assert isinstance(response, CreateProcessInstanceWithResultResponse) 44 | 45 | def test_run_process_returns_int(self, sync_zeebe_client: SyncZeebeClient, deployed_process): 46 | bpmn_process_id, version = deployed_process 47 | 48 | response = sync_zeebe_client.run_process(bpmn_process_id, version=version) 49 | 50 | assert isinstance(response.process_instance_key, int) 51 | 52 | def test_run_process_with_result_output_variables_are_as_expected( 53 | self, sync_zeebe_client: SyncZeebeClient, deployed_process 54 | ): 55 | expected = {} 56 | bpmn_process_id, version = deployed_process 57 | 58 | response = sync_zeebe_client.run_process_with_result(bpmn_process_id, {}, version) 59 | 60 | assert response.variables == expected 61 | 62 | def test_raises_process_definition_not_found_error_for_invalid_process_id(self, sync_zeebe_client: SyncZeebeClient): 63 | with pytest.raises(ProcessDefinitionNotFoundError): 64 | sync_zeebe_client.run_process_with_result(bpmn_process_id=str(uuid4())) 65 | 66 | 67 | class TestCancelProcessInstance: 68 | def test_cancel_process_instance(self, sync_zeebe_client: SyncZeebeClient, deployed_process): 69 | bpmn_process_id, version = deployed_process 70 | response = sync_zeebe_client.run_process(bpmn_process_id=bpmn_process_id, variables={}, version=version) 71 | 72 | cancel_response = sync_zeebe_client.cancel_process_instance(response.process_instance_key) 73 | 74 | assert isinstance(cancel_response, CancelProcessInstanceResponse) 75 | 76 | 77 | class TestDeployResource: 78 | def test_calls_deploy_resource_of_zeebe_client(self, sync_zeebe_client: SyncZeebeClient): 79 | sync_zeebe_client.client.deploy_resource = AsyncMock() 80 | file_path = str(uuid4()) 81 | 82 | sync_zeebe_client.deploy_resource(file_path) 83 | 84 | sync_zeebe_client.client.deploy_resource.assert_called_with(file_path, tenant_id=None) 85 | 86 | 87 | class TestEvaluateDecision: 88 | def test_calls_evaluate_decision_of_zeebe_client(self, sync_zeebe_client: SyncZeebeClient): 89 | sync_zeebe_client.client.evaluate_decision = AsyncMock() 90 | decision_id = str(uuid4()) 91 | 92 | sync_zeebe_client.evaluate_decision(decision_key=None, decision_id=decision_id) 93 | 94 | sync_zeebe_client.client.evaluate_decision.assert_called_with(None, decision_id, variables=None, tenant_id=None) 95 | 96 | 97 | class TestBroadcastSignal: 98 | def test_calls_broadcast_signal_of_zeebe_client(self, sync_zeebe_client: SyncZeebeClient): 99 | sync_zeebe_client.client.broadcast_signal = AsyncMock() 100 | name = str(uuid4()) 101 | 102 | sync_zeebe_client.broadcast_signal(name) 103 | 104 | sync_zeebe_client.client.broadcast_signal.assert_called_once() 105 | 106 | 107 | class TestPublishMessage: 108 | def test_calls_publish_message_of_zeebe_client(self, sync_zeebe_client: SyncZeebeClient): 109 | sync_zeebe_client.client.publish_message = AsyncMock() 110 | name = str(uuid4()) 111 | correlation_key = str(uuid4()) 112 | 113 | sync_zeebe_client.publish_message(name, correlation_key) 114 | 115 | sync_zeebe_client.client.publish_message.assert_called_once() 116 | 117 | 118 | class TestTopology: 119 | def test_calls_topology_of_zeebe_client(self, sync_zeebe_client: SyncZeebeClient): 120 | sync_zeebe_client.client.topology = AsyncMock() 121 | 122 | sync_zeebe_client.topology() 123 | 124 | sync_zeebe_client.client.topology.assert_called_once() 125 | 126 | 127 | class TestHealthCheck: 128 | def test_calls_topology_of_zeebe_client(self, sync_zeebe_client: SyncZeebeClient): 129 | sync_zeebe_client.client.healthcheck = AsyncMock() 130 | 131 | sync_zeebe_client.healthcheck() 132 | 133 | sync_zeebe_client.client.healthcheck.assert_called_once() 134 | -------------------------------------------------------------------------------- /tests/unit/grpc_internals/zeebe_adapter_base_test.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock, Mock 2 | 3 | import grpc 4 | import pytest 5 | 6 | from pyzeebe.errors import ( 7 | ZeebeBackPressureError, 8 | ZeebeDeadlineExceeded, 9 | ZeebeGatewayUnavailableError, 10 | ZeebeInternalError, 11 | ) 12 | from pyzeebe.errors.zeebe_errors import UnknownGrpcStatusCodeError 13 | from pyzeebe.grpc_internals.zeebe_adapter_base import ZeebeAdapterBase 14 | 15 | 16 | @pytest.mark.anyio 17 | class TestShouldRetry: 18 | async def test_returns_true_when_no_current_retries(self, zeebe_adapter: ZeebeAdapterBase): 19 | zeebe_adapter._max_connection_retries = 1 20 | assert zeebe_adapter._should_retry() 21 | 22 | async def test_returns_false_when_current_retries_over_max(self, zeebe_adapter: ZeebeAdapterBase): 23 | zeebe_adapter._max_connection_retries = 1 24 | zeebe_adapter._current_connection_retries = 1 25 | assert not zeebe_adapter._should_retry() 26 | 27 | 28 | @pytest.mark.anyio 29 | class TestHandleRpcError: 30 | async def test_raises_internal_error_on_internal_error_status(self, zeebe_adapter: ZeebeAdapterBase): 31 | error = grpc.aio.AioRpcError(grpc.StatusCode.INTERNAL, None, None) 32 | with pytest.raises(ZeebeInternalError) as err: 33 | await zeebe_adapter._handle_grpc_error(error) 34 | 35 | assert ( 36 | str(err.value) 37 | == "ZeebeInternalError(grpc_error=AioRpcError(code=StatusCode.INTERNAL, details=None, debug_error_string=None))" 38 | ) 39 | 40 | async def test_raises_back_pressure_error_on_resource_exhausted(self, zeebe_adapter: ZeebeAdapterBase): 41 | error = grpc.aio.AioRpcError(grpc.StatusCode.RESOURCE_EXHAUSTED, None, None) 42 | with pytest.raises(ZeebeBackPressureError) as err: 43 | await zeebe_adapter._handle_grpc_error(error) 44 | 45 | assert ( 46 | str(err.value) 47 | == "ZeebeBackPressureError(grpc_error=AioRpcError(code=StatusCode.RESOURCE_EXHAUSTED, details=None, debug_error_string=None))" 48 | ) 49 | 50 | async def test_raises_deadline_exceeded_on_deadline_exceeded(self, zeebe_adapter: ZeebeAdapterBase): 51 | error = grpc.aio.AioRpcError(grpc.StatusCode.DEADLINE_EXCEEDED, None, None) 52 | with pytest.raises(ZeebeDeadlineExceeded) as err: 53 | await zeebe_adapter._handle_grpc_error(error) 54 | 55 | assert ( 56 | str(err.value) 57 | == "ZeebeDeadlineExceeded(grpc_error=AioRpcError(code=StatusCode.DEADLINE_EXCEEDED, details=None, debug_error_string=None))" 58 | ) 59 | 60 | async def test_raises_gateway_unavailable_on_unavailable_status( 61 | self, 62 | zeebe_adapter: ZeebeAdapterBase, 63 | ): 64 | error = grpc.aio.AioRpcError(grpc.StatusCode.UNAVAILABLE, None, None) 65 | with pytest.raises(ZeebeGatewayUnavailableError) as err: 66 | await zeebe_adapter._handle_grpc_error(error) 67 | 68 | assert ( 69 | str(err.value) 70 | == "ZeebeGatewayUnavailableError(grpc_error=AioRpcError(code=StatusCode.UNAVAILABLE, details=None, debug_error_string=None))" 71 | ) 72 | 73 | async def test_raises_gateway_unavailable_on_cancelled_status( 74 | self, 75 | zeebe_adapter: ZeebeAdapterBase, 76 | ): 77 | error = grpc.aio.AioRpcError(grpc.StatusCode.CANCELLED, None, None) 78 | 79 | with pytest.raises(ZeebeGatewayUnavailableError) as err: 80 | await zeebe_adapter._handle_grpc_error(error) 81 | 82 | assert ( 83 | str(err.value) 84 | == "ZeebeGatewayUnavailableError(grpc_error=AioRpcError(code=StatusCode.CANCELLED, details=None, debug_error_string=None))" 85 | ) 86 | 87 | async def test_raises_unkown_grpc_status_code_on_unkown_status_code( 88 | self, 89 | zeebe_adapter: ZeebeAdapterBase, 90 | ): 91 | error = grpc.aio.AioRpcError("FakeGrpcStatus", None, None) 92 | with pytest.raises(UnknownGrpcStatusCodeError) as err: 93 | await zeebe_adapter._handle_grpc_error(error) 94 | 95 | assert ( 96 | str(err.value) 97 | == "UnknownGrpcStatusCodeError(grpc_error=AioRpcError(code=FakeGrpcStatus, details=None, debug_error_string=None))" 98 | ) 99 | 100 | async def test_closes_after_retries_exceeded(self, zeebe_adapter: ZeebeAdapterBase): 101 | on_disconnect_callback = Mock() 102 | zeebe_adapter.add_disconnect_callback(on_disconnect_callback) 103 | 104 | error = grpc.aio.AioRpcError(grpc.StatusCode.UNAVAILABLE, None, None) 105 | 106 | zeebe_adapter._channel.close = AsyncMock() 107 | zeebe_adapter._max_connection_retries = 1 108 | with pytest.raises(ZeebeGatewayUnavailableError): 109 | await zeebe_adapter._handle_grpc_error(error) 110 | 111 | assert zeebe_adapter.connected is False 112 | zeebe_adapter._channel.close.assert_awaited_once() 113 | on_disconnect_callback.assert_called_once() 114 | 115 | async def test_closes_after_internal_error(self, zeebe_adapter: ZeebeAdapterBase): 116 | on_disconnect_callback = Mock() 117 | zeebe_adapter.add_disconnect_callback(on_disconnect_callback) 118 | 119 | error = grpc.aio.AioRpcError(grpc.StatusCode.INTERNAL, None, None) 120 | 121 | zeebe_adapter._channel.close = AsyncMock() 122 | zeebe_adapter._max_connection_retries = 1 123 | with pytest.raises(ZeebeInternalError): 124 | await zeebe_adapter._handle_grpc_error(error) 125 | 126 | assert zeebe_adapter.connected is False 127 | zeebe_adapter._channel.close.assert_awaited_once() 128 | on_disconnect_callback.assert_called_once() 129 | -------------------------------------------------------------------------------- /docs/channels_quickstart.rst: -------------------------------------------------------------------------------- 1 | =================== 2 | Channels QuickStart 3 | =================== 4 | 5 | In order to instantiate a ZeebeWorker or ZeebeClient you will need to provide an instance of a `grpc.aio.Channel`. 6 | This Channel can be configured with the parameters `channel_credentials` and `channel_options`. 7 | 8 | .. seealso:: 9 | 10 | `Python Channel Options `_ 11 | Documentation of the available Python `grpc.aio.Channel` `options` (channel_arguments). 12 | 13 | 14 | .. note:: 15 | 16 | By default, channel_options is defined so that the grpc.keepalive_time_ms option is always set to 45_000 (45 seconds). 17 | Reference Camunda Docs `keep alive intervals `_. 18 | 19 | You can override the default `channel_options` by passing 20 | e.g. `channel_options = (("grpc.keepalive_time_ms", 60_000),)` - for a keepalive time of 60 seconds. 21 | 22 | Insecure 23 | -------- 24 | 25 | For creating a grpc channel connected to a Zeebe Gateway with tls disabled, your can use the :py:func:`.create_insecure_channel`. 26 | 27 | Example: 28 | 29 | .. code-block:: python 30 | 31 | from pyzeebe import create_insecure_channel 32 | 33 | channel = create_insecure_channel(grpc_address="localhost:26500") 34 | 35 | 36 | Secure 37 | ------ 38 | 39 | Create a grpc channel with a secure connection to a Zeebe Gateway with tls used the :py:func:`.create_secure_channel`. 40 | 41 | Example: 42 | 43 | .. code-block:: python 44 | 45 | import grpc 46 | from pyzeebe import create_secure_channel 47 | 48 | 49 | credentials = grpc.ssl_channel_credentials(root_certificates="", private_key="") 50 | channel = create_secure_channel(grpc_address="host:port", channel_credentials=credentials) 51 | 52 | 53 | Oauth2 Client Credentials Channel 54 | --------------------------------- 55 | 56 | Create a grpc channel with a secure connection to a Zeebe Gateway with authorization via O2Auth 57 | (Camunda Self-Hosted with Identity, for example) used the :py:func:`.create_oauth2_client_credentials_channel`. 58 | 59 | .. note:: 60 | Some arguments are Optional and are highly dependent on your Authentication Server configuration, 61 | `scope` is usually required and is often optional `audience` . 62 | 63 | Example: 64 | 65 | .. code-block:: python 66 | 67 | import grpc 68 | from pyzeebe import create_oauth2_client_credentials_channel 69 | 70 | channel = create_oauth2_client_credentials_channel( 71 | grpc_address=ZEEBE_ADDRESS, 72 | client_id=ZEEBE_CLIENT_ID, 73 | client_secret=ZEEBE_CLIENT_SECRET, 74 | authorization_server=ZEEBE_AUTHORIZATION_SERVER_URL, 75 | scope="profile email", 76 | audience="zeebe-api", # NOTE: Can be omitted in some cases. 77 | ) 78 | 79 | Example with custom `channel_options`: 80 | 81 | .. code-block:: python 82 | 83 | import grpc 84 | from pyzeebe import create_oauth2_client_credentials_channel 85 | from pyzeebe.types import ChannelArgumentType 86 | 87 | channel_options: ChannelArgumentType = (("grpc.so_reuseport", 0),) 88 | 89 | channel = create_oauth2_client_credentials_channel( 90 | grpc_address=ZEEBE_ADDRESS, 91 | client_id=ZEEBE_CLIENT_ID, 92 | client_secret=ZEEBE_CLIENT_SECRET, 93 | authorization_server=ZEEBE_AUTHORIZATION_SERVER_URL, 94 | scope="profile email", 95 | audience="zeebe-api", 96 | channel_options=channel_options, 97 | ) 98 | 99 | Example with custom `channel_credentials`: 100 | 101 | Useful for self-signed certificates with :py:func:`grpc.ssl_channel_credentials`. 102 | 103 | .. code-block:: python 104 | 105 | import grpc 106 | from pyzeebe import create_oauth2_client_credentials_channel 107 | from pyzeebe.types import ChannelArgumentType 108 | 109 | channel_credentials = grpc.ssl_channel_credentials( 110 | root_certificates="", private_key="" 111 | ) 112 | channel_options: ChannelArgumentType = (("grpc.so_reuseport", 0),) 113 | 114 | channel = create_oauth2_client_credentials_channel( 115 | grpc_address=ZEEBE_ADDRESS, 116 | client_id=ZEEBE_CLIENT_ID, 117 | client_secret=ZEEBE_CLIENT_SECRET, 118 | authorization_server=ZEEBE_AUTHORIZATION_SERVER_URL, 119 | scope="profile email", 120 | audience="zeebe-api", 121 | channel_credentials=channel_credentials, 122 | channel_options=channel_options, 123 | ) 124 | 125 | This method use the :py:class:`.Oauth2ClientCredentialsMetadataPlugin` under the hood. 126 | 127 | Camunda Cloud (Oauth2 Client Credentials Channel) 128 | ------------------------------------------------- 129 | 130 | Create a grpc channel with a secure connection to a Camunda SaaS used the :py:func:`.create_camunda_cloud_channel`. 131 | 132 | .. note:: 133 | This is a convenience function for creating a channel with the correct parameters for Camunda Cloud. 134 | It is equivalent to calling `create_oauth2_client_credentials_channel` with the correct parameters. 135 | 136 | Example: 137 | 138 | .. code-block:: python 139 | 140 | from pyzeebe import create_camunda_cloud_channel 141 | 142 | channel = create_camunda_cloud_channel( 143 | client_id=ZEEBE_CLIENT_ID, 144 | client_secret=ZEEBE_CLIENT_SECRET, 145 | cluster_id=CAMUNDA_CLUSTER_ID, 146 | ) 147 | 148 | This method use the :py:class:`.Oauth2ClientCredentialsMetadataPlugin` under the hood. 149 | 150 | Configuration 151 | ------------- 152 | 153 | It is possible to omit any arguments to the channel initialization functions and instead provide environment variables. 154 | See :doc:`Channels Configuration ` for additional details. 155 | 156 | Custom Oauth2 Authorization Flow 157 | --------------------------------- 158 | 159 | If your need another authorization flow, your can create custom plugin used :py:class:`.OAuth2MetadataPlugin`. 160 | -------------------------------------------------------------------------------- /pyzeebe/worker/job_poller.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import logging 5 | 6 | from pyzeebe.errors import ( 7 | ActivateJobsRequestInvalidError, 8 | StreamActivateJobsRequestInvalidError, 9 | ZeebeBackPressureError, 10 | ZeebeDeadlineExceeded, 11 | ZeebeGatewayUnavailableError, 12 | ZeebeInternalError, 13 | ) 14 | from pyzeebe.grpc_internals.zeebe_job_adapter import ZeebeJobAdapter 15 | from pyzeebe.job.job import Job 16 | from pyzeebe.task.task import Task 17 | from pyzeebe.worker.task_state import TaskState 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | class JobPoller: 23 | def __init__( 24 | self, 25 | zeebe_adapter: ZeebeJobAdapter, 26 | task: Task, 27 | queue: asyncio.Queue[Job], 28 | worker_name: str, 29 | request_timeout: int, 30 | task_state: TaskState, 31 | poll_retry_delay: int, 32 | tenant_ids: list[str] | None, 33 | ) -> None: 34 | self.zeebe_adapter = zeebe_adapter 35 | self.task = task 36 | self.queue = queue 37 | self.worker_name = worker_name 38 | self.request_timeout = request_timeout 39 | self.task_state = task_state 40 | self.poll_retry_delay = poll_retry_delay 41 | self.tenant_ids = tenant_ids 42 | self.stop_event = asyncio.Event() 43 | 44 | async def poll(self) -> None: 45 | while self.should_poll(): 46 | await self.activate_max_jobs() 47 | 48 | async def activate_max_jobs(self) -> None: 49 | if self.calculate_max_jobs_to_activate() > 0: 50 | await self.poll_once() 51 | else: 52 | logger.warning( 53 | "Maximum number of jobs running for %s. Polling again in %s seconds...", 54 | self.task.type, 55 | self.poll_retry_delay, 56 | ) 57 | await asyncio.sleep(self.poll_retry_delay) 58 | 59 | async def poll_once(self) -> None: 60 | try: 61 | jobs = self.zeebe_adapter.activate_jobs( 62 | task_type=self.task.type, 63 | worker=self.worker_name, 64 | timeout=self.task.config.timeout_ms, 65 | max_jobs_to_activate=self.calculate_max_jobs_to_activate(), 66 | variables_to_fetch=self.task.config.variables_to_fetch or [], 67 | request_timeout=self.request_timeout, 68 | tenant_ids=self.tenant_ids, 69 | ) 70 | async for job in jobs: 71 | self.task_state.add(job) 72 | await self.queue.put(job) 73 | except ActivateJobsRequestInvalidError: 74 | logger.warning("Activate job requests was invalid for task %s", self.task.type) 75 | raise 76 | except ( 77 | ZeebeBackPressureError, 78 | ZeebeGatewayUnavailableError, 79 | ZeebeInternalError, 80 | ZeebeDeadlineExceeded, 81 | ) as error: 82 | logger.warning( 83 | "Failed to activate jobs from the gateway. Exception: %s. Retrying in 5 seconds...", 84 | repr(error), 85 | ) 86 | await asyncio.sleep(5) 87 | 88 | def should_poll(self) -> bool: 89 | return not self.stop_event.is_set() and (self.zeebe_adapter.connected or self.zeebe_adapter.retrying_connection) 90 | 91 | def calculate_max_jobs_to_activate(self) -> int: 92 | worker_max_jobs = self.task.config.max_running_jobs - self.task_state.count_active() 93 | return min(worker_max_jobs, self.task.config.max_jobs_to_activate) 94 | 95 | async def stop(self) -> None: 96 | self.stop_event.set() 97 | await self.queue.join() 98 | 99 | 100 | class JobStreamer: 101 | def __init__( 102 | self, 103 | zeebe_adapter: ZeebeJobAdapter, 104 | task: Task, 105 | queue: asyncio.Queue[Job], 106 | worker_name: str, 107 | stream_request_timeout: int, 108 | task_state: TaskState, 109 | tenant_ids: list[str] | None, 110 | ) -> None: 111 | self.zeebe_adapter = zeebe_adapter 112 | self.task = task 113 | self.queue = queue 114 | self.worker_name = worker_name 115 | self.stream_request_timeout = stream_request_timeout 116 | self.task_state = task_state 117 | self.tenant_ids = tenant_ids 118 | self.stop_event = asyncio.Event() 119 | 120 | async def poll(self) -> None: 121 | while self.should_poll(): 122 | await self.activate_stream() 123 | 124 | async def activate_stream(self) -> None: 125 | try: 126 | jobs = self.zeebe_adapter.stream_activate_jobs( 127 | task_type=self.task.type, 128 | worker=self.worker_name, 129 | timeout=self.task.config.timeout_ms, 130 | variables_to_fetch=self.task.config.variables_to_fetch or [], 131 | stream_request_timeout=self.stream_request_timeout, 132 | tenant_ids=self.tenant_ids, 133 | ) 134 | async for job in jobs: 135 | self.task_state.add(job) 136 | await self.queue.put(job) 137 | except StreamActivateJobsRequestInvalidError: 138 | logger.warning("Stream job requests was invalid for task %s", self.task.type) 139 | raise 140 | except ( 141 | ZeebeBackPressureError, 142 | ZeebeGatewayUnavailableError, 143 | ZeebeInternalError, 144 | ZeebeDeadlineExceeded, 145 | ) as error: 146 | logger.warning( 147 | "Failed to strean jobs from the gateway. Exception: %s. Retrying in 5 seconds...", 148 | repr(error), 149 | ) 150 | await asyncio.sleep(5) 151 | 152 | def should_poll(self) -> bool: 153 | return not self.stop_event.is_set() and (self.zeebe_adapter.connected or self.zeebe_adapter.retrying_connection) 154 | 155 | async def stop(self) -> None: 156 | self.stop_event.set() 157 | await self.queue.join() 158 | --------------------------------------------------------------------------------