diff --git a/cadence/__init__.py b/cadence/__init__.py new file mode 100644 index 0000000..175f01b --- /dev/null +++ b/cadence/__init__.py @@ -0,0 +1,14 @@ +""" +Cadence Python Client + +A Python framework for authoring workflows and activities for Cadence. +""" + +# Import main client functionality +from .client import Client + +__version__ = "0.1.0" + +__all__ = [ + "Client", +] diff --git a/cadence/worker/__init__.py b/cadence/worker/__init__.py index c2959b6..6249d28 100644 --- a/cadence/worker/__init__.py +++ b/cadence/worker/__init__.py @@ -5,7 +5,16 @@ WorkerOptions ) +from ._registry import ( + Registry, + RegisterWorkflowOptions, + RegisterActivityOptions, +) + __all__ = [ "Worker", - "WorkerOptions" -] \ No newline at end of file + "WorkerOptions", + 'Registry', + 'RegisterWorkflowOptions', + 'RegisterActivityOptions', +] diff --git a/cadence/worker/_registry.py b/cadence/worker/_registry.py new file mode 100644 index 0000000..6822351 --- /dev/null +++ b/cadence/worker/_registry.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +""" +Workflow and Activity Registry for Cadence Python Client. + +This module provides a registry system for managing workflows and activities, +similar to the Go client's registry.go implementation. +""" + +import logging +from typing import Callable, Dict, Optional, Unpack, TypedDict + + +logger = logging.getLogger(__name__) + + +class RegisterWorkflowOptions(TypedDict, total=False): + """Options for registering a workflow.""" + name: Optional[str] + alias: Optional[str] + + +class RegisterActivityOptions(TypedDict, total=False): + """Options for registering an activity.""" + name: Optional[str] + alias: Optional[str] + + +class Registry: + """ + Registry for managing workflows and activities. + + This class provides functionality to register, retrieve, and manage + workflows and activities in a Cadence application. + """ + + def __init__(self): + """Initialize the registry.""" + self._workflows: Dict[str, Callable] = {} + self._activities: Dict[str, Callable] = {} + self._workflow_aliases: Dict[str, str] = {} # alias -> name mapping + self._activity_aliases: Dict[str, str] = {} # alias -> name mapping + + def workflow( + self, + func: Optional[Callable] = None, + **kwargs: Unpack[RegisterWorkflowOptions] + ) -> Callable: + """ + Register a workflow function. + + This method can be used as a decorator or called directly. + + Args: + func: The workflow function to register + **kwargs: Options for registration (name, alias) + + Returns: + The decorated function or the function itself + + Raises: + KeyError: If workflow name already exists + """ + options = RegisterWorkflowOptions(**kwargs) + + def decorator(f: Callable) -> Callable: + workflow_name = options.get('name') or f.__name__ + + if workflow_name in self._workflows: + raise KeyError(f"Workflow '{workflow_name}' is already registered") + + self._workflows[workflow_name] = f + + # Register alias if provided + alias = options.get('alias') + if alias: + if alias in self._workflow_aliases: + raise KeyError(f"Workflow alias '{alias}' is already registered") + self._workflow_aliases[alias] = workflow_name + + logger.info(f"Registered workflow '{workflow_name}'") + return f + + if func is None: + return decorator + return decorator(func) + + def activity( + self, + func: Optional[Callable] = None, + **kwargs: Unpack[RegisterActivityOptions] + ) -> Callable: + """ + Register an activity function. + + This method can be used as a decorator or called directly. + + Args: + func: The activity function to register + **kwargs: Options for registration (name, alias) + + Returns: + The decorated function or the function itself + + Raises: + KeyError: If activity name already exists + """ + options = RegisterActivityOptions(**kwargs) + + def decorator(f: Callable) -> Callable: + activity_name = options.get('name') or f.__name__ + + if activity_name in self._activities: + raise KeyError(f"Activity '{activity_name}' is already registered") + + self._activities[activity_name] = f + + # Register alias if provided + alias = options.get('alias') + if alias: + if alias in self._activity_aliases: + raise KeyError(f"Activity alias '{alias}' is already registered") + self._activity_aliases[alias] = activity_name + + logger.info(f"Registered activity '{activity_name}'") + return f + + if func is None: + return decorator + return decorator(func) + + def get_workflow(self, name: str) -> Callable: + """ + Get a registered workflow by name. + + Args: + name: Name or alias of the workflow + + Returns: + The workflow function + + Raises: + KeyError: If workflow is not found + """ + # Check if it's an alias + actual_name = self._workflow_aliases.get(name, name) + + if actual_name not in self._workflows: + raise KeyError(f"Workflow '{name}' not found in registry") + + return self._workflows[actual_name] + + def get_activity(self, name: str) -> Callable: + """ + Get a registered activity by name. + + Args: + name: Name or alias of the activity + + Returns: + The activity function + + Raises: + KeyError: If activity is not found + """ + # Check if it's an alias + actual_name = self._activity_aliases.get(name, name) + + if actual_name not in self._activities: + raise KeyError(f"Activity '{name}' not found in registry") + + return self._activities[actual_name] + + + \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 1bec80a..82c7457 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ dependencies = [ [project.optional-dependencies] dev = [ - "pytest>=7.0.0", + "pytest>=8.4.1", "pytest-cov>=4.0.0", "pytest-asyncio>=0.21.0", "black>=23.0.0", diff --git a/tests/cadence/worker/test_registry.py b/tests/cadence/worker/test_registry.py new file mode 100644 index 0000000..57f345b --- /dev/null +++ b/tests/cadence/worker/test_registry.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +""" +Tests for the registry functionality. +""" + +import pytest + +from cadence.worker import Registry, RegisterWorkflowOptions, RegisterActivityOptions + + +class TestRegistry: + """Test registry functionality.""" + + def test_basic_registry_creation(self): + """Test basic registry creation.""" + reg = Registry() + with pytest.raises(KeyError): + reg.get_workflow("nonexistent") + with pytest.raises(KeyError): + reg.get_activity("nonexistent") + + @pytest.mark.parametrize("registration_type", ["workflow", "activity"]) + def test_basic_registration_and_retrieval(self, registration_type): + """Test basic registration and retrieval for both workflows and activities.""" + reg = Registry() + + if registration_type == "workflow": + @reg.workflow + def test_func(): + return "test" + + func = reg.get_workflow("test_func") + else: + @reg.activity + def test_func(): + return "test" + + func = reg.get_activity("test_func") + + assert func() == "test" + + @pytest.mark.parametrize("registration_type", ["workflow", "activity"]) + def test_direct_call_behavior(self, registration_type): + """Test direct function call behavior for both workflows and activities.""" + reg = Registry() + + def test_func(): + return "direct_call" + + if registration_type == "workflow": + registered_func = reg.workflow(test_func) + func = reg.get_workflow("test_func") + else: + registered_func = reg.activity(test_func) + func = reg.get_activity("test_func") + + assert registered_func == test_func + assert func() == "direct_call" + + @pytest.mark.parametrize("registration_type", ["workflow", "activity"]) + def test_decorator_with_options(self, registration_type): + """Test decorator with options for both workflows and activities.""" + reg = Registry() + + if registration_type == "workflow": + @reg.workflow(name="custom_name", alias="custom_alias") + def test_func(): + return "decorator_with_options" + + func = reg.get_workflow("custom_name") + func_by_alias = reg.get_workflow("custom_alias") + else: + @reg.activity(name="custom_name", alias="custom_alias") + def test_func(): + return "decorator_with_options" + + func = reg.get_activity("custom_name") + func_by_alias = reg.get_activity("custom_alias") + + assert func() == "decorator_with_options" + assert func_by_alias() == "decorator_with_options" + assert func == func_by_alias + + @pytest.mark.parametrize("registration_type", ["workflow", "activity"]) + def test_direct_call_with_options(self, registration_type): + """Test direct call with options for both workflows and activities.""" + reg = Registry() + + def test_func(): + return "direct_call_with_options" + + if registration_type == "workflow": + registered_func = reg.workflow(test_func, name="custom_name", alias="custom_alias") + func = reg.get_workflow("custom_name") + func_by_alias = reg.get_workflow("custom_alias") + else: + registered_func = reg.activity(test_func, name="custom_name", alias="custom_alias") + func = reg.get_activity("custom_name") + func_by_alias = reg.get_activity("custom_name") + + assert registered_func == test_func + assert func() == "direct_call_with_options" + assert func_by_alias() == "direct_call_with_options" + assert func == func_by_alias + + @pytest.mark.parametrize("registration_type", ["workflow", "activity"]) + def test_not_found_error(self, registration_type): + """Test KeyError is raised when function not found.""" + reg = Registry() + + if registration_type == "workflow": + with pytest.raises(KeyError): + reg.get_workflow("nonexistent") + else: + with pytest.raises(KeyError): + reg.get_activity("nonexistent") + + @pytest.mark.parametrize("registration_type", ["workflow", "activity"]) + def test_duplicate_registration_error(self, registration_type): + """Test KeyError is raised for duplicate registrations.""" + reg = Registry() + + if registration_type == "workflow": + @reg.workflow + def test_func(): + return "test" + + with pytest.raises(KeyError): + @reg.workflow + def test_func(): + return "duplicate" + else: + @reg.activity + def test_func(): + return "test" + + with pytest.raises(KeyError): + @reg.activity + def test_func(): + return "duplicate" + + @pytest.mark.parametrize("registration_type", ["workflow", "activity"]) + def test_alias_functionality(self, registration_type): + """Test alias functionality for both workflows and activities.""" + reg = Registry() + + if registration_type == "workflow": + @reg.workflow(name="custom_name") + def test_func(): + return "test" + + func = reg.get_workflow("custom_name") + else: + @reg.activity(alias="custom_alias") + def test_func(): + return "test" + + func = reg.get_activity("custom_alias") + func_by_name = reg.get_activity("test_func") + assert func_by_name() == "test" + assert func == func_by_name + + assert func() == "test" + + @pytest.mark.parametrize("registration_type", ["workflow", "activity"]) + def test_options_class(self, registration_type): + """Test using options classes for both workflows and activities.""" + reg = Registry() + + if registration_type == "workflow": + options = RegisterWorkflowOptions(name="custom_name", alias="custom_alias") + + @reg.workflow(**options) + def test_func(): + return "test" + + func = reg.get_workflow("custom_name") + func_by_alias = reg.get_workflow("custom_alias") + else: + options = RegisterActivityOptions(name="custom_name", alias="custom_alias") + + @reg.activity(**options) + def test_func(): + return "test" + + func = reg.get_activity("custom_name") + func_by_alias = reg.get_activity("custom_alias") + + assert func() == "test" + assert func_by_alias() == "test" + assert func == func_by_alias diff --git a/uv.lock b/uv.lock index feb34bf..fe3f9e0 100644 --- a/uv.lock +++ b/uv.lock @@ -193,7 +193,7 @@ requires-dist = [ { name = "myst-parser", marker = "extra == 'docs'", specifier = ">=1.0.0" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.0.0" }, { name = "protobuf", specifier = "==5.29.1" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.4.1" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" }, { name = "requests", marker = "extra == 'examples'", specifier = ">=2.28.0" },