diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..593492f --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,86 @@ +# Copilot Instructions for durabletask-python + +## Project Overview + +This is the Durable Task Python SDK, providing a client and worker for +building durable orchestrations. The repo contains two packages: + +- `durabletask` — core SDK (in `durabletask/`) +- `durabletask.azuremanaged` — Azure Durable Task Scheduler provider (in `durabletask-azuremanaged/`) + +## Language and Style + +- Python 3.10+ is required. +- Use type hints for all public API signatures. +- Follow PEP 8 conventions. +- Use `autopep8` for Python formatting. + +## Markdown Style + +Use GitHub-style callouts for notes, warnings, and tips in Markdown files: + +```markdown +> [!NOTE] +> This is a note. + +> [!WARNING] +> This is a warning. + +> [!TIP] +> This is a tip. +``` + +Do **not** use bold-text callouts like `**NOTE:**` or `> **Note:**`. + +When providing shell commands in Markdown, include both Bash and +PowerShell examples if the syntax differs between them. Common cases +include multiline commands (Bash uses `\` for line continuation while +PowerShell uses a backtick `` ` ``), environment variable syntax, and +path separators. If a command is identical in both shells, a single +example is sufficient. + +## Markdown Linting + +This repository uses [pymarkdownlnt](https://pypi.org/project/pymarkdownlnt/) +for linting Markdown files. Configuration is in `.pymarkdown.json` at the +repository root. + +To lint a single file: + +```bash +pymarkdown -c .pymarkdown.json scan path/to/file.md +``` + +To lint all Markdown files in the repository: + +```bash +pymarkdown -c .pymarkdown.json scan **/*.md +``` + +Install the linter via the dev dependencies: + +```bash +pip install -r dev-requirements.txt +``` + +## Building and Testing + +Install the packages locally in editable mode: + +```bash +pip install -e . -e ./durabletask-azuremanaged +``` + +Run tests with pytest: + +```bash +pytest +``` + +## Project Structure + +- `durabletask/` — core SDK source +- `durabletask-azuremanaged/` — Azure managed provider source +- `examples/` — example orchestrations (see `examples/README.md`) +- `tests/` — test suite +- `dev-requirements.txt` — development dependencies diff --git a/.pymarkdown.json b/.pymarkdown.json new file mode 100644 index 0000000..69ca1e2 --- /dev/null +++ b/.pymarkdown.json @@ -0,0 +1,10 @@ +{ + "plugins": { + "md013": { + "line_length": 100 + }, + "md014": { + "enabled": false + } + } +} \ No newline at end of file diff --git a/dev-requirements.txt b/dev-requirements.txt index b3ff6f7..98f4c30 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1 +1,2 @@ grpcio-tools +pymarkdownlnt diff --git a/durabletask/worker.py b/durabletask/worker.py index 9950677..726eabf 100644 --- a/durabletask/worker.py +++ b/durabletask/worker.py @@ -1913,6 +1913,7 @@ class _EntityExecutor: def __init__(self, registry: _Registry, logger: logging.Logger): self._registry = registry self._logger = logger + self._entity_method_cache: dict[tuple[type, str], bool] = {} def execute( self, @@ -1948,7 +1949,20 @@ def execute( raise TypeError(f"Entity operation '{operation}' is not callable") # Execute the entity method entity_instance._initialize_entity_context(ctx) - entity_output = method(entity_input) + cache_key = (type(entity_instance), operation) + has_required_param = self._entity_method_cache.get(cache_key) + if has_required_param is None: + sig = inspect.signature(method) + has_required_param = any( + p.default == inspect.Parameter.empty + and p.kind not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD) + for p in sig.parameters.values() + ) + self._entity_method_cache[cache_key] = has_required_param + if has_required_param or entity_input is not None: + entity_output = method(entity_input) + else: + entity_output = method() else: # Execute the entity function entity_output = fn(ctx, entity_input) diff --git a/examples/README.md b/examples/README.md index 0912a60..59fa7fd 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,86 +1,176 @@ # Examples -This directory contains examples of how to author durable orchestrations using the Durable Task Python SDK in conjunction with the Durable Task Scheduler (DTS). +This directory contains examples of how to author durable orchestrations +using the Durable Task Python SDK in conjunction with the +Durable Task Scheduler (DTS). ## Prerequisites + If using a deployed Durable Task Scheduler: - - [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli) - - [`az durabletask` CLI extension](https://learn.microsoft.com/en-us/cli/azure/durabletask?view=azure-cli-latest) + +- [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli) +- [`az durabletask` CLI extension](https://learn.microsoft.com/cli/azure/durabletask?view=azure-cli-latest) ## Running the Examples + There are two separate ways to run an example: - Using the Emulator (recommended for learning and development) -- Using a deployed Scheduler and Taskhub in Azure +- Using a deployed Scheduler and Taskhub in Azure ### Running with the Emulator -We recommend using the emulator for learning and development as it's faster to set up and doesn't require any Azure resources. The emulator simulates a scheduler and taskhub, packaged into an easy-to-use Docker container. + +We recommend using the emulator for learning and development as it's +faster to set up and doesn't require any Azure resources. The emulator +simulates a scheduler and taskhub, packaged into an easy-to-use +Docker container. 1. Install Docker: If it is not already installed. -2. Pull the Docker Image for the Emulator: -```bash -docker pull mcr.microsoft.com/dts/dts-emulator:v0.0.6 -``` +1. Pull the Docker Image for the Emulator: -3. Run the Emulator: Wait a few seconds for the container to be ready. -```bash -docker run --name dtsemulator -d -p 8080:8080 mcr.microsoft.com/dts/dts-emulator:v0.0.6 -``` + ```bash + docker pull mcr.microsoft.com/dts/dts-emulator:latest + ``` -4. Install the Required Packages: -```bash -pip install -r requirements.txt -``` +1. Run the Emulator: Wait a few seconds for the container to be ready. + + ```bash + docker run --name dtsemulator -d -p 8080:8080 mcr.microsoft.com/dts/dts-emulator:latest + ``` + +1. Create a Python virtual environment (recommended): + + ```bash + python -m venv .venv + ``` -Note: The example code has been updated to use the default emulator settings automatically (endpoint: http://localhost:8080, taskhub: default). You don't need to set any environment variables. + Activate the virtual environment: + + Bash: + + ```bash + source .venv/bin/activate + ``` + + PowerShell: + + ```powershell + .\.venv\Scripts\Activate.ps1 + ``` + +1. Install the Required Packages: + + ```bash + pip install -r requirements.txt + ``` + + If you are running from a local clone of the repository, install the + local packages in editable mode instead (run this from the repository + root, not the `examples/` directory): + + ```bash + pip install -e . -e ./durabletask-azuremanaged + ``` + +> [!NOTE] +> The example code uses the default emulator settings +> automatically (endpoint: `http://localhost:8080`, taskhub: `default`). +> You don't need to set any environment variables. ### Running with a Deployed Scheduler and Taskhub Resource in Azure -For production scenarios or when you're ready to deploy to Azure, you can create a taskhub using the Azure CLI: + +For production scenarios or when you're ready to deploy to Azure, you +can create a taskhub using the Azure CLI: 1. Create a Scheduler: -```bash -az durabletask scheduler create --resource-group --name --location --ip-allowlist "[0.0.0.0/0]" --sku-capacity 1 --sku-name "Dedicated" --tags "{'myattribute':'myvalue'}" -``` -2. Create Your Taskhub: -```bash -az durabletask taskhub create --resource-group --scheduler-name --name -``` + Bash: -3. Retrieve the Endpoint for the Scheduler: Locate the taskhub in the Azure portal to find the endpoint. + ```bash + az durabletask scheduler create \ + --resource-group \ + --name \ + --location \ + --ip-allowlist "[0.0.0.0/0]" \ + --sku-capacity 1 \ + --sku-name "Dedicated" \ + --tags "{'myattribute':'myvalue'}" + ``` -4. Set the Environment Variables: -Bash: -```bash -export TASKHUB= -export ENDPOINT= -``` -Powershell: -```powershell -$env:TASKHUB = "" -$env:ENDPOINT = "" -``` + PowerShell: -5. Install the Required Packages: -```bash -pip install -r requirements.txt -``` + ```powershell + az durabletask scheduler create ` + --resource-group ` + --name ` + --location ` + --ip-allowlist "[0.0.0.0/0]" ` + --sku-capacity 1 ` + --sku-name "Dedicated" ` + --tags "{'myattribute':'myvalue'}" + ``` + +1. Create Your Taskhub: + + Bash: + + ```bash + az durabletask taskhub create \ + --resource-group \ + --scheduler-name \ + --name + ``` + + PowerShell: + + ```powershell + az durabletask taskhub create ` + --resource-group ` + --scheduler-name ` + --name + ``` + +1. Retrieve the Endpoint for the Scheduler: Locate the taskhub in the + Azure portal to find the endpoint. + +1. Set the Environment Variables: + + Bash: + + ```bash + export TASKHUB= + export ENDPOINT= + ``` + + PowerShell: + + ```powershell + $env:TASKHUB = "" + $env:ENDPOINT = "" + ``` + +1. Install the Required Packages: + + ```bash + pip install -r requirements.txt + ``` + +### Executing the Examples -### Running the Examples You can now execute any of the examples in this directory using Python: ```bash -python3 example_file.py +python activity_sequence.py ``` -### Review Orchestration History and Status in the Durable Task Scheduler Dashboard -To access the Durable Task Scheduler Dashboard, follow these steps: +### Review Orchestration History and Status -- **Using the Emulator**: By default, the dashboard runs on portal 8082. Navigate to http://localhost:8082 and click on the default task hub. +To access the Durable Task Scheduler Dashboard, follow these steps: -- **Using a Deployed Scheduler**: Navigate to the Scheduler resource. Then, go to the Task Hub subresource that you are using and click on the dashboard URL in the top right corner. +- **Using the Emulator**: By default, the dashboard runs on port 8082. + Navigate to and click on the default task hub. -```sh -python3 activity_sequence.py -``` +- **Using a Deployed Scheduler**: Navigate to the Scheduler resource. + Then, go to the Task Hub subresource that you are using and click on + the dashboard URL in the top right corner. diff --git a/examples/activity_sequence.py b/examples/activity_sequence.py index 38c013d..420935d 100644 --- a/examples/activity_sequence.py +++ b/examples/activity_sequence.py @@ -33,10 +33,8 @@ def sequence(ctx: task.OrchestrationContext, _): print(f"Using endpoint: {endpoint}") # Set credential to None for emulator, or DefaultAzureCredential for Azure -credential = None if endpoint == "http://localhost:8080" else DefaultAzureCredential() - -# configure and start the worker - use secure_channel=False for emulator -secure_channel = endpoint != "http://localhost:8080" +secure_channel = endpoint.startswith("https://") +credential = DefaultAzureCredential() if secure_channel else None with DurableTaskSchedulerWorker(host_address=endpoint, secure_channel=secure_channel, taskhub=taskhub_name, token_credential=credential) as w: w.add_orchestrator(sequence) diff --git a/examples/entities/class_based_entity.py b/examples/entities/class_based_entity.py index f211b65..e1b581d 100644 --- a/examples/entities/class_based_entity.py +++ b/examples/entities/class_based_entity.py @@ -15,7 +15,7 @@ def set(self, input: int): def add(self, input: int): current_state = self.get_state(int, 0) - new_state = current_state + (input or 1) + new_state = current_state + (1 if input is None else input) self.set_state(new_state) return new_state @@ -44,10 +44,8 @@ def counter_orchestrator(ctx: task.OrchestrationContext, _): print(f"Using endpoint: {endpoint}") # Set credential to None for emulator, or DefaultAzureCredential for Azure -credential = None if endpoint == "http://localhost:8080" else DefaultAzureCredential() - -# configure and start the worker - use secure_channel=False for emulator -secure_channel = endpoint != "http://localhost:8080" +secure_channel = endpoint.startswith("https://") +credential = DefaultAzureCredential() if secure_channel else None with DurableTaskSchedulerWorker(host_address=endpoint, secure_channel=secure_channel, taskhub=taskhub_name, token_credential=credential) as w: w.add_orchestrator(counter_orchestrator) diff --git a/examples/entities/class_based_entity_actions.py b/examples/entities/class_based_entity_actions.py index 8a38218..5240bb7 100644 --- a/examples/entities/class_based_entity_actions.py +++ b/examples/entities/class_based_entity_actions.py @@ -15,7 +15,7 @@ def set(self, input: int): def add(self, input: int): current_state = self.get_state(int, 0) - new_state = current_state + (input or 1) + new_state = current_state + (1 if input is None else input) self.set_state(new_state) return new_state @@ -63,10 +63,8 @@ def hello_orchestrator(ctx: task.OrchestrationContext, _): print(f"Using endpoint: {endpoint}") # Set credential to None for emulator, or DefaultAzureCredential for Azure -credential = None if endpoint == "http://localhost:8080" else DefaultAzureCredential() - -# configure and start the worker - use secure_channel=False for emulator -secure_channel = endpoint != "http://localhost:8080" +secure_channel = endpoint.startswith("https://") +credential = DefaultAzureCredential() if secure_channel else None with DurableTaskSchedulerWorker(host_address=endpoint, secure_channel=secure_channel, taskhub=taskhub_name, token_credential=credential) as w: w.add_orchestrator(counter_orchestrator) diff --git a/examples/entities/entity_locking.py b/examples/entities/entity_locking.py index cdc25ab..c6c1bda 100644 --- a/examples/entities/entity_locking.py +++ b/examples/entities/entity_locking.py @@ -15,7 +15,7 @@ def set(self, input: int): def add(self, input: int): current_state = self.get_state(int, 0) - new_state = current_state + (input or 1) + new_state = current_state + (1 if input is None else input) self.set_state(new_state) return new_state @@ -46,10 +46,8 @@ def counter_orchestrator(ctx: task.OrchestrationContext, _): print(f"Using endpoint: {endpoint}") # Set credential to None for emulator, or DefaultAzureCredential for Azure -credential = None if endpoint == "http://localhost:8080" else DefaultAzureCredential() - -# configure and start the worker - use secure_channel=False for emulator -secure_channel = endpoint != "http://localhost:8080" +secure_channel = endpoint.startswith("https://") +credential = DefaultAzureCredential() if secure_channel else None with DurableTaskSchedulerWorker(host_address=endpoint, secure_channel=secure_channel, taskhub=taskhub_name, token_credential=credential) as w: w.add_orchestrator(counter_orchestrator) diff --git a/examples/entities/function_based_entity.py b/examples/entities/function_based_entity.py index a43b86d..85bcded 100644 --- a/examples/entities/function_based_entity.py +++ b/examples/entities/function_based_entity.py @@ -13,9 +13,9 @@ def counter(ctx: entities.EntityContext, input: int) -> Optional[int]: if ctx.operation == "set": ctx.set_state(input) - if ctx.operation == "add": + elif ctx.operation == "add": current_state = ctx.get_state(int, 0) - new_state = current_state + (input or 1) + new_state = current_state + (1 if input is None else input) ctx.set_state(new_state) return new_state elif ctx.operation == "get": @@ -45,10 +45,8 @@ def counter_orchestrator(ctx: task.OrchestrationContext, _): print(f"Using endpoint: {endpoint}") # Set credential to None for emulator, or DefaultAzureCredential for Azure -credential = None if endpoint == "http://localhost:8080" else DefaultAzureCredential() - -# configure and start the worker - use secure_channel=False for emulator -secure_channel = endpoint != "http://localhost:8080" +secure_channel = endpoint.startswith("https://") +credential = DefaultAzureCredential() if secure_channel else None with DurableTaskSchedulerWorker(host_address=endpoint, secure_channel=secure_channel, taskhub=taskhub_name, token_credential=credential) as w: w.add_orchestrator(counter_orchestrator) diff --git a/examples/entities/function_based_entity_actions.py b/examples/entities/function_based_entity_actions.py index 129eb6c..28eb29a 100644 --- a/examples/entities/function_based_entity_actions.py +++ b/examples/entities/function_based_entity_actions.py @@ -13,6 +13,11 @@ def counter(ctx: entities.EntityContext, input: int) -> Optional[int]: if ctx.operation == "set": ctx.set_state(input) + elif ctx.operation == "add": + current_state = ctx.get_state(int, 0) + new_state = current_state + (1 if input is None else input) + ctx.set_state(new_state) + return new_state elif ctx.operation == "get": return ctx.get_state(int, 0) elif ctx.operation == "update_parent": @@ -57,10 +62,8 @@ def hello_orchestrator(ctx: task.OrchestrationContext, _): print(f"Using endpoint: {endpoint}") # Set credential to None for emulator, or DefaultAzureCredential for Azure -credential = None if endpoint == "http://localhost:8080" else DefaultAzureCredential() - -# configure and start the worker - use secure_channel=False for emulator -secure_channel = endpoint != "http://localhost:8080" +secure_channel = endpoint.startswith("https://") +credential = DefaultAzureCredential() if secure_channel else None with DurableTaskSchedulerWorker(host_address=endpoint, secure_channel=secure_channel, taskhub=taskhub_name, token_credential=credential) as w: w.add_orchestrator(counter_orchestrator) diff --git a/examples/fanout_fanin.py b/examples/fanout_fanin.py index a606731..0975d92 100644 --- a/examples/fanout_fanin.py +++ b/examples/fanout_fanin.py @@ -58,10 +58,8 @@ def orchestrator(ctx: task.OrchestrationContext, _): print(f"Using endpoint: {endpoint}") # Set credential to None for emulator, or DefaultAzureCredential for Azure -credential = None if endpoint == "http://localhost:8080" else DefaultAzureCredential() - -# configure and start the worker - use secure_channel=False for emulator -secure_channel = endpoint != "http://localhost:8080" +secure_channel = endpoint.startswith("https://") +credential = DefaultAzureCredential() if secure_channel else None with DurableTaskSchedulerWorker(host_address=endpoint, secure_channel=secure_channel, taskhub=taskhub_name, token_credential=credential) as w: w.add_orchestrator(orchestrator) diff --git a/examples/human_interaction.py b/examples/human_interaction.py index ae93cd2..9d60758 100644 --- a/examples/human_interaction.py +++ b/examples/human_interaction.py @@ -114,10 +114,8 @@ def prompt_for_approval(): print(f"Using endpoint: {endpoint}") # Set credential to None for emulator, or DefaultAzureCredential for Azure - credential = None if endpoint == "http://localhost:8080" else DefaultAzureCredential() - - # Configure and start the worker - use secure_channel=False for emulator - secure_channel = endpoint != "http://localhost:8080" + secure_channel = endpoint.startswith("https://") + credential = DefaultAzureCredential() if secure_channel else None with DurableTaskSchedulerWorker(host_address=endpoint, secure_channel=secure_channel, taskhub=taskhub_name, token_credential=credential) as w: w.add_orchestrator(purchase_order_workflow) diff --git a/examples/sub-orchestrations-with-fan-out-fan-in/orchestrator.py b/examples/sub-orchestrations-with-fan-out-fan-in/orchestrator.py index a5e013b..eef2edc 100644 --- a/examples/sub-orchestrations-with-fan-out-fan-in/orchestrator.py +++ b/examples/sub-orchestrations-with-fan-out-fan-in/orchestrator.py @@ -11,10 +11,11 @@ print(f"Using endpoint: {endpoint}") # Set credential to None for emulator, or DefaultAzureCredential for Azure -credential = None if endpoint == "http://localhost:8080" else DefaultAzureCredential() +secure_channel = endpoint.startswith("https://") +credential = DefaultAzureCredential() if secure_channel else None # Create a client, start an orchestration, and wait for it to finish -c = DurableTaskSchedulerClient(host_address=endpoint, secure_channel=True, +c = DurableTaskSchedulerClient(host_address=endpoint, secure_channel=secure_channel, taskhub=taskhub_name, token_credential=credential) instance_id = c.schedule_new_orchestration("orchestrator") diff --git a/examples/sub-orchestrations-with-fan-out-fan-in/worker.py b/examples/sub-orchestrations-with-fan-out-fan-in/worker.py index 8ca447d..45620cd 100644 --- a/examples/sub-orchestrations-with-fan-out-fan-in/worker.py +++ b/examples/sub-orchestrations-with-fan-out-fan-in/worker.py @@ -119,10 +119,9 @@ def orchestrator(ctx, _): print(f"Using endpoint: {endpoint}") # Set credential to None for emulator, or DefaultAzureCredential for Azure -credential = None if endpoint == "http://localhost:8080" else DefaultAzureCredential() - -# Configure and start the worker -with DurableTaskSchedulerWorker(host_address=endpoint, secure_channel=True, +secure_channel = endpoint.startswith("https://") +credential = DefaultAzureCredential() if secure_channel else None +with DurableTaskSchedulerWorker(host_address=endpoint, secure_channel=secure_channel, taskhub=taskhub_name, token_credential=credential) as w: w.add_orchestrator(orchestrator) diff --git a/examples/version_aware_orchestrator.py b/examples/version_aware_orchestrator.py index 15ac961..b0af11a 100644 --- a/examples/version_aware_orchestrator.py +++ b/examples/version_aware_orchestrator.py @@ -47,10 +47,8 @@ def orchestrator(ctx: task.OrchestrationContext, _): print(f"Using endpoint: {endpoint}") # Set credential to None for emulator, or DefaultAzureCredential for Azure -credential = None if endpoint == "http://localhost:8080" else DefaultAzureCredential() - -# configure and start the worker - use secure_channel=False for emulator -secure_channel = endpoint != "http://localhost:8080" +secure_channel = endpoint.startswith("https://") +credential = DefaultAzureCredential() if secure_channel else None with DurableTaskSchedulerWorker(host_address=endpoint, secure_channel=secure_channel, taskhub=taskhub_name, token_credential=credential) as w: # This worker is versioned for v2, as the orchestrator code has already been updated diff --git a/tests/durabletask/test_entity_executor.py b/tests/durabletask/test_entity_executor.py new file mode 100644 index 0000000..851edd7 --- /dev/null +++ b/tests/durabletask/test_entity_executor.py @@ -0,0 +1,131 @@ +"""Unit tests for the _EntityExecutor class in durabletask.worker.""" +import logging + +from durabletask import entities +from durabletask.internal.entity_state_shim import StateShim +from durabletask.worker import _EntityExecutor, _Registry + + +def _make_executor(*entity_args) -> _EntityExecutor: + """Helper to create an _EntityExecutor with registered entities.""" + registry = _Registry() + for entity in entity_args: + registry.add_entity(entity) + return _EntityExecutor(registry, logging.getLogger("test")) + + +def _execute(executor, entity_name, operation, encoded_input=None): + """Helper to execute an entity operation.""" + entity_id = entities.EntityInstanceId(entity_name, "test-key") + state = StateShim(None) + return executor.execute("test-orchestration", entity_id, operation, state, encoded_input) + + +class TestClassBasedEntityMethodDispatch: + """Tests for class-based entity method dispatch in _EntityExecutor.""" + + def test_method_with_no_input_parameter(self): + """Methods that don't accept input should work without _=None.""" + class Counter(entities.DurableEntity): + def get(self): + return self.get_state(int, 0) + + executor = _make_executor(Counter) + result = _execute(executor, "Counter", "get") + assert result == "0" + + def test_method_with_input_parameter(self): + """Methods that accept input should receive entity_input.""" + class Counter(entities.DurableEntity): + def set(self, value: int): + self.set_state(value) + + executor = _make_executor(Counter) + result = _execute(executor, "Counter", "set", "42") + assert result is None + + def test_method_with_input_returns_value(self): + """Methods that accept input and return a value.""" + class Counter(entities.DurableEntity): + def add(self, value: int): + current = self.get_state(int, 0) + new_value = current + value + self.set_state(new_value) + return new_value + + executor = _make_executor(Counter) + result = _execute(executor, "Counter", "add", "5") + assert result == "5" + + def test_mix_of_methods_with_and_without_input(self): + """An entity with both input and no-input methods should work.""" + class Counter(entities.DurableEntity): + def set(self, value: int): + self.set_state(value) + + def get(self): + return self.get_state(int, 0) + + executor = _make_executor(Counter) + entity_id = entities.EntityInstanceId("Counter", "test-key") + + # set requires input + state = StateShim(None) + executor.execute("test-orch", entity_id, "set", state, "10") + state.commit() + + # get does not require input — reuse state to simulate persistence + result = executor.execute("test-orch", entity_id, "get", state, None) + assert result == "10" + + def test_method_with_optional_parameter_uses_default(self): + """Methods with default parameters should use defaults when no input is provided.""" + class Counter(entities.DurableEntity): + def add(self, value: int = 1): + current = self.get_state(int, 0) + new_value = current + value + self.set_state(new_value) + return new_value + + executor = _make_executor(Counter) + + # No input provided — should use default value of 1 + result = _execute(executor, "Counter", "add") + assert result == "1" + + def test_method_with_optional_parameter_uses_provided_input(self): + """Methods with default parameters should use provided input when given.""" + class Counter(entities.DurableEntity): + def add(self, value: int = 1): + current = self.get_state(int, 0) + new_value = current + value + self.set_state(new_value) + return new_value + + executor = _make_executor(Counter) + + # Input provided — should use it instead of default + result = _execute(executor, "Counter", "add", "5") + assert result == "5" + + +class TestFunctionBasedEntityDispatch: + """Tests for function-based entity dispatch in _EntityExecutor.""" + + def test_function_entity_receives_context_and_input(self): + """Function-based entities always receive (ctx, input).""" + def counter(ctx: entities.EntityContext, input): + if ctx.operation == "get": + return ctx.get_state(int, 0) + elif ctx.operation == "set": + ctx.set_state(input) + + executor = _make_executor(counter) + entity_id = entities.EntityInstanceId("counter", "test-key") + state = StateShim(None) + + executor.execute("test-orch", entity_id, "set", state, "42") + state.commit() + + result = executor.execute("test-orch", entity_id, "get", state, None) + assert result == "42"