From 9f0a771600d3eba79bc75727eeed4f7f4c4b3672 Mon Sep 17 00:00:00 2001 From: Eric Searcy Date: Thu, 19 Dec 2024 07:36:44 -0800 Subject: [PATCH 1/2] Replace deprecated tox.ini option (#762) This option was replaced in 2020, deprecated, and eventually removed in tox 4. The correct option already appears elseware in this tox.ini file. This fix is necessary to run `tox -e doc` per the README.md instructions on tox 4. Signed-off-by: Eric Searcy Co-authored-by: Elena Kolevska --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 78f23086..5e7a2539 100644 --- a/tox.ini +++ b/tox.ini @@ -88,7 +88,7 @@ commands_pre = [testenv:doc] basepython = python3 usedevelop = False -whitelist_externals = make +allowlist_externals = make deps = sphinx commands = sphinx-apidoc -E -o docs/actor dapr/actor From aafb9009acce06f34d3f5dac0c6a490a7ded8385 Mon Sep 17 00:00:00 2001 From: Lorenzo Curcio Date: Thu, 2 Jan 2025 13:25:25 +0100 Subject: [PATCH 2/2] Add Actor Mocks (#750) * Moved files to new branch to avoid weird git bug Signed-off-by: Lorenzo Curcio * requested documentation changes Signed-off-by: Lorenzo Curcio * forgot to move file back to starting point Signed-off-by: Lorenzo Curcio * result of ruff format Signed-off-by: Lorenzo Curcio * fixed minor formatting issues, fixed type issues Signed-off-by: Lorenzo Curcio * minor test fix Signed-off-by: Lorenzo Curcio * fixes try_add_state Signed-off-by: Elena Kolevska Signed-off-by: Lorenzo Curcio * Revert "fixes try_add_state" This reverts commit 254ad17bfb184310b2ceae37c1eb82c947466ce6. Signed-off-by: Lorenzo Curcio * Update dapr/actor/runtime/mock_state_manager.py Fixing bug in try_add_state as mentioned in PR #756 Co-authored-by: Elena Kolevska Signed-off-by: Lorenzo Curcio Signed-off-by: Lorenzo Curcio * Update dapr/actor/runtime/mock_actor.py Whoops missed this Co-authored-by: Elena Kolevska Signed-off-by: Lorenzo Curcio * Update daprdocs/content/en/python-sdk-docs/python-actor.md Co-authored-by: Elena Kolevska Signed-off-by: Lorenzo Curcio * Update daprdocs/content/en/python-sdk-docs/python-actor.md Co-authored-by: Elena Kolevska Signed-off-by: Lorenzo Curcio * Update daprdocs/content/en/python-sdk-docs/python-actor.md Co-authored-by: Elena Kolevska Signed-off-by: Lorenzo Curcio * Update daprdocs/content/en/python-sdk-docs/python-actor.md Co-authored-by: Elena Kolevska Signed-off-by: Lorenzo Curcio * Update daprdocs/content/en/python-sdk-docs/python-actor.md Co-authored-by: Elena Kolevska Signed-off-by: Lorenzo Curcio * Update daprdocs/content/en/python-sdk-docs/python-actor.md Co-authored-by: Elena Kolevska Signed-off-by: Lorenzo Curcio * Update daprdocs/content/en/python-sdk-docs/python-actor.md Co-authored-by: Elena Kolevska Signed-off-by: Lorenzo Curcio * minor error in docs Signed-off-by: Lorenzo Curcio * fixed and added more unit tests. Added example Signed-off-by: Lorenzo Curcio * unittest fix Signed-off-by: Lorenzo Curcio * Update examples/demo_actor/README.md Co-authored-by: Elena Kolevska Signed-off-by: Lorenzo Curcio * concentrated some tests Signed-off-by: Lorenzo Curcio * removed unnecessary type hint Signed-off-by: Lorenzo Curcio * Update daprdocs/content/en/python-sdk-docs/python-actor.md didnt see this earlier whoops Co-authored-by: Elena Kolevska Signed-off-by: Lorenzo Curcio * Update examples/demo_actor/README.md Co-authored-by: Elena Kolevska Signed-off-by: Lorenzo Curcio * documentation changes Signed-off-by: Lorenzo Curcio * now requires #type: ignore Signed-off-by: Lorenzo Curcio * small docs change Signed-off-by: Elena Kolevska * examples test fix Signed-off-by: Elena Kolevska --------- Signed-off-by: Lorenzo Curcio Signed-off-by: Lorenzo Curcio Signed-off-by: Elena Kolevska Co-authored-by: Elena Kolevska Co-authored-by: Lorenzo Curcio Co-authored-by: Elena Kolevska --- dapr/actor/runtime/mock_actor.py | 122 +++++++ dapr/actor/runtime/mock_state_manager.py | 238 +++++++++++++ dapr/actor/runtime/state_manager.py | 5 +- .../en/python-sdk-docs/python-actor.md | 71 +++- docs/clients/clients.grpc.rst | 31 ++ docs/proto/proto.runtime.rst | 18 + docs/proto/proto.runtime.v1.rst | 37 +++ examples/demo_actor/README.md | 55 +++ .../demo_actor/demo_actor/test_demo_actor.py | 40 +++ tests/actor/test_mock_actor.py | 314 ++++++++++++++++++ tests/actor/test_mock_state_manager.py | 73 ++++ 11 files changed, 1000 insertions(+), 4 deletions(-) create mode 100644 dapr/actor/runtime/mock_actor.py create mode 100644 dapr/actor/runtime/mock_state_manager.py create mode 100644 docs/clients/clients.grpc.rst create mode 100644 docs/proto/proto.runtime.rst create mode 100644 docs/proto/proto.runtime.v1.rst create mode 100644 examples/demo_actor/demo_actor/test_demo_actor.py create mode 100644 tests/actor/test_mock_actor.py create mode 100644 tests/actor/test_mock_state_manager.py diff --git a/dapr/actor/runtime/mock_actor.py b/dapr/actor/runtime/mock_actor.py new file mode 100644 index 00000000..e35baac5 --- /dev/null +++ b/dapr/actor/runtime/mock_actor.py @@ -0,0 +1,122 @@ +""" +Copyright 2023 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +from __future__ import annotations + +from datetime import timedelta +from typing import Any, Optional, TypeVar + +from dapr.actor.id import ActorId +from dapr.actor.runtime._reminder_data import ActorReminderData +from dapr.actor.runtime._timer_data import TIMER_CALLBACK, ActorTimerData +from dapr.actor.runtime.actor import Actor +from dapr.actor.runtime.mock_state_manager import MockStateManager + + +class MockActor(Actor): + """A mock actor class to be used to override certain Actor methods for unit testing. + To be used only via the create_mock_actor function, which takes in a class and returns a + mock actor object for that class. + + Examples: + class SomeActorInterface(ActorInterface): + @actor_method(name="method") + async def set_state(self, data: dict) -> None: + + class SomeActor(Actor, SomeActorInterface): + async def set_state(self, data: dict) -> None: + await self._state_manager.set_state('state', data) + await self._state_manager.save_state() + + mock_actor = create_mock_actor(SomeActor, "actor_1") + assert mock_actor._state_manager._mock_state == {} + await mock_actor.set_state({"test":10}) + assert mock_actor._state_manager._mock_state == {"test":10} + """ + + def __init__(self, actor_id: str, initstate: Optional[dict]): + self.id = ActorId(actor_id) + self._runtime_ctx = None # type: ignore + self._state_manager = MockStateManager(self, initstate) + + async def register_timer( + self, + name: Optional[str], + callback: TIMER_CALLBACK, + state: Any, + due_time: timedelta, + period: timedelta, + ttl: Optional[timedelta] = None, + ) -> None: + """Adds actor timer to self._state_manager._mock_timers. + Args: + name (str): the name of the timer to register. + callback (Callable): An awaitable callable which will be called when the timer fires. + state (Any): An object which will pass to the callback method, or None. + due_time (datetime.timedelta): the amount of time to delay before the awaitable + callback is first invoked. + period (datetime.timedelta): the time interval between invocations + of the awaitable callback. + ttl (Optional[datetime.timedelta]): the time interval before the timer stops firing + """ + name = name or self.__get_new_timer_name() + timer = ActorTimerData(name, callback, state, due_time, period, ttl) + self._state_manager._mock_timers[name] = timer # type: ignore + + async def unregister_timer(self, name: str) -> None: + """Unregisters actor timer from self._state_manager._mock_timers. + + Args: + name (str): the name of the timer to unregister. + """ + self._state_manager._mock_timers.pop(name, None) # type: ignore + + async def register_reminder( + self, + name: str, + state: bytes, + due_time: timedelta, + period: timedelta, + ttl: Optional[timedelta] = None, + ) -> None: + """Adds actor reminder to self._state_manager._mock_reminders. + + Args: + name (str): the name of the reminder to register. the name must be unique per actor. + state (bytes): the user state passed to the reminder invocation. + due_time (datetime.timedelta): the amount of time to delay before invoking the reminder + for the first time. + period (datetime.timedelta): the time interval between reminder invocations after + the first invocation. + ttl (datetime.timedelta): the time interval before the reminder stops firing + """ + reminder = ActorReminderData(name, state, due_time, period, ttl) + self._state_manager._mock_reminders[name] = reminder # type: ignore + + async def unregister_reminder(self, name: str) -> None: + """Unregisters actor reminder from self._state_manager._mock_reminders.. + + Args: + name (str): the name of the reminder to unregister. + """ + self._state_manager._mock_reminders.pop(name, None) # type: ignore + + +T = TypeVar('T', bound=Actor) + + +def create_mock_actor(cls1: type[T], actor_id: str, initstate: Optional[dict] = None) -> T: + class MockSuperClass(MockActor, cls1): # type: ignore + pass + + return MockSuperClass(actor_id, initstate) # type: ignore diff --git a/dapr/actor/runtime/mock_state_manager.py b/dapr/actor/runtime/mock_state_manager.py new file mode 100644 index 00000000..bcac6d95 --- /dev/null +++ b/dapr/actor/runtime/mock_state_manager.py @@ -0,0 +1,238 @@ +""" +Copyright 2023 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import asyncio +from contextvars import ContextVar +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple, TypeVar + +from dapr.actor.runtime._reminder_data import ActorReminderData +from dapr.actor.runtime._timer_data import ActorTimerData +from dapr.actor.runtime.state_change import ActorStateChange, StateChangeKind +from dapr.actor.runtime.state_manager import ActorStateManager, StateMetadata + +if TYPE_CHECKING: + from dapr.actor.runtime.mock_actor import MockActor + +T = TypeVar('T') +CONTEXT: ContextVar[Optional[Dict[str, Any]]] = ContextVar('state_tracker_context') + + +class MockStateManager(ActorStateManager): + def __init__(self, actor: 'MockActor', initstate: Optional[dict]): + self._actor = actor + self._default_state_change_tracker: Dict[str, StateMetadata] = {} + self._mock_state: Dict[str, Any] = {} + self._mock_timers: Dict[str, ActorTimerData] = {} + self._mock_reminders: Dict[str, ActorReminderData] = {} + if initstate: + self._mock_state = initstate + + async def add_state(self, state_name: str, value: T) -> None: + if not await self.try_add_state(state_name, value): + raise ValueError(f'The actor state name {state_name} already exist.') + + async def try_add_state(self, state_name: str, value: T) -> bool: + if state_name in self._default_state_change_tracker: + state_metadata = self._default_state_change_tracker[state_name] + if state_metadata.change_kind == StateChangeKind.remove: + self._default_state_change_tracker[state_name] = StateMetadata( + value, StateChangeKind.update + ) + return True + return False + existed = state_name in self._mock_state + if existed: + return False + self._default_state_change_tracker[state_name] = StateMetadata(value, StateChangeKind.add) + self._mock_state[state_name] = value + return True + + async def get_state(self, state_name: str) -> Optional[T]: + has_value, val = await self.try_get_state(state_name) + if has_value: + return val + else: + raise KeyError(f'Actor State with name {state_name} was not found.') + + async def try_get_state(self, state_name: str) -> Tuple[bool, Optional[T]]: + if state_name in self._default_state_change_tracker: + state_metadata = self._default_state_change_tracker[state_name] + if state_metadata.change_kind == StateChangeKind.remove: + return False, None + return True, state_metadata.value + has_value = state_name in self._mock_state + val = self._mock_state.get(state_name) + if has_value: + self._default_state_change_tracker[state_name] = StateMetadata( + val, StateChangeKind.none + ) + return has_value, val + + async def set_state(self, state_name: str, value: T) -> None: + await self.set_state_ttl(state_name, value, None) + + async def set_state_ttl(self, state_name: str, value: T, ttl_in_seconds: Optional[int]) -> None: + if ttl_in_seconds is not None and ttl_in_seconds < 0: + return + + if state_name in self._default_state_change_tracker: + state_metadata = self._default_state_change_tracker[state_name] + state_metadata.value = value + state_metadata.ttl_in_seconds = ttl_in_seconds + + if ( + state_metadata.change_kind == StateChangeKind.none + or state_metadata.change_kind == StateChangeKind.remove + ): + state_metadata.change_kind = StateChangeKind.update + self._default_state_change_tracker[state_name] = state_metadata + self._mock_state[state_name] = value + return + + existed = state_name in self._mock_state + if existed: + self._default_state_change_tracker[state_name] = StateMetadata( + value, StateChangeKind.update, ttl_in_seconds + ) + else: + self._default_state_change_tracker[state_name] = StateMetadata( + value, StateChangeKind.add, ttl_in_seconds + ) + self._mock_state[state_name] = value + + async def remove_state(self, state_name: str) -> None: + if not await self.try_remove_state(state_name): + raise KeyError(f'Actor State with name {state_name} was not found.') + + async def try_remove_state(self, state_name: str) -> bool: + if state_name in self._default_state_change_tracker: + state_metadata = self._default_state_change_tracker[state_name] + if state_metadata.change_kind == StateChangeKind.remove: + return False + elif state_metadata.change_kind == StateChangeKind.add: + self._default_state_change_tracker.pop(state_name, None) + self._mock_state.pop(state_name, None) + return True + self._mock_state.pop(state_name, None) + state_metadata.change_kind = StateChangeKind.remove + return True + + existed = state_name in self._mock_state + if existed: + self._default_state_change_tracker[state_name] = StateMetadata( + None, StateChangeKind.remove + ) + self._mock_state.pop(state_name, None) + return True + return False + + async def contains_state(self, state_name: str) -> bool: + if state_name in self._default_state_change_tracker: + state_metadata = self._default_state_change_tracker[state_name] + return state_metadata.change_kind != StateChangeKind.remove + return state_name in self._mock_state + + async def get_or_add_state(self, state_name: str, value: T) -> Optional[T]: + has_value, val = await self.try_get_state(state_name) + if has_value: + return val + change_kind = ( + StateChangeKind.update + if self.is_state_marked_for_remove(state_name) + else StateChangeKind.add + ) + self._mock_state[state_name] = value + self._default_state_change_tracker[state_name] = StateMetadata(value, change_kind) + return value + + async def add_or_update_state( + self, state_name: str, value: T, update_value_factory: Callable[[str, T], T] + ) -> T: + if not callable(update_value_factory): + raise AttributeError('update_value_factory is not callable') + + if state_name in self._default_state_change_tracker: + state_metadata = self._default_state_change_tracker[state_name] + if state_metadata.change_kind == StateChangeKind.remove: + self._default_state_change_tracker[state_name] = StateMetadata( + value, StateChangeKind.update + ) + self._mock_state[state_name] = value + return value + new_value = update_value_factory(state_name, state_metadata.value) + state_metadata.value = new_value + if state_metadata.change_kind == StateChangeKind.none: + state_metadata.change_kind = StateChangeKind.update + self._default_state_change_tracker[state_name] = state_metadata + self._mock_state[state_name] = new_value + return new_value + + has_value = state_name in self._mock_state + val: Any = self._mock_state.get(state_name) + if has_value: + new_value = update_value_factory(state_name, val) + self._default_state_change_tracker[state_name] = StateMetadata( + new_value, StateChangeKind.update + ) + self._mock_state[state_name] = new_value + return new_value + self._default_state_change_tracker[state_name] = StateMetadata(value, StateChangeKind.add) + self._mock_state[state_name] = value + return value + + async def get_state_names(self) -> List[str]: + # TODO: Get all state names from Dapr once implemented. + def append_names_sync(): + state_names = [] + for key, value in self._default_state_change_tracker.items(): + if value.change_kind == StateChangeKind.add: + state_names.append(key) + elif value.change_kind == StateChangeKind.remove: + state_names.append(key) + return state_names + + default_loop = asyncio.get_running_loop() + return await default_loop.run_in_executor(None, append_names_sync) + + async def clear_cache(self) -> None: + self._default_state_change_tracker.clear() + + async def save_state(self) -> None: + if len(self._default_state_change_tracker) == 0: + return + + state_changes = [] + states_to_remove = [] + for state_name, state_metadata in self._default_state_change_tracker.items(): + if state_metadata.change_kind == StateChangeKind.none: + continue + state_changes.append( + ActorStateChange( + state_name, + state_metadata.value, + state_metadata.change_kind, + state_metadata.ttl_in_seconds, + ) + ) + if state_metadata.change_kind == StateChangeKind.remove: + states_to_remove.append(state_name) + # Mark the states as unmodified so that tracking for next invocation is done correctly. + state_metadata.change_kind = StateChangeKind.none + for state_name in states_to_remove: + self._default_state_change_tracker.pop(state_name, None) + + def is_state_marked_for_remove(self, state_name: str) -> bool: + return ( + state_name in self._default_state_change_tracker + and self._default_state_change_tracker[state_name].change_kind == StateChangeKind.remove + ) diff --git a/dapr/actor/runtime/state_manager.py b/dapr/actor/runtime/state_manager.py index 7132175b..35cc33fb 100644 --- a/dapr/actor/runtime/state_manager.py +++ b/dapr/actor/runtime/state_manager.py @@ -15,11 +15,10 @@ import asyncio from contextvars import ContextVar +from typing import TYPE_CHECKING, Any, Callable, Dict, Generic, List, Optional, Tuple, TypeVar -from dapr.actor.runtime.state_change import StateChangeKind, ActorStateChange from dapr.actor.runtime.reentrancy_context import reentrancy_ctx - -from typing import Any, Callable, Dict, Generic, List, Tuple, TypeVar, Optional, TYPE_CHECKING +from dapr.actor.runtime.state_change import ActorStateChange, StateChangeKind if TYPE_CHECKING: from dapr.actor.runtime.actor import Actor diff --git a/daprdocs/content/en/python-sdk-docs/python-actor.md b/daprdocs/content/en/python-sdk-docs/python-actor.md index 565435aa..bd85e996 100644 --- a/daprdocs/content/en/python-sdk-docs/python-actor.md +++ b/daprdocs/content/en/python-sdk-docs/python-actor.md @@ -56,4 +56,73 @@ async def main(): ## Sample -Visit [this page](https://github.com/dapr/python-sdk/tree/release-1.0/examples/demo_actor) for a runnable actor sample. \ No newline at end of file +Visit [this page](https://github.com/dapr/python-sdk/tree/v1.14.0/examples/demo_actor) for a runnable actor sample. + + +## Mock Actor Testing + +The Dapr Python SDK provides the ability to create mock actors to unit test your actor methods and see how they interact with the actor state. + +### Sample Usage + + +``` +from dapr.actor.runtime.mock_actor import create_mock_actor + +class MyActor(Actor, MyActorInterface): + async def save_state(self, data) -> None: + await self._state_manager.set_state('mystate', data) + await self._state_manager.save_state() + +mock_actor = create_mock_actor(MyActor, "id") + +await mock_actor.save_state(5) +assert mockactor._state_manager._mock_state['mystate'] == 5 #True +``` +Mock actors are created by passing your actor class and an actor ID (a string) to the create_mock_actor function. This function returns an instance of the actor with many internal methods overridden. Instead of interacting with Dapr for tasks like saving state or managing timers, the mock actor uses in-memory state to simulate these behaviors. + +This state can be accessed through the following variables: + +**IMPORTANT NOTE: Due to type hinting issues as discussed further down, these variables will not be visible to type hinters/linters/etc, who will think they are invalid variables. You will need to use them with #type: ignore in order to satisfy any such systems.** + +- **_state_manager._mock_state()** +A `[str, object]` dict where all the actor state is stored. Any variable saved via `_state_manager.save_state(key, value)`, or any other statemanager method is stored in the dict as that key, value pair. Any value loaded via `try_get_state` or any other statemanager method is taken from this dict. + +- **_state_manager._mock_timers()** +A `[str, ActorTimerData]` dict which holds the active actor timers. Any actor method which would add or remove a timer adds or pops the appropriate `ActorTimerData` object from this dict. + +- **_state_manager._mock_reminders()** +A [str, ActorReminderData] dict which holds the active actor reminders. Any actor method which would add or remove a timer adds or pops the appropriate ActorReminderData object from this dict. + +**Note: The timers and reminders will never actually trigger. The dictionaries exist only so methods that should add or remove timers/reminders can be tested. If you need to test the callbacks they should activate, you should call them directly with the appropriate values:** +``` +result = await mock_actor.recieve_reminder(name, state, due_time, period, _ttl) +# Test the result directly or test for side effects (like changing state) by querying `_state_manager._mock_state` +``` + +### Usage and Limitations + +**To allow for more fine-grained control, the `_on_activate` method will not be called automatically the way it is when Dapr initializes a new Actor instance. You should call it manually as needed as part of your tests.** + +The `__init__`, `register_timer`, `unregister_timer`, `register_reminder`, `unregister_reminder` methods are all overwritten by the MockActor class that gets applied as a mixin via `create_mock_actor`. If your actor itself overwrites these methods, those modifications will themselves be overwritten and the actor will likely not behave as you expect. + +*note: `__init__` is a special case where you are expected to define it as* +``` + def __init__(self, ctx, actor_id): + super().__init__(ctx, actor_id) +``` +*Mock actors work fine with this, but if you have added any extra logic into `__init__`, it will be overwritten. It is worth noting that the correct way to apply logic on initialization is via `_on_activate` (which can also be safely used with mock actors) instead of `__init__`.* + +*If you have an actor which does override default Dapr actor methods, you can create a custom subclass of the MockActor class (from MockActor.py) which implements whatever custom logic you have along with interacting with `_mock_state`, `_mock_timers`, and `_mock_reminders` as normal, and then applying that custom class as a mixin via a `create_mock_actor` function you define yourself.* + +The actor `_runtime_ctx` variable is set to None. All the normal actor methods have been overwritten such as to not call it, but if your code itself interacts directly with `_runtime_ctx`, tests may fail. + +The actor _state_manager is overwritten with an instance of `MockStateManager`. This has all the same methods and functionality of the base `ActorStateManager`, except for using the various `_mock` variables for storing data instead of the `_runtime_ctx`. If your code implements its own custom state manager it will be overwritten and tests will likely fail. + +### Type Hinting + +Because of Python's lack of a unified method for type hinting type intersections (see: [python/typing #213](https://github.com/python/typing/issues/213)), type hinting unfortunately doesn't work with Mock Actors. The return type is type hinted as "instance of Actor subclass T" when it should really be type hinted as "instance of MockActor subclass T" or "instance of type intersection `[Actor subclass T, MockActor]`" (where, it is worth noting, `MockActor` is itself a subclass of `Actor`). + +This means that, for example, if you hover over `mockactor._state_manager` in a code editor, it will come up as an instance of ActorStateManager (instead of MockStateManager), and various IDE helper functions (like VSCode's `Go to Definition`, which will bring you to the definition of ActorStateManager instead of MockStateManager) won't work properly. + +For now, this issue is unfixable, so it's merely something to be noted because of the confusion it might cause. If in the future it becomes possible to accurately type hint cases like this feel free to open an issue about implementing it. \ No newline at end of file diff --git a/docs/clients/clients.grpc.rst b/docs/clients/clients.grpc.rst new file mode 100644 index 00000000..472cbce6 --- /dev/null +++ b/docs/clients/clients.grpc.rst @@ -0,0 +1,31 @@ +clients.grpc package +==================== + +Submodules +---------- + + +.. automodule:: clients.grpc.client + :members: + :undoc-members: + :show-inheritance: + + +.. automodule:: clients.grpc.interceptors + :members: + :undoc-members: + :show-inheritance: + + +.. automodule:: clients.grpc.subscription + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: clients.grpc + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/proto/proto.runtime.rst b/docs/proto/proto.runtime.rst new file mode 100644 index 00000000..ccad378d --- /dev/null +++ b/docs/proto/proto.runtime.rst @@ -0,0 +1,18 @@ +proto.runtime package +===================== + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + proto.runtime.v1 + +Module contents +--------------- + +.. automodule:: proto.runtime + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/proto/proto.runtime.v1.rst b/docs/proto/proto.runtime.v1.rst new file mode 100644 index 00000000..3d4fb150 --- /dev/null +++ b/docs/proto/proto.runtime.v1.rst @@ -0,0 +1,37 @@ +proto.runtime.v1 package +======================== + +Submodules +---------- + + +.. automodule:: proto.runtime.v1.appcallback_pb2 + :members: + :undoc-members: + :show-inheritance: + + +.. automodule:: proto.runtime.v1.appcallback_pb2_grpc + :members: + :undoc-members: + :show-inheritance: + + +.. automodule:: proto.runtime.v1.dapr_pb2 + :members: + :undoc-members: + :show-inheritance: + + +.. automodule:: proto.runtime.v1.dapr_pb2_grpc + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: proto.runtime.v1 + :members: + :undoc-members: + :show-inheritance: diff --git a/examples/demo_actor/README.md b/examples/demo_actor/README.md index c5ece71e..64ca7ca5 100644 --- a/examples/demo_actor/README.md +++ b/examples/demo_actor/README.md @@ -6,6 +6,7 @@ This document describes how to create an Actor(DemoActor) and invoke its methods - **The actor service(demo_actor_service.py).** This implements FastAPI service that is going to host the actor. It contains the implementation of the actor, `demo_actor.py`. An actor implementation is a class that derives from the base type `Actor` and implements the interfaces defined in `demo_actor_interface.py`. - **The actor service for flask(demo_actor_flask.py).** This implements Flask web service that is going to host the actor. - **The actor client(demo_actor_client.py)** This contains the implementation of the actor client which calls DemoActor's method defined in Actor Interfaces. +- **Actor tests(test_demo_actor.py)** This contains actor unit tests using mock actor testing functionality. ## Pre-requisites @@ -183,3 +184,57 @@ expected_stdout_lines: kubectl logs -l app="demoactor-client" -c demoactor-client ``` +## Run DemoActor mock actor tests + + + +1. Run Tests + + ```bash + cd demo_actor + python -m unittest test_demo_actor.py + ``` + + Expected output (note that the unit test print outputs might not necessarily be in this order - what really matters is that all tests pass anyway): + + ``` + set_my_data: {'state': 5} + has_value: True + clear_my_data + has_value: False + has_value: False + set reminder to True + set reminder is done + set reminder to False + set reminder is done + set_my_data: {'state': 5} + has_value: True + ---------------------------------------------------------------------- + Ran 5 tests in 0.052s + + OK + ``` + + + diff --git a/examples/demo_actor/demo_actor/test_demo_actor.py b/examples/demo_actor/demo_actor/test_demo_actor.py new file mode 100644 index 00000000..e4289dda --- /dev/null +++ b/examples/demo_actor/demo_actor/test_demo_actor.py @@ -0,0 +1,40 @@ +import unittest + +from demo_actor import DemoActor + +from dapr.actor.runtime.mock_actor import create_mock_actor + + +class DemoActorTests(unittest.IsolatedAsyncioTestCase): + def test_create_actor(self): + mockactor = create_mock_actor(DemoActor, '1') + self.assertEqual(mockactor.id.id, '1') + + async def test_get_data(self): + mockactor = create_mock_actor(DemoActor, '1') + self.assertFalse(mockactor._state_manager._mock_state) # type: ignore + val = await mockactor.get_my_data() + self.assertIsNone(val) + + async def test_set_data(self): + mockactor = create_mock_actor(DemoActor, '1') + await mockactor.set_my_data({'state': 5}) + val = await mockactor.get_my_data() + self.assertIs(val['state'], 5) # type: ignore + + async def test_clear_data(self): + mockactor = create_mock_actor(DemoActor, '1') + await mockactor.set_my_data({'state': 5}) + val = await mockactor.get_my_data() + self.assertIs(val['state'], 5) # type: ignore + await mockactor.clear_my_data() + val = await mockactor.get_my_data() + self.assertIsNone(val) + + async def test_reminder(self): + mockactor = create_mock_actor(DemoActor, '1') + self.assertFalse(mockactor._state_manager._mock_reminders) # type: ignore + await mockactor.set_reminder(True) + self.assertTrue('demo_reminder' in mockactor._state_manager._mock_reminders) # type: ignore + await mockactor.set_reminder(False) + self.assertFalse(mockactor._state_manager._mock_reminders) # type: ignore diff --git a/tests/actor/test_mock_actor.py b/tests/actor/test_mock_actor.py new file mode 100644 index 00000000..c37cdf4f --- /dev/null +++ b/tests/actor/test_mock_actor.py @@ -0,0 +1,314 @@ +import datetime +import unittest +from typing import Optional + +from dapr.actor import Actor, ActorInterface, Remindable, actormethod +from dapr.actor.runtime.mock_actor import create_mock_actor +from dapr.actor.runtime.state_change import StateChangeKind + + +class MockTestActorInterface(ActorInterface): + @actormethod(name='GetData') + async def get_data(self) -> object: + ... + + @actormethod(name='SetData') + async def set_data(self, data: object) -> None: + ... + + @actormethod(name='ClearData') + async def clear_data(self) -> None: + ... + + @actormethod(name='TestData') + async def test_data(self) -> int: + ... + + @actormethod(name='AddState') + async def add_state(self, name: str, data: object) -> None: + ... + + @actormethod(name='UpdateState') + async def update_state(self, name: str, data: object) -> None: + ... + + @actormethod(name='AddDataNoSave') + async def add_data_no_save(self, data: object) -> None: + ... + + @actormethod(name='RemoveDataNoSave') + async def remove_data_no_save(self) -> None: + ... + + @actormethod(name='SaveState') + async def save_state(self) -> None: + ... + + @actormethod(name='ToggleReminder') + async def toggle_reminder(self, name: str, enabled: bool) -> None: + ... + + @actormethod(name='ToggleTimer') + async def toggle_timer(self, name: str, enabled: bool) -> None: + ... + + +class MockTestActor(Actor, MockTestActorInterface, Remindable): + def __init__(self, ctx, actor_id): + super().__init__(ctx, actor_id) + + async def _on_activate(self) -> None: + await self._state_manager.set_state('state', {'test': 5}) + await self._state_manager.save_state() + + async def get_data(self) -> object: + _, val = await self._state_manager.try_get_state('state') + return val + + async def set_data(self, data) -> None: + await self._state_manager.set_state('state', data) + await self._state_manager.save_state() + + async def clear_data(self) -> None: + await self._state_manager.remove_state('state') + await self._state_manager.save_state() + + async def test_data(self) -> int: + _, val = await self._state_manager.try_get_state('state') + if val is None: + return 0 + if 'test' not in val: + return 1 + if val['test'] % 2 == 1: + return 2 + elif val['test'] % 2 == 0: + return 3 + return 4 + + async def add_state(self, name: str, data: object) -> None: + await self._state_manager.add_state(name, data) + + async def update_state(self, name: str, data: object) -> None: + def double(_: str, x: int) -> int: + return 2 * x + + await self._state_manager.add_or_update_state(name, data, double) + + async def add_data_no_save(self, data: object) -> None: + await self._state_manager.set_state('state', data) + + async def remove_data_no_save(self) -> None: + await self._state_manager.remove_state('state') + + async def save_state(self) -> None: + await self._state_manager.save_state() + + async def toggle_reminder(self, name: str, enabled: bool) -> None: + if enabled: + await self.register_reminder( + name, + b'reminder_state', + datetime.timedelta(seconds=5), + datetime.timedelta(seconds=10), + datetime.timedelta(seconds=15), + ) + else: + await self.unregister_reminder(name) + + async def toggle_timer(self, name: str, enabled: bool) -> None: + if enabled: + await self.register_timer( + name, + self.timer_callback, + 'timer_state', + datetime.timedelta(seconds=5), + datetime.timedelta(seconds=10), + datetime.timedelta(seconds=15), + ) + else: + await self.unregister_timer(name) + + async def receive_reminder( + self, + name: str, + state: bytes, + due_time: datetime.timedelta, + period: datetime.timedelta, + ttl: Optional[datetime.timedelta] = None, + ) -> None: + await self._state_manager.set_state(name, True) + await self._state_manager.save_state() + + async def timer_callback(self, state) -> None: + print('Timer triggered') + + +class ActorMockActorTests(unittest.IsolatedAsyncioTestCase): + def test_create_actor(self): + mockactor = create_mock_actor(MockTestActor, '1') + self.assertEqual(mockactor.id.id, '1') + + async def test_inistate(self): + mockactor = create_mock_actor(MockTestActor, '1', initstate={'state': 5}) + self.assertTrue('state' in mockactor._state_manager._mock_state) # type: ignore + self.assertEqual(mockactor._state_manager._mock_state['state'], 5) # type: ignore + + async def test_on_activate(self): + mockactor = create_mock_actor(MockTestActor, '1') + await mockactor._on_activate() + self.assertTrue('state' in mockactor._state_manager._mock_state) # type: ignore + self.assertEqual(mockactor._state_manager._mock_state['state'], {'test': 5}) # type: ignore + + async def test_get_data(self): + mockactor = create_mock_actor(MockTestActor, '1') + await mockactor._on_activate() + out1 = await mockactor.get_data() + self.assertEqual(out1, {'test': 5}) + + async def test_get_data_initstate(self): + mockactor = create_mock_actor(MockTestActor, '1', initstate={'state': {'test': 6}}) + out1 = await mockactor.get_data() + self.assertEqual(out1, {'test': 6}) + + async def test_set_data(self): + mockactor = create_mock_actor(MockTestActor, '1') + await mockactor._on_activate() + self.assertTrue('state' in mockactor._state_manager._mock_state) # type: ignore + self.assertEqual(mockactor._state_manager._mock_state['state'], {'test': 5}) # type: ignore + await mockactor.set_data({'test': 10}) + self.assertTrue('state' in mockactor._state_manager._mock_state) # type: ignore + self.assertEqual(mockactor._state_manager._mock_state['state'], {'test': 10}) # type: ignore + out1 = await mockactor.get_data() + self.assertEqual(out1, {'test': 10}) + + async def test_clear_data(self): + mockactor = create_mock_actor(MockTestActor, '1') + await mockactor._on_activate() + self.assertTrue('state' in mockactor._state_manager._mock_state) # type: ignore + self.assertEqual(mockactor._state_manager._mock_state['state'], {'test': 5}) # type: ignore + await mockactor.clear_data() + self.assertFalse('state' in mockactor._state_manager._mock_state) # type: ignore + self.assertIsNone(mockactor._state_manager._mock_state.get('state')) # type: ignore + out1 = await mockactor.get_data() + self.assertIsNone(out1) + + async def test_toggle_reminder(self): + mockactor = create_mock_actor(MockTestActor, '1') + await mockactor._on_activate() + self.assertEqual(len(mockactor._state_manager._mock_reminders), 0) # type: ignore + await mockactor.toggle_reminder('test', True) + self.assertEqual(len(mockactor._state_manager._mock_reminders), 1) # type: ignore + self.assertTrue('test' in mockactor._state_manager._mock_reminders) # type: ignore + reminderstate = mockactor._state_manager._mock_reminders['test'] # type: ignore + self.assertTrue(reminderstate.reminder_name, 'test') + await mockactor.toggle_reminder('test', False) + self.assertEqual(len(mockactor._state_manager._mock_reminders), 0) # type: ignore + + async def test_toggle_timer(self): + mockactor = create_mock_actor(MockTestActor, '1') + await mockactor._on_activate() + self.assertEqual(len(mockactor._state_manager._mock_timers), 0) # type: ignore + await mockactor.toggle_timer('test', True) + self.assertEqual(len(mockactor._state_manager._mock_timers), 1) # type: ignore + self.assertTrue('test' in mockactor._state_manager._mock_timers) # type: ignore + timerstate = mockactor._state_manager._mock_timers['test'] # type: ignore + self.assertTrue(timerstate.timer_name, 'test') + await mockactor.toggle_timer('test', False) + self.assertEqual(len(mockactor._state_manager._mock_timers), 0) # type: ignore + + async def test_activate_reminder(self): + mockactor = create_mock_actor(MockTestActor, '1') + await mockactor.receive_reminder( + 'test', + b'test1', + datetime.timedelta(days=1), + datetime.timedelta(days=1), + datetime.timedelta(days=1), + ) + self.assertEqual(mockactor._state_manager._mock_state['test'], True) # type: ignore + + async def test_test_data(self): + mockactor = create_mock_actor(MockTestActor, '1') + result = await mockactor.test_data() + self.assertEqual(result, 0) + await mockactor.set_data('lol') + result = await mockactor.test_data() + self.assertEqual(result, 1) + await mockactor.set_data({'test': 'lol'}) + with self.assertRaises(TypeError): + await mockactor.test_data() + await mockactor.set_data({'test': 1}) + result = await mockactor.test_data() + self.assertEqual(result, 2) + await mockactor.set_data({'test': 2}) + result = await mockactor.test_data() + self.assertEqual(result, 3) + + async def test_add_state(self): + mockactor = create_mock_actor(MockTestActor, '1') + self.assertFalse(mockactor._state_manager._mock_state) # type: ignore + await mockactor.add_state('test', 5) + self.assertTrue('test' in mockactor._state_manager._mock_state) # type: ignore + self.assertEqual(mockactor._state_manager._mock_state['test'], 5) # type: ignore + await mockactor.add_state('test2', 10) + self.assertTrue('test2' in mockactor._state_manager._mock_state) # type: ignore + self.assertEqual(mockactor._state_manager._mock_state['test2'], 10) # type: ignore + self.assertEqual(len(mockactor._state_manager._mock_state), 2) # type: ignore + with self.assertRaises(ValueError): + await mockactor.add_state('test', 10) + + async def test_update_state(self): + mockactor = create_mock_actor(MockTestActor, '1') + self.assertFalse(mockactor._state_manager._mock_state) # type: ignore + await mockactor.update_state('test', 10) + self.assertTrue('test' in mockactor._state_manager._mock_state) # type: ignore + self.assertEqual(mockactor._state_manager._mock_state['test'], 10) # type: ignore + await mockactor.update_state('test', 10) + self.assertTrue('test' in mockactor._state_manager._mock_state) # type: ignore + self.assertEqual(mockactor._state_manager._mock_state['test'], 20) # type: ignore + self.assertEqual(len(mockactor._state_manager._mock_state), 1) # type: ignore + + async def test_state_change_tracker(self): + mockactor = create_mock_actor(MockTestActor, '1') + self.assertEqual(len(mockactor._state_manager._default_state_change_tracker), 0) + await mockactor._on_activate() + self.assertEqual(len(mockactor._state_manager._default_state_change_tracker), 1) + self.assertTrue('state' in mockactor._state_manager._default_state_change_tracker) + self.assertEqual( + mockactor._state_manager._default_state_change_tracker['state'].change_kind, + StateChangeKind.none, + ) + self.assertEqual( + mockactor._state_manager._default_state_change_tracker['state'].value, {'test': 5} + ) + self.assertEqual(mockactor._state_manager._mock_state['state'], {'test': 5}) # type: ignore + await mockactor.remove_data_no_save() + self.assertEqual( + mockactor._state_manager._default_state_change_tracker['state'].change_kind, + StateChangeKind.remove, + ) + self.assertEqual( + mockactor._state_manager._default_state_change_tracker['state'].value, {'test': 5} + ) + self.assertTrue('state' not in mockactor._state_manager._mock_state) # type: ignore + await mockactor.save_state() + self.assertEqual(len(mockactor._state_manager._default_state_change_tracker), 0) + self.assertTrue('state' not in mockactor._state_manager._mock_state) # type: ignore + await mockactor.add_data_no_save({'test': 6}) + self.assertEqual( + mockactor._state_manager._default_state_change_tracker['state'].change_kind, + StateChangeKind.add, + ) + self.assertEqual( + mockactor._state_manager._default_state_change_tracker['state'].value, {'test': 6} + ) + self.assertEqual(mockactor._state_manager._mock_state['state'], {'test': 6}) # type: ignore + await mockactor.save_state() + self.assertEqual( + mockactor._state_manager._default_state_change_tracker['state'].change_kind, + StateChangeKind.none, + ) + self.assertEqual( + mockactor._state_manager._default_state_change_tracker['state'].value, {'test': 6} + ) + self.assertEqual(mockactor._state_manager._mock_state['state'], {'test': 6}) # type: ignore diff --git a/tests/actor/test_mock_state_manager.py b/tests/actor/test_mock_state_manager.py new file mode 100644 index 00000000..81af9307 --- /dev/null +++ b/tests/actor/test_mock_state_manager.py @@ -0,0 +1,73 @@ +import unittest + +from dapr.actor import Actor, ActorInterface +from dapr.actor.runtime.mock_actor import create_mock_actor +from dapr.actor.runtime.mock_state_manager import MockStateManager + + +def double(_: str, x: int) -> int: + return 2 * x + + +class MockTestActorInterface(ActorInterface): + pass + + +class MockTestActor(Actor, MockTestActorInterface): + def __init__(self, ctx, actor_id): + super().__init__(ctx, actor_id) + + +class ActorMockActorTests(unittest.IsolatedAsyncioTestCase): + def test_init_state(self): + mock_actor = create_mock_actor(MockTestActor, 'test') + state_manager = mock_actor._state_manager + self.assertIsInstance(state_manager, MockStateManager) + self.assertFalse(state_manager._mock_state) # type: ignore + self.assertFalse(state_manager._mock_reminders) # type: ignore + self.assertFalse(state_manager._mock_timers) # type: ignore + + async def test_state_methods(self): + mock_actor = create_mock_actor(MockTestActor, 'test') + state_manager = mock_actor._state_manager + self.assertFalse(await state_manager.contains_state('state')) + self.assertFalse(state_manager._default_state_change_tracker) + names = await state_manager.get_state_names() + self.assertFalse(names) + with self.assertRaises(KeyError): + await state_manager.get_state('state') + await state_manager.add_state('state', 5) + names = await state_manager.get_state_names() + self.assertCountEqual(names, ['state']) + self.assertIs(state_manager._mock_state['state'], 5) # type: ignore + value = await state_manager.get_state('state') + self.assertIs(value, 5) + await state_manager.add_state('state2', 5) + self.assertIs(state_manager._mock_state['state2'], 5) # type: ignore + with self.assertRaises(ValueError): + await state_manager.add_state('state', 5) + await state_manager.set_state('state3', 5) + self.assertIs(state_manager._mock_state['state3'], 5) # type: ignore + await state_manager.set_state('state3', 10) + self.assertIs(state_manager._mock_state['state3'], 10) # type: ignore + self.assertTrue(await state_manager.contains_state('state3')) + await state_manager.remove_state('state3') + self.assertFalse('state3' in state_manager._mock_state) # type: ignore + with self.assertRaises(KeyError): + await state_manager.remove_state('state3') + self.assertFalse(await state_manager.contains_state('state3')) + await state_manager.add_or_update_state('state3', 5, double) + self.assertIs(state_manager._mock_state['state3'], 5) # type: ignore + await state_manager.add_or_update_state('state3', 1000, double) + self.assertIs(state_manager._mock_state['state3'], 10) # type: ignore + out = await state_manager.get_or_add_state('state4', 5) + self.assertIs(out, 5) + self.assertIs(state_manager._mock_state['state4'], 5) # type: ignore + out = await state_manager.get_or_add_state('state4', 10) + self.assertIs(out, 5) + self.assertIs(state_manager._mock_state['state4'], 5) # type: ignore + names = await state_manager.get_state_names() + self.assertCountEqual(names, ['state', 'state2', 'state3', 'state4']) + self.assertTrue('state', state_manager._default_state_change_tracker) + await state_manager.clear_cache() + self.assertFalse(state_manager._default_state_change_tracker)