Durable Entities (#184)
Co-authored-by: Wenonah Zhang <wenhzha@microsoft.com> Co-authored-by: wenhzha <53274673+wenhzha@users.noreply.github.com>
This commit is contained in:
Родитель
d95c56dae2
Коммит
46a050508f
|
@ -3,14 +3,20 @@
|
|||
Exposes the different API components intended for public consumption
|
||||
"""
|
||||
from .orchestrator import Orchestrator
|
||||
from .entity import Entity
|
||||
from .models.utils.entity_utils import EntityId
|
||||
from .models.DurableOrchestrationClient import DurableOrchestrationClient
|
||||
from .models.DurableOrchestrationContext import DurableOrchestrationContext
|
||||
from .models.DurableEntityContext import DurableEntityContext
|
||||
from .models.RetryOptions import RetryOptions
|
||||
from .models.TokenSource import ManagedIdentityTokenSource
|
||||
|
||||
__all__ = [
|
||||
'Orchestrator',
|
||||
'Entity',
|
||||
'EntityId',
|
||||
'DurableOrchestrationClient',
|
||||
'DurableEntityContext',
|
||||
'DurableOrchestrationContext',
|
||||
'ManagedIdentityTokenSource',
|
||||
'RetryOptions'
|
||||
|
|
|
@ -0,0 +1,119 @@
|
|||
from .models import DurableEntityContext
|
||||
from .models.entities import OperationResult, EntityState
|
||||
from datetime import datetime
|
||||
from typing import Callable, Any, List, Dict
|
||||
|
||||
class InternalEntityException(Exception):
|
||||
pass
|
||||
|
||||
class Entity:
|
||||
"""Durable Entity Class.
|
||||
|
||||
Responsible for executing the user-defined entity function.
|
||||
"""
|
||||
|
||||
def __init__(self, entity_func: Callable[[DurableEntityContext], None]):
|
||||
"""Create a new entity for the user-defined entity.
|
||||
|
||||
Responsible for executing the user-defined entity function
|
||||
|
||||
Parameters
|
||||
----------
|
||||
entity_func: Callable[[DurableEntityContext], Generator[Any, Any, Any]]
|
||||
The user defined entity function
|
||||
"""
|
||||
self.fn: Callable[[DurableEntityContext], None] = entity_func
|
||||
|
||||
def handle(self, context: DurableEntityContext, batch: List[Dict[str, Any]]) -> str:
|
||||
"""Handle the execution of the user-defined entity function.
|
||||
|
||||
Loops over the batch, which serves to specify inputs to the entity,
|
||||
and collects results and generates a final state, which are returned.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
context: DurableEntityContext
|
||||
The entity context of the entity, which the user interacts with as their Durable API
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
A JSON-formatted string representing the output state, results, and exceptions for the
|
||||
entity execution.
|
||||
"""
|
||||
response = EntityState(results=[], signals=[])
|
||||
for operation_data in batch:
|
||||
result: Any = None
|
||||
is_error: bool = False
|
||||
start_time: datetime = datetime.now()
|
||||
|
||||
try:
|
||||
# populate context
|
||||
operation = operation_data["name"]
|
||||
if operation is None:
|
||||
raise InternalEntityException("Durable Functions Internal Error: Entity operation was missing a name field")
|
||||
context._operation = operation
|
||||
context._input = operation_data["input"]
|
||||
self.fn(context)
|
||||
result = context._result
|
||||
|
||||
except InternalEntityException as e:
|
||||
raise e
|
||||
|
||||
except Exception as e:
|
||||
is_error = True
|
||||
result = str(e)
|
||||
|
||||
duration: int = self._elapsed_milliseconds_since(start_time)
|
||||
operation_result = OperationResult(
|
||||
is_error=is_error,
|
||||
duration=duration,
|
||||
result=result
|
||||
)
|
||||
response.results.append(operation_result)
|
||||
|
||||
response.state = context._state
|
||||
response.entity_exists = context._exists
|
||||
return response.to_json_string()
|
||||
|
||||
@classmethod
|
||||
def create(cls, fn: Callable[[DurableEntityContext], None]) -> Callable[[Any], str]:
|
||||
"""Create an instance of the entity class.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
fn (Callable[[DurableEntityContext], None]): [description]
|
||||
|
||||
Returns
|
||||
-------
|
||||
Callable[[Any], str]
|
||||
Handle function of the newly created entity client
|
||||
"""
|
||||
def handle(context) -> str:
|
||||
# It is not clear when the context JSON would be found
|
||||
# inside a "body"-key, but this pattern matches the
|
||||
# orchestrator implementation, so we keep it for safety.
|
||||
context_body = getattr(context, "body", None)
|
||||
if context_body is None:
|
||||
context_body = context
|
||||
ctx, batch = DurableEntityContext.from_json(context_body)
|
||||
return Entity(fn).handle(ctx, batch)
|
||||
return handle
|
||||
|
||||
def _elapsed_milliseconds_since(self, start_time: datetime) -> int:
|
||||
"""Calculate the elapsed time, in milliseconds, from the start_time to the present.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
start_time: datetime
|
||||
The timestamp of when the entity began processing a batched request.
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
The time, in millseconds, from start_time to now
|
||||
"""
|
||||
end_time = datetime.now()
|
||||
time_diff = end_time - start_time
|
||||
elapsed_time = int(time_diff.total_seconds() * 1000)
|
||||
return elapsed_time
|
|
@ -0,0 +1,200 @@
|
|||
from typing import Optional, Any, Dict, Tuple, List, Callable
|
||||
from azure.functions._durable_functions import _deserialize_custom_object
|
||||
import json
|
||||
|
||||
|
||||
class DurableEntityContext:
|
||||
"""Context of the durable entity context.
|
||||
|
||||
Describes the API used to specify durable entity user code.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
name: str,
|
||||
key: str,
|
||||
exists: bool,
|
||||
state: Any):
|
||||
"""Context of the durable entity context.
|
||||
|
||||
Describes the API used to specify durable entity user code.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name: str
|
||||
The name of the Durable Entity
|
||||
key: str
|
||||
The key of the Durable Entity
|
||||
exists: bool
|
||||
Flag to determine if the entity exists
|
||||
state: Any
|
||||
The internal state of the Durable Entity
|
||||
"""
|
||||
self._entity_name: str = name
|
||||
self._entity_key: str = key
|
||||
|
||||
self._exists: bool = exists
|
||||
self._is_newly_constructed: bool = False
|
||||
|
||||
self._state: Any = state
|
||||
self._input: Any = None
|
||||
self._operation: Optional[str] = None
|
||||
self._result: Any = None
|
||||
|
||||
@property
|
||||
def entity_name(self) -> str:
|
||||
"""Get the name of the Entity.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The name of the entity
|
||||
"""
|
||||
return self._entity_name
|
||||
|
||||
@property
|
||||
def entity_key(self) -> str:
|
||||
"""Get the Entity key.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The entity key
|
||||
"""
|
||||
return self._entity_key
|
||||
|
||||
@property
|
||||
def operation_name(self) -> Optional[str]:
|
||||
"""Get the current operation name.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Optional[str]
|
||||
The current operation name
|
||||
"""
|
||||
if self._operation is None:
|
||||
raise Exception("Entity operation is unassigned")
|
||||
return self._operation
|
||||
|
||||
@property
|
||||
def is_newly_constructed(self) -> bool:
|
||||
"""Determine if the Entity was newly constructed.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
True if the Entity was newly constructed. False otherwise.
|
||||
"""
|
||||
# This is not updated at the moment, as its semantics are unclear
|
||||
return self._is_newly_constructed
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_str: str) -> Tuple['DurableEntityContext', List[Dict[str, Any]]]:
|
||||
"""Instantiate a DurableEntityContext from a JSON-formatted string.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
json_string: str
|
||||
A JSON-formatted string, returned by the durable-extension,
|
||||
which represents the entity context
|
||||
|
||||
Returns
|
||||
-------
|
||||
DurableEntityContext
|
||||
The DurableEntityContext originated from the input string
|
||||
"""
|
||||
json_dict = json.loads(json_str)
|
||||
json_dict["name"] = json_dict["self"]["name"]
|
||||
json_dict["key"] = json_dict["self"]["key"]
|
||||
json_dict.pop("self")
|
||||
|
||||
serialized_state = json_dict["state"]
|
||||
if serialized_state is not None:
|
||||
json_dict["state"] = from_json_util(serialized_state)
|
||||
|
||||
batch = json_dict.pop("batch")
|
||||
return cls(**json_dict), batch
|
||||
|
||||
def set_state(self, state: Any) -> None:
|
||||
"""Set the state of the entity.
|
||||
|
||||
Parameter
|
||||
---------
|
||||
state: Any
|
||||
The new state of the entity
|
||||
"""
|
||||
self._exists = True
|
||||
|
||||
# should only serialize the state at the end of the batch
|
||||
self._state = state
|
||||
|
||||
def get_state(self, initializer: Optional[Callable[[], Any]] = None) -> Any:
|
||||
"""Get the current state of this entity.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
initializer: Optional[Callable[[], Any]]
|
||||
A 0-argument function to provide an initial state. Defaults to None.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Any
|
||||
The current state of the entity
|
||||
"""
|
||||
state = self._state
|
||||
if state is not None:
|
||||
return state
|
||||
elif initializer:
|
||||
if not callable(initializer):
|
||||
raise Exception("initializer argument needs to be a callable function")
|
||||
state = initializer()
|
||||
return state
|
||||
|
||||
def get_input(self) -> Any:
|
||||
"""Get the input for this operation.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Any
|
||||
The input for the current operation
|
||||
"""
|
||||
input_ = None
|
||||
req_input = self._input
|
||||
req_input = json.loads(req_input)
|
||||
input_ = None if req_input is None else from_json_util(req_input)
|
||||
return input_
|
||||
|
||||
def set_result(self, result: Any) -> None:
|
||||
"""Set the result (return value) of the entity.
|
||||
|
||||
Paramaters
|
||||
----------
|
||||
result: Any
|
||||
The result / return value for the entity
|
||||
"""
|
||||
self._exists = True
|
||||
self._result = result
|
||||
|
||||
def destruct_on_exit(self) -> None:
|
||||
"""Delete this entity after the operation completes."""
|
||||
self._exists = False
|
||||
self._state = None
|
||||
|
||||
def from_json_util(self, json_str: str) -> Any:
|
||||
"""Load an arbitrary datatype from its JSON representation.
|
||||
|
||||
The Out-of-proc SDK has a special JSON encoding strategy
|
||||
to enable arbitrary datatypes to be serialized. This utility
|
||||
loads a JSON with the assumption that it follows that encoding
|
||||
method.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
json_str: str
|
||||
A JSON-formatted string, from durable-extension
|
||||
|
||||
Returns
|
||||
-------
|
||||
Any:
|
||||
The original datatype that was serialized
|
||||
"""
|
||||
return json.loads(json_str, object_hook=_deserialize_custom_object)
|
|
@ -13,6 +13,7 @@ from .RpcManagementOptions import RpcManagementOptions
|
|||
from .OrchestrationRuntimeStatus import OrchestrationRuntimeStatus
|
||||
from ..models.DurableOrchestrationBindings import DurableOrchestrationBindings
|
||||
from .utils.http_utils import get_async_request, post_async_request, delete_async_request
|
||||
from .utils.entity_utils import EntityId
|
||||
from azure.functions._durable_functions import _serialize_custom_object
|
||||
|
||||
|
||||
|
@ -353,7 +354,6 @@ class DurableOrchestrationClient:
|
|||
PurgeHistoryResult
|
||||
The results of the request to purge history
|
||||
"""
|
||||
# TODO: do we really want folks to us this without specifying all the args?
|
||||
options = RpcManagementOptions(created_time_from=created_time_from,
|
||||
created_time_to=created_time_to,
|
||||
runtime_status=runtime_status)
|
||||
|
@ -457,6 +457,57 @@ class DurableOrchestrationClient:
|
|||
else:
|
||||
return self.create_check_status_response(request, instance_id)
|
||||
|
||||
async def signal_entity(self, entityId: EntityId, operation_name: str,
|
||||
operation_input: Optional[Any] = None,
|
||||
task_hub_name: Optional[str] = None,
|
||||
connection_name: Optional[str] = None) -> None:
|
||||
"""Signals an entity to perform an operation.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
entityId : EntityId
|
||||
The EntityId of the targeted entity to perform operation.
|
||||
operation_name: str
|
||||
The name of the operation.
|
||||
operation_input: Optional[Any]
|
||||
The content for the operation.
|
||||
task_hub_name: Optional[str]
|
||||
The task hub name of the target entity.
|
||||
connection_name: Optional[str]
|
||||
The name of the connection string associated with [task_hub_name].
|
||||
|
||||
Raises
|
||||
------
|
||||
Exception:
|
||||
When the signal entity call failed with an unexpected status code
|
||||
|
||||
Returns
|
||||
-------
|
||||
None
|
||||
"""
|
||||
options = RpcManagementOptions(operation_name=operation_name,
|
||||
connection_name=connection_name,
|
||||
task_hub_name=task_hub_name,
|
||||
entity_Id=entityId)
|
||||
|
||||
request_url = options.to_url(self._orchestration_bindings.rpc_base_url)
|
||||
response = await self._post_async_request(
|
||||
request_url,
|
||||
json.dumps(operation_input) if operation_input else None)
|
||||
|
||||
switch_statement = {
|
||||
202: lambda: None # signal accepted
|
||||
}
|
||||
|
||||
has_error_message = switch_statement.get(
|
||||
response[0],
|
||||
lambda: f"The operation failed with an unexpected status code {response[0]}")
|
||||
|
||||
error_message = has_error_message()
|
||||
|
||||
if error_message:
|
||||
raise Exception(error_message)
|
||||
|
||||
@staticmethod
|
||||
def _create_http_response(
|
||||
status_code: int, body: Union[str, Any]) -> func.HttpResponse:
|
||||
|
|
|
@ -9,9 +9,11 @@ from .history import HistoryEvent, HistoryEventType
|
|||
from .actions import Action
|
||||
from ..models.Task import Task
|
||||
from ..models.TokenSource import TokenSource
|
||||
from .utils.entity_utils import EntityId
|
||||
from ..tasks import call_activity_task, task_all, task_any, call_activity_with_retry_task, \
|
||||
wait_for_external_event_task, continue_as_new, new_uuid, call_http, create_timer_task, \
|
||||
call_sub_orchestrator_task, call_sub_orchestrator_with_retry_task
|
||||
call_sub_orchestrator_task, call_sub_orchestrator_with_retry_task, call_entity_task, \
|
||||
signal_entity_task
|
||||
from azure.functions._durable_functions import _deserialize_custom_object
|
||||
|
||||
|
||||
|
@ -34,7 +36,6 @@ class DurableOrchestrationContext:
|
|||
self._new_uuid_counter: int = 0
|
||||
self._sub_orchestrator_counter: int = 0
|
||||
self._continue_as_new_flag: bool = False
|
||||
# TODO: waiting on the `continue_as_new` intellisense until that's implemented
|
||||
self.decision_started_event: HistoryEvent = \
|
||||
[e_ for e_ in self.histories
|
||||
if e_.event_type == HistoryEventType.ORCHESTRATOR_STARTED][0]
|
||||
|
@ -359,6 +360,46 @@ class DurableOrchestrationContext:
|
|||
"""
|
||||
return self._function_context
|
||||
|
||||
def call_entity(self, entityId: EntityId,
|
||||
operationName: str, operationInput: Optional[Any] = None):
|
||||
"""Get the result of Durable Entity operation given some input.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
entityId: EntityId
|
||||
The ID of the entity to call
|
||||
operationName: str
|
||||
The operation to execute
|
||||
operationInput: Optional[Any]
|
||||
The input for tne operation, defaults to None.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Task
|
||||
A Task of the entity call
|
||||
"""
|
||||
return call_entity_task(self.histories, entityId, operationName, operationInput)
|
||||
|
||||
def signal_entity(self, entityId: EntityId,
|
||||
operationName: str, operationInput: Optional[Any] = None):
|
||||
"""Send a signal operation to Durable Entity given some input.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
entityId: EntityId
|
||||
The ID of the entity to call
|
||||
operationName: str
|
||||
The operation to execute
|
||||
operationInput: Optional[Any]
|
||||
The input for tne operation, defaults to None.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Task
|
||||
A Task of the entity signal
|
||||
"""
|
||||
return signal_entity_task(self, self.histories, entityId, operationName, operationInput)
|
||||
|
||||
@property
|
||||
def will_continue_as_new(self) -> bool:
|
||||
"""Return true if continue_as_new was called."""
|
||||
|
|
|
@ -4,6 +4,8 @@ from typing import Any, List, Optional
|
|||
from azure.durable_functions.constants import DATETIME_STRING_FORMAT
|
||||
from azure.durable_functions.models.OrchestrationRuntimeStatus import OrchestrationRuntimeStatus
|
||||
|
||||
from .utils.entity_utils import EntityId
|
||||
|
||||
|
||||
class RpcManagementOptions:
|
||||
"""Class used to collect the options for getting orchestration status."""
|
||||
|
@ -12,7 +14,9 @@ class RpcManagementOptions:
|
|||
connection_name: str = None, show_history: bool = None,
|
||||
show_history_output: bool = None, created_time_from: datetime = None,
|
||||
created_time_to: datetime = None,
|
||||
runtime_status: List[OrchestrationRuntimeStatus] = None, show_input: bool = None):
|
||||
runtime_status: List[OrchestrationRuntimeStatus] = None, show_input: bool = None,
|
||||
operation_name: str = None,
|
||||
entity_Id: EntityId = None):
|
||||
self._instance_id = instance_id
|
||||
self._task_hub_name = task_hub_name
|
||||
self._connection_name = connection_name
|
||||
|
@ -22,6 +26,8 @@ class RpcManagementOptions:
|
|||
self._created_time_to = created_time_to
|
||||
self._runtime_status = runtime_status
|
||||
self._show_input = show_input
|
||||
self.operation_name = operation_name
|
||||
self.entity_Id = entity_Id
|
||||
|
||||
@staticmethod
|
||||
def _add_arg(query: List[str], name: str, value: Any):
|
||||
|
@ -55,7 +61,10 @@ class RpcManagementOptions:
|
|||
if base_url is None:
|
||||
raise ValueError("orchestration bindings has not RPC base url")
|
||||
|
||||
url = f"{base_url}instances/{self._instance_id if self._instance_id else ''}"
|
||||
if self.entity_Id:
|
||||
url = f'{base_url}{EntityId.get_entity_id_url_path(self.entity_Id)}'
|
||||
else:
|
||||
url = f"{base_url}instances/{self._instance_id if self._instance_id else ''}"
|
||||
|
||||
query: List[str] = []
|
||||
|
||||
|
@ -66,6 +75,7 @@ class RpcManagementOptions:
|
|||
self._add_arg(query, 'showHistoryOutput', self._show_history_output)
|
||||
self._add_date_arg(query, 'createdTimeFrom', self._created_time_from)
|
||||
self._add_date_arg(query, 'createdTimeTo', self._created_time_to)
|
||||
self._add_arg(query, 'op', self.operation_name)
|
||||
if self._runtime_status is not None and len(self._runtime_status) > 0:
|
||||
runtime_status = ",".join(r.value for r in self._runtime_status)
|
||||
self._add_arg(query, 'runtimeStatus', runtime_status)
|
||||
|
|
|
@ -10,10 +10,12 @@ from .Task import Task
|
|||
from .TaskSet import TaskSet
|
||||
from .DurableHttpRequest import DurableHttpRequest
|
||||
from .TokenSource import ManagedIdentityTokenSource
|
||||
from .DurableEntityContext import DurableEntityContext
|
||||
|
||||
__all__ = [
|
||||
'DurableOrchestrationBindings',
|
||||
'DurableOrchestrationClient',
|
||||
'DurableEntityContext',
|
||||
'DurableOrchestrationContext',
|
||||
'DurableHttpRequest',
|
||||
'ManagedIdentityTokenSource',
|
||||
|
|
|
@ -11,4 +11,6 @@ class ActionType(IntEnum):
|
|||
CONTINUE_AS_NEW: int = 4
|
||||
CREATE_TIMER: int = 5
|
||||
WAIT_FOR_EXTERNAL_EVENT: int = 6
|
||||
CALL_ENTITY = 7
|
||||
CALL_HTTP: int = 8
|
||||
SIGNAL_ENTITY: int = 9
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
from typing import Any, Dict
|
||||
|
||||
from .Action import Action
|
||||
from .ActionType import ActionType
|
||||
from ..utils.json_utils import add_attrib
|
||||
from json import dumps
|
||||
from azure.functions._durable_functions import _serialize_custom_object
|
||||
from ..utils.entity_utils import EntityId
|
||||
|
||||
|
||||
class CallEntityAction(Action):
|
||||
"""Defines the structure of the Call Entity object.
|
||||
|
||||
Provides the information needed by the durable extension to be able to call an activity
|
||||
"""
|
||||
|
||||
def __init__(self, entity_id: EntityId, operation: str, input_=None):
|
||||
self.entity_id: EntityId = entity_id
|
||||
|
||||
# Validating that EntityId exists before trying to parse its instanceId
|
||||
if not self.entity_id:
|
||||
raise ValueError("entity_id cannot be empty")
|
||||
|
||||
self.instance_id: str = EntityId.get_scheduler_id(entity_id)
|
||||
self.operation: str = operation
|
||||
self.input_: str = dumps(input_, default=_serialize_custom_object)
|
||||
|
||||
@property
|
||||
def action_type(self) -> int:
|
||||
"""Get the type of action this class represents."""
|
||||
return ActionType.CALL_ENTITY
|
||||
|
||||
def to_json(self) -> Dict[str, Any]:
|
||||
"""Convert object into a json dictionary.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Dict[str, Any]
|
||||
The instance of the class converted into a json dictionary
|
||||
"""
|
||||
json_dict: Dict[str, Any] = {}
|
||||
add_attrib(json_dict, self, "action_type", "actionType")
|
||||
add_attrib(json_dict, self, 'instance_id', 'instanceId')
|
||||
add_attrib(json_dict, self, 'operation', 'operation')
|
||||
add_attrib(json_dict, self, 'input_', 'input')
|
||||
return json_dict
|
|
@ -0,0 +1,47 @@
|
|||
from typing import Any, Dict
|
||||
|
||||
from .Action import Action
|
||||
from .ActionType import ActionType
|
||||
from ..utils.json_utils import add_attrib
|
||||
from json import dumps
|
||||
from azure.functions._durable_functions import _serialize_custom_object
|
||||
from ..utils.entity_utils import EntityId
|
||||
|
||||
|
||||
class SignalEntityAction(Action):
|
||||
"""Defines the structure of the Signal Entity object.
|
||||
|
||||
Provides the information needed by the durable extension to be able to signal an entity
|
||||
"""
|
||||
|
||||
def __init__(self, entity_id: EntityId, operation: str, input_=None):
|
||||
self.entity_id: EntityId = entity_id
|
||||
|
||||
# Validating that EntityId exists before trying to parse its instanceId
|
||||
if not self.entity_id:
|
||||
raise ValueError("entity_id cannot be empty")
|
||||
|
||||
self.instance_id: str = EntityId.get_scheduler_id(entity_id)
|
||||
self.operation: str = operation
|
||||
self.input_: str = dumps(input_, default=_serialize_custom_object)
|
||||
|
||||
@property
|
||||
def action_type(self) -> int:
|
||||
"""Get the type of action this class represents."""
|
||||
return ActionType.SIGNAL_ENTITY
|
||||
|
||||
def to_json(self) -> Dict[str, Any]:
|
||||
"""Convert object into a json dictionary.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Dict[str, Any]
|
||||
The instance of the class converted into a json dictionary
|
||||
"""
|
||||
json_dict: Dict[str, Any] = {}
|
||||
add_attrib(json_dict, self, "action_type", "actionType")
|
||||
add_attrib(json_dict, self, 'instance_id', 'instanceId')
|
||||
add_attrib(json_dict, self, 'operation', 'operation')
|
||||
add_attrib(json_dict, self, 'input_', 'input')
|
||||
|
||||
return json_dict
|
|
@ -0,0 +1,74 @@
|
|||
from typing import List, Optional, Dict, Any
|
||||
from .Signal import Signal
|
||||
from azure.functions._durable_functions import _serialize_custom_object
|
||||
from .OperationResult import OperationResult
|
||||
import json
|
||||
|
||||
|
||||
class EntityState:
|
||||
"""Entity State.
|
||||
|
||||
Used to communicate the state of the entity back to the durable extension
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
results: List[OperationResult],
|
||||
signals: List[Signal],
|
||||
entity_exists: bool = False,
|
||||
state: Optional[str] = None):
|
||||
self.entity_exists = entity_exists
|
||||
self.state = state
|
||||
self._results = results
|
||||
self._signals = signals
|
||||
|
||||
@property
|
||||
def results(self) -> List[OperationResult]:
|
||||
"""Get list of results of the entity.
|
||||
|
||||
Returns
|
||||
-------
|
||||
List[OperationResult]:
|
||||
The results of the entity
|
||||
"""
|
||||
return self._results
|
||||
|
||||
@property
|
||||
def signals(self) -> List[Signal]:
|
||||
"""Get list of signals to the entity.
|
||||
|
||||
Returns
|
||||
-------
|
||||
List[Signal]:
|
||||
The signals of the entity
|
||||
"""
|
||||
return self._signals
|
||||
|
||||
def to_json(self) -> Dict[str, Any]:
|
||||
"""Convert object into a json dictionary.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Dict[str, Any]
|
||||
The instance of the class converted into a json dictionary
|
||||
"""
|
||||
json_dict: Dict[str, Any] = {}
|
||||
# Serialize the OperationResult list
|
||||
serialized_results = list(map(lambda x: x.to_json(), self.results))
|
||||
|
||||
json_dict["entityExists"] = self.entity_exists
|
||||
json_dict["entityState"] = json.dumps(self.state, default=_serialize_custom_object)
|
||||
json_dict["results"] = serialized_results
|
||||
json_dict["signals"] = self.signals
|
||||
return json_dict
|
||||
|
||||
def to_json_string(self) -> str:
|
||||
"""Convert object into a json string.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The instance of the object in json string format
|
||||
"""
|
||||
# TODO: Same implementation as in Orchestrator.py, we should refactor to shared a base
|
||||
json_dict = self.to_json()
|
||||
return json.dumps(json_dict)
|
|
@ -0,0 +1,76 @@
|
|||
from typing import Optional, Dict, Any
|
||||
from azure.functions._durable_functions import _serialize_custom_object
|
||||
import json
|
||||
|
||||
|
||||
class OperationResult:
|
||||
"""OperationResult.
|
||||
|
||||
The result of an Entity operation.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
is_error: bool,
|
||||
duration: int,
|
||||
result: Optional[str] = None):
|
||||
"""Instantiate an OperationResult.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
is_error: bool
|
||||
Whether or not the operation resulted in an exception.
|
||||
duration: int
|
||||
How long the operation took, in milliseconds.
|
||||
result: Optional[str]
|
||||
The operation result. Defaults to None.
|
||||
"""
|
||||
self._is_error: bool = is_error
|
||||
self._duration: int = duration
|
||||
self._result: Optional[str] = result
|
||||
|
||||
@property
|
||||
def is_error(self) -> bool:
|
||||
"""Determine if the operation resulted in an error.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
True if the operation resulted in error. Otherwise False.
|
||||
"""
|
||||
return self._is_error
|
||||
|
||||
@property
|
||||
def duration(self) -> int:
|
||||
"""Get the duration of this operation.
|
||||
|
||||
Returns
|
||||
-------
|
||||
int:
|
||||
The duration of this operation, in milliseconds
|
||||
"""
|
||||
return self._duration
|
||||
|
||||
@property
|
||||
def result(self) -> Any:
|
||||
"""Get the operation's result.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Any
|
||||
The operation's result
|
||||
"""
|
||||
return self._result
|
||||
|
||||
def to_json(self) -> Dict[str, Any]:
|
||||
"""Represent OperationResult as a JSON-serializable Dict.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Dict[str, Any]
|
||||
A JSON-serializable Dict of the OperationResult
|
||||
"""
|
||||
to_json: Dict[str, Any] = {}
|
||||
to_json["isError"] = self.is_error
|
||||
to_json["duration"] = self.duration
|
||||
to_json["result"] = json.dumps(self.result, default=_serialize_custom_object)
|
||||
return to_json
|
|
@ -0,0 +1,53 @@
|
|||
from typing import List, Optional, Any
|
||||
from ..utils.entity_utils import EntityId
|
||||
import json
|
||||
|
||||
|
||||
class RequestMessage:
|
||||
"""RequestMessage.
|
||||
|
||||
Specifies a request to an entity.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
id_: str,
|
||||
name: Optional[str] = None,
|
||||
signal: Optional[bool] = None,
|
||||
input_: Optional[str] = None,
|
||||
arg: Optional[Any] = None,
|
||||
parent: Optional[str] = None,
|
||||
lockset: Optional[List[EntityId]] = None,
|
||||
pos: Optional[int] = None,
|
||||
**kwargs):
|
||||
# TODO: this class has too many optionals, may speak to
|
||||
# over-caution, but it mimics the JS class. Investigate if
|
||||
# these many Optionals are necessary.
|
||||
self.id = id_
|
||||
self.name = name
|
||||
self.signal = signal
|
||||
self.input = input_
|
||||
self.arg = arg
|
||||
self.parent = parent
|
||||
self.lockset = lockset
|
||||
self.pos = pos
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_str: str) -> 'RequestMessage':
|
||||
"""Instantiate a RequestMessage object from the durable-extension provided JSON data.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
json_str: str
|
||||
A durable-extension provided json-formatted string representation of
|
||||
a RequestMessage
|
||||
|
||||
Returns
|
||||
-------
|
||||
RequestMessage:
|
||||
A RequestMessage object from the json_str parameter
|
||||
"""
|
||||
# We replace the `id` key for `id_` to avoid clashes with reserved
|
||||
# identifiers in Python
|
||||
json_dict = json.loads(json_str)
|
||||
json_dict["id_"] = json_dict.pop("id")
|
||||
return cls(**json_dict)
|
|
@ -0,0 +1,38 @@
|
|||
from typing import Dict, Any
|
||||
|
||||
|
||||
class ResponseMessage:
|
||||
"""ResponseMessage.
|
||||
|
||||
Specifies the response of an entity, as processed by the durable-extension.
|
||||
"""
|
||||
|
||||
def __init__(self, result: str):
|
||||
"""Instantiate a ResponseMessage.
|
||||
|
||||
Specifies the response of an entity, as processed by the durable-extension.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
result: str
|
||||
The result provided by the entity
|
||||
"""
|
||||
self.result = result
|
||||
# TODO: JS has an additional exceptionType field, but does not use it
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: Dict[str, Any]) -> 'ResponseMessage':
|
||||
"""Instantiate a ResponseMessage from a dict of the JSON-response by the extension.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
d: Dict[str, Any]
|
||||
The dictionary parsed from the JSON-response by the durable-extension
|
||||
|
||||
Returns
|
||||
-------
|
||||
ResponseMessage:
|
||||
The ResponseMessage built from the provided dictionary
|
||||
"""
|
||||
result = cls(d["result"])
|
||||
return result
|
|
@ -0,0 +1,62 @@
|
|||
from ..utils.entity_utils import EntityId
|
||||
|
||||
|
||||
class Signal:
|
||||
"""An EntitySignal.
|
||||
|
||||
Describes a signal call to a Durable Entity.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
target: EntityId,
|
||||
name: str,
|
||||
input_: str):
|
||||
"""Instantiate an EntitySignal.
|
||||
|
||||
Instantiate a signal call to a Durable Entity.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
target: EntityId
|
||||
The target of signal
|
||||
name: str
|
||||
The name of the signal
|
||||
input_: str
|
||||
The signal's input
|
||||
"""
|
||||
self._target = target
|
||||
self._name = name
|
||||
self._input = input_
|
||||
|
||||
@property
|
||||
def target(self) -> EntityId:
|
||||
"""Get the Signal's target entity.
|
||||
|
||||
Returns
|
||||
-------
|
||||
EntityId
|
||||
EntityId of the target
|
||||
"""
|
||||
return self._target
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Get the Signal's name.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The Signal's name
|
||||
"""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def input(self) -> str:
|
||||
"""Get the Signal's input.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The Signal's input
|
||||
"""
|
||||
return self._input
|
|
@ -0,0 +1,17 @@
|
|||
"""Utility classes used by the Durable Function python library for dealing with entities.
|
||||
|
||||
_Internal Only_
|
||||
"""
|
||||
|
||||
from .RequestMessage import RequestMessage
|
||||
from .OperationResult import OperationResult
|
||||
from .EntityState import EntityState
|
||||
from .Signal import Signal
|
||||
|
||||
|
||||
__all__ = [
|
||||
'RequestMessage',
|
||||
'OperationResult',
|
||||
'Signal',
|
||||
'EntityState'
|
||||
]
|
|
@ -0,0 +1,91 @@
|
|||
class EntityId:
|
||||
"""EntityId.
|
||||
|
||||
It identifies an entity by its name and its key.
|
||||
"""
|
||||
|
||||
def __init__(self, name: str, key: str):
|
||||
"""Instantiate an EntityId object.
|
||||
|
||||
Identifies an entity by its name and its key.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name: str
|
||||
The entity name
|
||||
key: str
|
||||
The entity key
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError: If the entity name or key are the empty string
|
||||
"""
|
||||
if name == "":
|
||||
raise ValueError("Entity name cannot be empty")
|
||||
if key == "":
|
||||
raise ValueError("Entity key cannot be empty")
|
||||
self.name: str = name
|
||||
self.key: str = key
|
||||
|
||||
@staticmethod
|
||||
def get_scheduler_id(entity_id: 'EntityId') -> str:
|
||||
"""Produce a SchedulerId from an EntityId.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
entity_id: EntityId
|
||||
An EntityId object
|
||||
|
||||
Returns
|
||||
-------
|
||||
str:
|
||||
A SchedulerId representation of the input EntityId
|
||||
"""
|
||||
return f"@{entity_id.name.lower()}@{entity_id.key}"
|
||||
|
||||
@staticmethod
|
||||
def get_entity_id(scheduler_id: str) -> 'EntityId':
|
||||
"""Return an EntityId from a SchedulerId string.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
scheduler_id: str
|
||||
The SchedulerId in which to base the returned EntityId
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError:
|
||||
When the SchedulerId string does not have the expected format
|
||||
|
||||
Returns
|
||||
-------
|
||||
EntityId:
|
||||
An EntityId object based on the SchedulerId string
|
||||
"""
|
||||
sched_id_truncated = scheduler_id[1:] # we drop the starting `@`
|
||||
components = sched_id_truncated.split("@")
|
||||
if len(components) != 2:
|
||||
raise ValueError("Unexpected format in SchedulerId")
|
||||
[name, key] = components
|
||||
return EntityId(name, key)
|
||||
|
||||
@staticmethod
|
||||
def get_entity_id_url_path(entity_id: 'EntityId') -> str:
|
||||
"""Print the the entity url path.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str:
|
||||
A url path of the EntityId
|
||||
"""
|
||||
return f'entities/{entity_id.name}/{entity_id.key}'
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Print the string representation of this EntityId.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str:
|
||||
A SchedulerId-based string representation of the EntityId
|
||||
"""
|
||||
return EntityId.get_scheduler_id(entity_id=self)
|
|
@ -11,12 +11,16 @@ from .continue_as_new import continue_as_new
|
|||
from .new_uuid import new_uuid
|
||||
from .call_http import call_http
|
||||
from .create_timer import create_timer_task
|
||||
from .call_entity import call_entity_task
|
||||
from .signal_entity import signal_entity_task
|
||||
|
||||
__all__ = [
|
||||
'call_activity_task',
|
||||
'call_activity_with_retry_task',
|
||||
'call_sub_orchestrator_task',
|
||||
'call_sub_orchestrator_with_retry_task',
|
||||
'call_entity_task',
|
||||
'signal_entity_task',
|
||||
'call_http',
|
||||
'continue_as_new',
|
||||
'new_uuid',
|
||||
|
|
|
@ -0,0 +1,83 @@
|
|||
from typing import List, Any, Optional
|
||||
|
||||
from ..models.Task import (
|
||||
Task)
|
||||
from ..models.actions.CallEntityAction import CallEntityAction
|
||||
from ..models.history import HistoryEvent, HistoryEventType
|
||||
from .task_utilities import set_processed, parse_history_event, find_event
|
||||
from ..models.utils.entity_utils import EntityId
|
||||
from ..models.entities.RequestMessage import RequestMessage
|
||||
from ..models.entities.ResponseMessage import ResponseMessage
|
||||
import json
|
||||
|
||||
|
||||
def call_entity_task(
|
||||
state: List[HistoryEvent],
|
||||
entity_id: EntityId,
|
||||
operation_name: str = "",
|
||||
input_: Optional[Any] = None):
|
||||
"""Determine the status of a call-entity task.
|
||||
|
||||
It the task hasn't been scheduled, it returns a Task to schedule. If the task completed,
|
||||
we return a completed Task, to process its result.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
state: List[HistoryEvent]
|
||||
The list of history events to search over to determine the
|
||||
current state of the callEntity Task.
|
||||
entity_id: EntityId
|
||||
An identifier for the entity to call.
|
||||
operation_name: str
|
||||
The name of the operation the entity needs to execute.
|
||||
input_: Any
|
||||
The JSON-serializable input to pass to the activity function.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Task
|
||||
A Durable Task that completes when the called entity completes or fails.
|
||||
"""
|
||||
new_action = CallEntityAction(entity_id, operation_name, input_)
|
||||
scheduler_id = EntityId.get_scheduler_id(entity_id=entity_id)
|
||||
|
||||
hist_type = HistoryEventType.EVENT_SENT
|
||||
extra_constraints = {
|
||||
"InstanceId": scheduler_id,
|
||||
"Name": "op"
|
||||
}
|
||||
event_sent = find_event(state, hist_type, extra_constraints)
|
||||
|
||||
event_raised = None
|
||||
if event_sent:
|
||||
event_input = None
|
||||
if hasattr(event_sent, "Input"):
|
||||
event_input = RequestMessage.from_json(event_sent.Input)
|
||||
hist_type = HistoryEventType.EVENT_RAISED
|
||||
extra_constraints = {
|
||||
"Name": event_input.id
|
||||
}
|
||||
event_raised = find_event(state, hist_type, extra_constraints)
|
||||
# TODO: does it make sense to have an event_sent but no `Input` attribute ?
|
||||
# If not, we should raise an exception here
|
||||
|
||||
set_processed([event_sent, event_raised])
|
||||
if event_raised is not None:
|
||||
response = parse_history_event(event_raised)
|
||||
response = ResponseMessage.from_dict(response)
|
||||
|
||||
# TODO: json.loads inside parse_history_event is not recursive
|
||||
# investigate if response.result is used elsewhere,
|
||||
# which probably requires another deserialization
|
||||
result = json.loads(response.result)
|
||||
|
||||
return Task(
|
||||
is_completed=True,
|
||||
is_faulted=False,
|
||||
action=new_action,
|
||||
result=result,
|
||||
timestamp=event_raised.timestamp,
|
||||
id_=event_raised.Name) # event_raised.TaskScheduledId
|
||||
|
||||
# TODO: this may be missing exception handling, as is JS
|
||||
return Task(is_completed=False, is_faulted=False, action=new_action)
|
|
@ -0,0 +1,45 @@
|
|||
from typing import List, Any, Optional
|
||||
from ..models.actions.SignalEntityAction import SignalEntityAction
|
||||
from ..models.history import HistoryEvent, HistoryEventType
|
||||
from .task_utilities import set_processed, find_event
|
||||
from ..models.utils.entity_utils import EntityId
|
||||
|
||||
|
||||
def signal_entity_task(
|
||||
context,
|
||||
state: List[HistoryEvent],
|
||||
entity_id: EntityId,
|
||||
operation_name: str = "",
|
||||
input_: Optional[Any] = None):
|
||||
"""Signal a entity operation.
|
||||
|
||||
It the action hasn't been scheduled, it appends the action.
|
||||
If the action has been scheduled, no ops.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
state: List[HistoryEvent]
|
||||
The list of history events to search over to determine the
|
||||
current state of the callEntity Task.
|
||||
entity_id: EntityId
|
||||
An identifier for the entity to call.
|
||||
operation_name: str
|
||||
The name of the operation the entity needs to execute.
|
||||
input_: Any
|
||||
The JSON-serializable input to pass to the activity function.
|
||||
"""
|
||||
new_action = SignalEntityAction(entity_id, operation_name, input_)
|
||||
scheduler_id = EntityId.get_scheduler_id(entity_id=entity_id)
|
||||
|
||||
hist_type = HistoryEventType.EVENT_SENT
|
||||
extra_constraints = {
|
||||
"InstanceId": scheduler_id,
|
||||
"Name": "op"
|
||||
}
|
||||
|
||||
event_sent = find_event(state, hist_type, extra_constraints)
|
||||
set_processed([event_sent])
|
||||
context.actions.append([new_action])
|
||||
|
||||
if event_sent:
|
||||
return
|
|
@ -1,8 +1,8 @@
|
|||
import json
|
||||
from ..models.history import HistoryEventType, HistoryEvent
|
||||
from azure.functions._durable_functions import _deserialize_custom_object
|
||||
from dateutil import parser
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Dict, Any
|
||||
from ..models.actions.Action import Action
|
||||
from ..models.Task import Task
|
||||
|
||||
|
@ -22,15 +22,65 @@ def parse_history_event(directive_result):
|
|||
|
||||
# We provide the ability to deserialize custom objects, because the output of this
|
||||
# will be passed directly to the orchestrator as the output of some activity
|
||||
if event_type == HistoryEventType.EVENT_RAISED:
|
||||
return json.loads(directive_result.Input, object_hook=_deserialize_custom_object)
|
||||
if event_type == HistoryEventType.SUB_ORCHESTRATION_INSTANCE_COMPLETED:
|
||||
return json.loads(directive_result.Result, object_hook=_deserialize_custom_object)
|
||||
if event_type == HistoryEventType.TASK_COMPLETED:
|
||||
return json.loads(directive_result.Result, object_hook=_deserialize_custom_object)
|
||||
if event_type == HistoryEventType.EVENT_RAISED:
|
||||
# TODO: Investigate why the payload is in "Input" instead of "Result"
|
||||
return json.loads(directive_result.Input, object_hook=_deserialize_custom_object)
|
||||
return None
|
||||
|
||||
|
||||
def find_event(state: List[HistoryEvent], event_type: HistoryEventType,
|
||||
extra_constraints: Dict[str, Any]) -> Optional[HistoryEvent]:
|
||||
"""Find event in the histories array as per some constraints.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
state: List[HistoryEvent]
|
||||
The list of events so far in the orchestaration
|
||||
event_type: HistoryEventType
|
||||
The type of the event we're looking for
|
||||
extra_constraints: Dict[str, Any]
|
||||
A dictionary of key-value pairs where the key is a property of the
|
||||
sought-after event, and value are its expected contents.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Optional[HistoryEvent]
|
||||
The event being searched-for, if found. Else, None.
|
||||
"""
|
||||
def satisfies_contraints(e: HistoryEvent) -> bool:
|
||||
"""Determine if an event matches our search criteria.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
e: HistoryEvent
|
||||
An event from the state array
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
True if the event matches our constraints. Else, False.
|
||||
"""
|
||||
for attr, val in extra_constraints.items():
|
||||
if hasattr(e, attr) and getattr(e, attr) == val:
|
||||
continue
|
||||
else:
|
||||
return False
|
||||
return True
|
||||
|
||||
tasks = [e for e in state
|
||||
if e.event_type == event_type
|
||||
and satisfies_contraints(e) and not e.is_processed]
|
||||
|
||||
if len(tasks) == 0:
|
||||
return None
|
||||
|
||||
return tasks[0]
|
||||
|
||||
|
||||
def find_event_raised(state, name):
|
||||
"""Find if the event with the given event name is raised.
|
||||
|
||||
|
@ -251,8 +301,9 @@ def find_sub_orchestration(
|
|||
err = "Tried to lookup suborchestration in history but had not name to reference it."
|
||||
raise ValueError(err)
|
||||
|
||||
# TODO: The HistoryEvent does not necessarily have an name or an instance_id
|
||||
# We should create sub-classes of these types like JS does
|
||||
# TODO: The HistoryEvent does not necessarily have a name or an instance_id
|
||||
# We should create sub-classes of these types like JS does, to ensure their
|
||||
# precense.
|
||||
err_message: str = ""
|
||||
if not(event.Name == name):
|
||||
mid_message = "a function name of {} instead of the provided function name of {}."
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
venv
|
|
@ -0,0 +1,132 @@
|
|||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
pip-wheel-metadata/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don’t work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# celery beat schedule file
|
||||
celerybeat-schedule
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# Azure Functions artifacts
|
||||
bin
|
||||
obj
|
||||
appsettings.json
|
||||
.python_packages
|
||||
|
||||
# pycharm
|
||||
.idea
|
|
@ -0,0 +1,35 @@
|
|||
import logging
|
||||
import json
|
||||
|
||||
import azure.functions as func
|
||||
import azure.durable_functions as df
|
||||
|
||||
|
||||
def entity_function(context: df.DurableEntityContext):
|
||||
"""A Counter Durable Entity.
|
||||
|
||||
A simple example of a Durable Entity that implements
|
||||
a simple counter.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
context (df.DurableEntityContext):
|
||||
The Durable Entity context, which exports an API
|
||||
for implementing durable entities.
|
||||
"""
|
||||
|
||||
current_value = context.get_state(lambda: 0)
|
||||
operation = context.operation_name
|
||||
if operation == "add":
|
||||
amount = context.get_input()
|
||||
current_value += amount
|
||||
elif operation == "reset":
|
||||
current_value = 0
|
||||
elif operation == "get":
|
||||
pass
|
||||
|
||||
context.set_state(current_value)
|
||||
context.set_result(current_value)
|
||||
|
||||
|
||||
main = df.Entity.create(entity_function)
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"scriptFile": "__init__.py",
|
||||
"bindings": [
|
||||
{
|
||||
"name": "context",
|
||||
"type": "entityTrigger",
|
||||
"direction": "in"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
# This function is not intended to be invoked directly. Instead it will be
|
||||
# triggered by an HTTP starter function.
|
||||
# Before running this sample, please:
|
||||
# - create a Durable activity function (default name is "Hello")
|
||||
# - create a Durable HTTP starter function
|
||||
# - add azure-functions-durable to requirements.txt
|
||||
# - run pip install -r requirements.txt
|
||||
|
||||
import logging
|
||||
import json
|
||||
|
||||
import azure.functions as func
|
||||
import azure.durable_functions as df
|
||||
|
||||
|
||||
def orchestrator_function(context: df.DurableOrchestrationContext):
|
||||
"""This function provides the a simple implementation of an orchestrator
|
||||
that signals and then calls a counter Durable Entity.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
context: DurableOrchestrationContext
|
||||
This context has the past history and the durable orchestration API
|
||||
|
||||
Returns
|
||||
-------
|
||||
state
|
||||
The state after applying the operation on the Durable Entity
|
||||
"""
|
||||
entityId = df.EntityId("Counter", "myCounter")
|
||||
context.signal_entity(entityId, "add", 3)
|
||||
state = yield context.call_entity(entityId, "get")
|
||||
return state
|
||||
|
||||
main = df.Orchestrator.create(orchestrator_function)
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"scriptFile": "__init__.py",
|
||||
"bindings": [
|
||||
{
|
||||
"name": "context",
|
||||
"type": "orchestrationTrigger",
|
||||
"direction": "in"
|
||||
}
|
||||
],
|
||||
"disabled": false
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
import logging
|
||||
|
||||
from azure.durable_functions import DurableOrchestrationClient
|
||||
import azure.functions as func
|
||||
|
||||
|
||||
async def main(req: func.HttpRequest, starter: str, message):
|
||||
"""This function starts up the orchestrator from an HTTP endpoint
|
||||
|
||||
starter: str
|
||||
A JSON-formatted string describing the orchestration context
|
||||
|
||||
message:
|
||||
An azure functions http output binding, it enables us to establish
|
||||
an http response.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
req: func.HttpRequest
|
||||
An HTTP Request object, it can be used to parse URL
|
||||
parameters.
|
||||
"""
|
||||
|
||||
|
||||
function_name = req.route_params.get('functionName')
|
||||
logging.info(starter)
|
||||
client = DurableOrchestrationClient(starter)
|
||||
instance_id = await client.start_new(function_name)
|
||||
response = client.create_check_status_response(req, instance_id)
|
||||
message.set(response)
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"scriptFile": "__init__.py",
|
||||
"bindings": [
|
||||
{
|
||||
"authLevel": "anonymous",
|
||||
"name": "req",
|
||||
"type": "httpTrigger",
|
||||
"direction": "in",
|
||||
"route": "orchestrators/{functionName}",
|
||||
"methods": [
|
||||
"post",
|
||||
"get"
|
||||
]
|
||||
},
|
||||
{
|
||||
"direction": "out",
|
||||
"name": "message",
|
||||
"type": "http"
|
||||
},
|
||||
{
|
||||
"name": "starter",
|
||||
"type": "durableClient",
|
||||
"direction": "in",
|
||||
"datatype": "string"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
# Durable Entities - Sample
|
||||
|
||||
This sample exemplifies how to go about using the [Durable Entities](https://docs.microsoft.com/en-us/azure/azure-functions/durable/durable-functions-entities?tabs=csharp) construct in Python Durable Functions.
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
### Create a `local.settings.json` file in this directory
|
||||
This file stores app settings, connection strings, and other settings used by local development tools. Learn more about it [here](https://docs.microsoft.com/en-us/azure/azure-functions/functions-run-local?tabs=windows%2Ccsharp%2Cbash#local-settings-file).
|
||||
For this sample, you will only need an `AzureWebJobsStorage` connection string, which you can obtain from the Azure portal.
|
||||
|
||||
With you connection string, your `local.settings.json` file should look as follows, with `<your connection string>` replaced with the connection string you obtained from the Azure portal:
|
||||
|
||||
```json
|
||||
{
|
||||
"IsEncrypted": false,
|
||||
"Values": {
|
||||
"AzureWebJobsStorage": "<your connection string>",
|
||||
"FUNCTIONS_WORKER_RUNTIME": "python"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Run the Sample
|
||||
To try this sample, run `func host start` in this directory. If all the system requirements have been met, and
|
||||
after some initialization logs, you should see something like the following:
|
||||
|
||||
```bash
|
||||
Http Functions:
|
||||
|
||||
DurableTrigger: [POST,GET] http://localhost:7071/api/orchestrators/{functionName}
|
||||
```
|
||||
|
||||
This indicates that your `DurableTrigger` function can be reached via a `GET` or `POST` request to that URL. `DurableTrigger` starts the function-chaning orchestrator whose name is passed as a parameter to the URL. So, to start the orchestrator, which is named `DurableOrchestration`, make a GET request to `http://127.0.0.1:7071/api/orchestrators/DurableOrchestration`.
|
||||
|
||||
And that's it! You should see a JSON response with five URLs to monitor the status of the orchestration.
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"version": "2.0"
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"IsEncrypted": false,
|
||||
"Values": {
|
||||
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
|
||||
"FUNCTIONS_WORKER_RUNTIME": "python"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
azure-functions
|
||||
#azure-functions-durable>=1.0.0b6
|
|
@ -495,7 +495,7 @@ async def test_wait_or_response_check_status_response(binding_string):
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_or_response_check_status_response(binding_string):
|
||||
async def test_wait_or_response_null_request(binding_string):
|
||||
status = dict(createdTime=TEST_CREATED_TIME,
|
||||
lastUpdatedTime=TEST_LAST_UPDATED_TIME,
|
||||
runtimeStatus="Running")
|
||||
|
|
|
@ -1,19 +1,36 @@
|
|||
import json
|
||||
from typing import Callable, Iterator, Any, Dict
|
||||
from typing import Callable, Iterator, Any, Dict, List
|
||||
from jsonschema import validate
|
||||
|
||||
from azure.durable_functions.models import DurableOrchestrationContext
|
||||
from azure.durable_functions.models import DurableOrchestrationContext, DurableEntityContext
|
||||
from azure.durable_functions.orchestrator import Orchestrator
|
||||
from azure.durable_functions.entity import Entity
|
||||
from .schemas.OrchetrationStateSchema import schema
|
||||
|
||||
|
||||
def assert_orchestration_state_equals(expected, result):
|
||||
"""Ensure that the observable OrchestratorState matches the expected result.
|
||||
"""
|
||||
assert_attribute_equal(expected, result, "isDone")
|
||||
assert_actions_are_equal(expected, result)
|
||||
assert_attribute_equal(expected, result, "output")
|
||||
assert_attribute_equal(expected, result, "error")
|
||||
assert_attribute_equal(expected, result, "customStatus")
|
||||
|
||||
def assert_entity_state_equals(expected, result):
|
||||
"""Ensure the that the observable EntityState json matches the expected result.
|
||||
"""
|
||||
assert_attribute_equal(expected, result,"entityExists")
|
||||
assert "results" in result
|
||||
observed_results = result["results"]
|
||||
expected_results = expected["results"]
|
||||
assert_results_are_equal(expected_results, observed_results)
|
||||
assert_attribute_equal(expected, result, "entityState")
|
||||
assert_attribute_equal(expected, result, "signals")
|
||||
|
||||
def assert_results_are_equal(expected: Dict[str, Any], result: Dict[str, Any]) -> bool:
|
||||
assert_attribute_equal(expected, result, "result")
|
||||
assert_attribute_equal(expected, result, "isError")
|
||||
|
||||
def assert_attribute_equal(expected, result, attribute):
|
||||
if attribute in expected:
|
||||
|
@ -50,6 +67,33 @@ def get_orchestration_state_result(
|
|||
result = json.loads(result_of_handle)
|
||||
return result
|
||||
|
||||
def get_entity_state_result(
|
||||
context_builder: DurableEntityContext,
|
||||
user_code: Callable[[DurableEntityContext], Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""Simulate the result of running the entity function with the provided context and batch.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
context_builder: DurableEntityContext
|
||||
A mocked entity context
|
||||
user_code: Callable[[DurableEntityContext], Any]
|
||||
A function implementing an entity
|
||||
|
||||
Returns:
|
||||
-------
|
||||
Dict[str, Any]:
|
||||
JSON-response of the entity
|
||||
"""
|
||||
# The durable-extension automatically wraps the data within a 'self' key
|
||||
context_as_string = context_builder.to_json_string()
|
||||
entity = Entity(user_code)
|
||||
|
||||
context, batch = DurableEntityContext.from_json(context_as_string)
|
||||
result_of_handle = entity.handle(context, batch)
|
||||
result = json.loads(result_of_handle)
|
||||
return result
|
||||
|
||||
def get_orchestration_property(
|
||||
context_builder,
|
||||
activity_func: Callable[[DurableOrchestrationContext], Iterator[Any]],
|
||||
|
|
|
@ -0,0 +1,217 @@
|
|||
from .orchestrator_test_utils \
|
||||
import assert_orchestration_state_equals, get_orchestration_state_result, assert_valid_schema, \
|
||||
get_entity_state_result, assert_entity_state_equals
|
||||
from tests.test_utils.ContextBuilder import ContextBuilder
|
||||
from tests.test_utils.EntityContextBuilder import EntityContextBuilder
|
||||
from azure.durable_functions.models.OrchestratorState import OrchestratorState
|
||||
from azure.durable_functions.models.entities.EntityState import EntityState, OperationResult
|
||||
from azure.durable_functions.models.actions.CallEntityAction \
|
||||
import CallEntityAction
|
||||
from azure.durable_functions.models.actions.SignalEntityAction \
|
||||
import SignalEntityAction
|
||||
from tests.test_utils.testClasses import SerializableClass
|
||||
import azure.durable_functions as df
|
||||
from typing import Any, Dict, List
|
||||
import json
|
||||
|
||||
def generator_function_call_entity(context):
|
||||
outputs = []
|
||||
entityId = df.EntityId("Counter", "myCounter")
|
||||
x = yield context.call_entity(entityId, "add", 3)
|
||||
|
||||
outputs.append(x)
|
||||
return outputs
|
||||
|
||||
def generator_function_signal_entity(context):
|
||||
outputs = []
|
||||
entityId = df.EntityId("Counter", "myCounter")
|
||||
context.signal_entity(entityId, "add", 3)
|
||||
x = yield context.call_entity(entityId, "get")
|
||||
|
||||
outputs.append(x)
|
||||
return outputs
|
||||
|
||||
def counter_entity_function(context):
|
||||
"""A Counter Durable Entity.
|
||||
|
||||
A simple example of a Durable Entity that implements
|
||||
a simple counter.
|
||||
"""
|
||||
|
||||
current_value = context.get_state(lambda: 0)
|
||||
operation = context.operation_name
|
||||
if operation == "add":
|
||||
amount = context.get_input()
|
||||
current_value += amount
|
||||
elif operation == "reset":
|
||||
current_value = 0
|
||||
elif operation == "get":
|
||||
pass
|
||||
|
||||
result = f"The state is now: {current_value}"
|
||||
context.set_state(current_value)
|
||||
context.set_result(result)
|
||||
|
||||
|
||||
def test_entity_signal_then_call():
|
||||
"""Tests that a simple counter entity outputs the correct value
|
||||
after a sequence of operations. Mostly just a sanity check.
|
||||
"""
|
||||
|
||||
# Create input batch
|
||||
batch = []
|
||||
add_to_batch(batch, name="add", input_=3)
|
||||
add_to_batch(batch, name="get")
|
||||
context_builder = EntityContextBuilder(batch=batch)
|
||||
|
||||
# Run the entity, get observed result
|
||||
result = get_entity_state_result(
|
||||
context_builder,
|
||||
counter_entity_function,
|
||||
)
|
||||
|
||||
# Construct expected result
|
||||
expected_state = entity_base_expected_state()
|
||||
apply_operation(expected_state, result="The state is now: 3", state=3)
|
||||
expected = expected_state.to_json()
|
||||
|
||||
# Ensure expectation matches observed behavior
|
||||
#assert_valid_schema(result)
|
||||
assert_entity_state_equals(expected, result)
|
||||
|
||||
|
||||
def apply_operation(entity_state: EntityState, result: Any, state: Any, is_error: bool = False):
|
||||
"""Apply the effects of an operation to the expected entity state object
|
||||
|
||||
Parameters
|
||||
----------
|
||||
entity_state: EntityState
|
||||
The expected entity state object
|
||||
result: Any
|
||||
The result of the latest operation
|
||||
state: Any
|
||||
The state right after the latest operation
|
||||
is_error: bool
|
||||
Whether or not the operation resulted in an exception. Defaults to False
|
||||
"""
|
||||
entity_state.state = state
|
||||
|
||||
# We cannot control duration, so default it to zero and avoid checking for it
|
||||
# in later asserts
|
||||
duration = 0
|
||||
operation_result = OperationResult(
|
||||
is_error=is_error,
|
||||
duration=duration,
|
||||
result=result
|
||||
)
|
||||
entity_state._results.append(operation_result)
|
||||
|
||||
def add_to_batch(batch: List[Dict[str, Any]], name: str, input_: Any=None):
|
||||
"""Add new work item to the batch of entity operations.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
batch: List[Dict[str, Any]]
|
||||
Current list of json-serialized entity work items
|
||||
name: str
|
||||
Name of the entity operation to be performed
|
||||
input_: Optional[Any]:
|
||||
Input to the operation. Defaults to None.
|
||||
|
||||
Returns
|
||||
--------
|
||||
List[Dict[str, str]]:
|
||||
Batch of json-serialized entity work items
|
||||
"""
|
||||
# It is key to serialize the input twice, as this is
|
||||
# the extension behavior
|
||||
packet = {
|
||||
"name": name,
|
||||
"input": json.dumps(json.dumps(input_))
|
||||
}
|
||||
batch.append(packet)
|
||||
return batch
|
||||
|
||||
|
||||
def entity_base_expected_state() -> EntityState:
|
||||
"""Get a base entity state.
|
||||
|
||||
Returns
|
||||
-------
|
||||
EntityState:
|
||||
An EntityState with no results, no signals, a None state, and entity_exists set to True.
|
||||
"""
|
||||
return EntityState(results=[], signals=[], entity_exists=True, state=None)
|
||||
|
||||
def add_call_entity_action_for_entity(state: OrchestratorState, id_: df.EntityId, op: str, input_: Any):
|
||||
action = CallEntityAction(entity_id=id_, operation=op, input_=input_)
|
||||
state.actions.append([action])
|
||||
|
||||
|
||||
def base_expected_state(output=None) -> OrchestratorState:
|
||||
return OrchestratorState(is_done=False, actions=[], output=output)
|
||||
|
||||
def add_call_entity_action(state: OrchestratorState, id_: df.EntityId, op: str, input_: Any):
|
||||
action = CallEntityAction(entity_id=id_, operation=op, input_=input_)
|
||||
state.actions.append([action])
|
||||
|
||||
def add_signal_entity_action(state: OrchestratorState, id_: df.EntityId, op: str, input_: Any):
|
||||
action = SignalEntityAction(entity_id=id_, operation=op, input_=input_)
|
||||
state.actions.append([action])
|
||||
|
||||
def add_call_entity_completed_events(
|
||||
context_builder: ContextBuilder, op: str, instance_id=str, input_=None):
|
||||
context_builder.add_event_sent_event(instance_id)
|
||||
context_builder.add_orchestrator_completed_event()
|
||||
context_builder.add_orchestrator_started_event()
|
||||
context_builder.add_event_raised_event(name="0000", id_=0, input_=input_, is_entity=True)
|
||||
|
||||
def test_call_entity_sent():
|
||||
context_builder = ContextBuilder('test_simple_function')
|
||||
|
||||
entityId = df.EntityId("Counter", "myCounter")
|
||||
result = get_orchestration_state_result(
|
||||
context_builder, generator_function_call_entity)
|
||||
|
||||
expected_state = base_expected_state()
|
||||
add_call_entity_action(expected_state, entityId, "add", 3)
|
||||
expected = expected_state.to_json()
|
||||
|
||||
#assert_valid_schema(result)
|
||||
assert_orchestration_state_equals(expected, result)
|
||||
|
||||
def test_signal_entity_sent():
|
||||
context_builder = ContextBuilder('test_simple_function')
|
||||
|
||||
entityId = df.EntityId("Counter", "myCounter")
|
||||
result = get_orchestration_state_result(
|
||||
context_builder, generator_function_signal_entity)
|
||||
|
||||
expected_state = base_expected_state()
|
||||
add_signal_entity_action(expected_state, entityId, "add", 3)
|
||||
add_call_entity_action(expected_state, entityId, "get", None)
|
||||
expected = expected_state.to_json()
|
||||
|
||||
#assert_valid_schema(result)
|
||||
assert_orchestration_state_equals(expected, result)
|
||||
|
||||
|
||||
def test_call_entity_raised():
|
||||
entityId = df.EntityId("Counter", "myCounter")
|
||||
context_builder = ContextBuilder('test_simple_function')
|
||||
add_call_entity_completed_events(context_builder, "add", df.EntityId.get_scheduler_id(entityId), 3)
|
||||
|
||||
result = get_orchestration_state_result(
|
||||
context_builder, generator_function_call_entity)
|
||||
|
||||
expected_state = base_expected_state(
|
||||
[3]
|
||||
)
|
||||
|
||||
add_call_entity_action(expected_state, entityId, "add", 3)
|
||||
expected_state._is_done = True
|
||||
expected = expected_state.to_json()
|
||||
|
||||
#assert_valid_schema(result)
|
||||
|
||||
assert_orchestration_state_equals(expected, result)
|
|
@ -63,6 +63,13 @@ class ContextBuilder:
|
|||
event.TaskScheduledId = id_
|
||||
self.history_events.append(event)
|
||||
|
||||
def add_event_sent_event(self, instance_id):
|
||||
event = self.get_base_event(HistoryEventType.EVENT_SENT)
|
||||
event.InstanceId = instance_id
|
||||
event.Name = "op"
|
||||
event.Input = json.dumps({ "id": "0000" }) # usually provided by the extension
|
||||
self.history_events.append(event)
|
||||
|
||||
def add_task_scheduled_event(
|
||||
self, name: str, id_: int, version: str = '', input_=None):
|
||||
event = self.get_base_event(HistoryEventType.TASK_SCHEDULED, id_=id_)
|
||||
|
@ -109,10 +116,13 @@ class ContextBuilder:
|
|||
event.Input = input_
|
||||
self.history_events.append(event)
|
||||
|
||||
def add_event_raised_event(self, name: str, id_: int, input_=None, timestamp=None):
|
||||
def add_event_raised_event(self, name:str, id_: int, input_=None, timestamp=None, is_entity=False):
|
||||
event = self.get_base_event(HistoryEventType.EVENT_RAISED, id_=id_, timestamp=timestamp)
|
||||
event.Name = name
|
||||
event.Input = input_
|
||||
if is_entity:
|
||||
event.Input = json.dumps({ "result": json.dumps(input_) })
|
||||
else:
|
||||
event.Input = input_
|
||||
# event.timestamp = timestamp
|
||||
self.history_events.append(event)
|
||||
|
||||
|
|
|
@ -0,0 +1,57 @@
|
|||
import json
|
||||
from typing import Any, List, Dict, Any
|
||||
|
||||
class EntityContextBuilder():
|
||||
"""Mock class for an EntityContext object, includes a batch field for convenience
|
||||
"""
|
||||
def __init__(self,
|
||||
name: str = "",
|
||||
key: str = "",
|
||||
exists: bool = True,
|
||||
state: Any = None,
|
||||
batch: List[Dict[str, Any]] = []):
|
||||
"""Construct an EntityContextBuilder
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name: str:
|
||||
The name of the entity. Defaults to the empty string.
|
||||
key: str
|
||||
The key of the entity. Defaults to the empty string.
|
||||
exists: bool
|
||||
Boolean representing if the entity exists, defaults to True.
|
||||
state: Any
|
||||
The state of the entity, defaults ot None.
|
||||
batch: List[Dict[str, Any]]
|
||||
The upcoming batch of operations for the entity to perform.
|
||||
Note that the batch is not technically a part of the entity context
|
||||
and so it is here only for convenience. Defaults to the empty list.
|
||||
"""
|
||||
self.name = name
|
||||
self.key = key
|
||||
self.exists = exists
|
||||
self.state = state
|
||||
self.batch = batch
|
||||
|
||||
def to_json_string(self) -> str:
|
||||
"""Generate a string-representation of the Entity input payload.
|
||||
|
||||
The payload matches the current durable-extension entity-communication
|
||||
schema.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str:
|
||||
A JSON-formatted string for an EntityContext to load via `from_json`
|
||||
"""
|
||||
context_json = {
|
||||
"self": {
|
||||
"name": self.name,
|
||||
"key": self.key
|
||||
},
|
||||
"state": self.state,
|
||||
"exists": self.exists,
|
||||
"batch": self.batch
|
||||
}
|
||||
json_string = json.dumps(context_json)
|
||||
return json_string
|
|
@ -24,7 +24,7 @@ def convert_history_event_to_json_dict(
|
|||
add_attrib(json_dict, history_event, 'FireAt')
|
||||
add_attrib(json_dict, history_event, 'TimerId')
|
||||
add_attrib(json_dict, history_event, 'Name')
|
||||
add_attrib(json_dict, history_event, 'InstanceId')
|
||||
add_json_attrib(json_dict, history_event,
|
||||
'orchestration_instance', 'OrchestrationInstance')
|
||||
|
||||
return json_dict
|
||||
|
|
Загрузка…
Ссылка в новой задаче