diff --git a/docs/modules/temporal.md b/docs/modules/temporal.md new file mode 100644 index 000000000..ac960b86e --- /dev/null +++ b/docs/modules/temporal.md @@ -0,0 +1,29 @@ +# Temporal + +## Introduction + +The Testcontainers module for [Temporal](https://temporal.io/) — a durable execution platform for running reliable, long-running workflows. + +This module spins up the Temporal dev server (`temporalio/temporal`) which includes the Temporal server, a preconfigured `default` namespace, and the Web UI. + +## Adding this module to your project dependencies + +Please run the following command to add the Temporal module to your python dependencies: + +```bash +pip install testcontainers[temporal] +``` + +To interact with the server you will also need a Temporal SDK, for example: + +```bash +pip install temporalio +``` + +## Usage example + + + +[Creating a Temporal container](../../modules/temporal/example_basic.py) + + diff --git a/mkdocs.yml b/mkdocs.yml index aca8281b7..e8f7a640b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -93,6 +93,7 @@ nav: - modules/registry.md - modules/selenium.md - modules/sftp.md + - modules/temporal.md - modules/test_module_import.md - modules/vault.md - System Requirements: diff --git a/modules/temporal/README.rst b/modules/temporal/README.rst new file mode 100644 index 000000000..f9ac1eb3f --- /dev/null +++ b/modules/temporal/README.rst @@ -0,0 +1,2 @@ +.. autoclass:: testcontainers.temporal.TemporalContainer +.. title:: testcontainers.temporal.TemporalContainer diff --git a/modules/temporal/example_basic.py b/modules/temporal/example_basic.py new file mode 100644 index 000000000..86258a29b --- /dev/null +++ b/modules/temporal/example_basic.py @@ -0,0 +1,40 @@ +import asyncio +from datetime import timedelta + +from temporalio.api.workflowservice.v1 import ListNamespacesRequest +from temporalio.client import Client + +from testcontainers.temporal import TemporalContainer + + +async def main(): + with TemporalContainer() as temporal: + print(f"Temporal gRPC address: {temporal.get_grpc_address()}") + print(f"Temporal Web UI: {temporal.get_web_ui_url()}") + + # Connect a Temporal client + client = await Client.connect(temporal.get_grpc_address()) + + # List available namespaces + resp = await client.service_client.workflow_service.list_namespaces(ListNamespacesRequest()) + for ns in resp.namespaces: + print(f"Namespace: {ns.namespace_info.name}") + + # Start a workflow (untyped — no workflow definition class needed) + handle = await client.start_workflow( + "GreetingWorkflow", + id="greeting-wf-1", + task_queue="greeting-queue", + execution_timeout=timedelta(seconds=10), + memo={"env": "example"}, + ) + print(f"Started workflow: {handle.id}") + + # Describe the workflow + desc = await handle.describe() + print(f"Workflow type: {desc.workflow_type}") + print(f"Task queue: {desc.task_queue}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/modules/temporal/testcontainers/temporal/__init__.py b/modules/temporal/testcontainers/temporal/__init__.py new file mode 100644 index 000000000..3e8be903e --- /dev/null +++ b/modules/temporal/testcontainers/temporal/__init__.py @@ -0,0 +1,54 @@ +import urllib.error +import urllib.parse +import urllib.request + +from testcontainers.core.container import DockerContainer +from testcontainers.core.waiting_utils import wait_container_is_ready + + +class TemporalContainer(DockerContainer): + """Temporal dev server container for integration testing. + + Example: + + The example spins up a Temporal dev server and connects to it using the + ``temporalio`` Python SDK. + + .. doctest:: + + >>> from testcontainers.temporal import TemporalContainer + >>> with TemporalContainer() as temporal: + ... address = temporal.get_grpc_address() + """ + + GRPC_PORT = 7233 + HTTP_PORT = 8233 + + def __init__(self, image: str = "temporalio/temporal:1.5.1", **kwargs) -> None: + super().__init__(image, **kwargs) + self.with_exposed_ports(self.GRPC_PORT, self.HTTP_PORT) + self.with_command("server start-dev --ip 0.0.0.0") + + @wait_container_is_ready(urllib.error.URLError, ConnectionError) + def _healthcheck(self) -> None: + host = self.get_container_host_ip() + port = self.get_exposed_port(self.HTTP_PORT) + url = urllib.parse.urlunsplit(("http", f"{host}:{port}", "/api/v1/namespaces", "", "")) + urllib.request.urlopen(url, timeout=1) + + def start(self) -> "TemporalContainer": + super().start() + self._healthcheck() + return self + + def get_grpc_address(self) -> str: + """Returns ``host:port`` for the Temporal gRPC frontend. + + The address intentionally omits a scheme because the Temporal SDKs + expect a plain ``host:port`` string. + """ + return f"{self.get_container_host_ip()}:{self.get_exposed_port(self.GRPC_PORT)}" + + def get_web_ui_url(self) -> str: + """Returns the base URL for the Temporal Web UI / HTTP API.""" + return f"http://{self.get_container_host_ip()}:{self.get_exposed_port(self.HTTP_PORT)}" diff --git a/modules/temporal/tests/test_temporal.py b/modules/temporal/tests/test_temporal.py new file mode 100644 index 000000000..067439b4f --- /dev/null +++ b/modules/temporal/tests/test_temporal.py @@ -0,0 +1,42 @@ +from datetime import timedelta +from uuid import uuid4 + +import pytest +from temporalio.api.workflowservice.v1 import ListNamespacesRequest +from temporalio.client import Client + +from testcontainers.temporal import TemporalContainer + + +@pytest.fixture(scope="module") +def temporal_container(): + with TemporalContainer() as container: + yield container + + +@pytest.mark.asyncio +async def test_default_namespace_exists(temporal_container): + client = await Client.connect(temporal_container.get_grpc_address()) + resp = await client.service_client.workflow_service.list_namespaces(ListNamespacesRequest()) + names = [ns.namespace_info.name for ns in resp.namespaces] + assert "default" in names + + +@pytest.mark.asyncio +async def test_start_and_describe_workflow(temporal_container): + client = await Client.connect(temporal_container.get_grpc_address()) + workflow_id = str(uuid4()) + + handle = await client.start_workflow( + "MyWorkflow", + id=workflow_id, + task_queue="my-task-queue", + execution_timeout=timedelta(seconds=10), + memo={"env": "test"}, + ) + desc = await handle.describe() + assert desc.id == workflow_id + assert desc.workflow_type == "MyWorkflow" + assert desc.task_queue == "my-task-queue" + memo = await desc.memo() + assert memo is not None diff --git a/pyproject.toml b/pyproject.toml index f983a2e3e..e703f8111 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,6 +100,7 @@ registry = ["bcrypt>=5"] selenium = ["selenium>=4"] scylla = ["cassandra-driver>=3"] sftp = ["cryptography"] +temporal = [] vault = [] weaviate = ["weaviate-client>=4"] chroma = ["chromadb-client>=1"] @@ -215,6 +216,7 @@ packages = [ "modules/registry/testcontainers", "modules/sftp/testcontainers", "modules/selenium/testcontainers", + "modules/temporal/testcontainers", "modules/scylla/testcontainers", "modules/trino/testcontainers", "modules/vault/testcontainers", @@ -264,6 +266,7 @@ dev-mode-dirs = [ "modules/registry", "modules/sftp", "modules/selenium", + "modules/temporal", "modules/scylla", "modules/trino", "modules/vault", diff --git a/uv.lock b/uv.lock index 5b0f9e1cc..f9d6be9a1 100644 --- a/uv.lock +++ b/uv.lock @@ -5142,7 +5142,7 @@ requires-dist = [ { name = "weaviate-client", marker = "extra == 'weaviate'", specifier = ">=4" }, { name = "wrapt" }, ] -provides-extras = ["arangodb", "aws", "azurite", "cassandra", "clickhouse", "cosmosdb", "cockroachdb", "db2", "elasticsearch", "generic", "test-module-import", "google", "influxdb", "k3s", "kafka", "keycloak", "localstack", "mailpit", "memcached", "minio", "milvus", "mongodb", "mqtt", "mssql", "mysql", "nats", "neo4j", "nginx", "openfga", "opensearch", "ollama", "oracle", "oracle-free", "postgres", "qdrant", "rabbitmq", "redis", "registry", "selenium", "scylla", "sftp", "vault", "weaviate", "chroma", "trino"] +provides-extras = ["arangodb", "aws", "azurite", "cassandra", "clickhouse", "cosmosdb", "cockroachdb", "db2", "elasticsearch", "generic", "test-module-import", "google", "influxdb", "k3s", "kafka", "keycloak", "localstack", "mailpit", "memcached", "minio", "milvus", "mongodb", "mqtt", "mssql", "mysql", "nats", "neo4j", "nginx", "openfga", "opensearch", "ollama", "oracle", "oracle-free", "postgres", "qdrant", "rabbitmq", "redis", "registry", "selenium", "scylla", "sftp", "temporal", "vault", "weaviate", "chroma", "trino"] [package.metadata.requires-dev] dev = [