Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: New eval no state - POC #684

Draft
wants to merge 2 commits into
base: dev
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/ui/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export function generateCore() {
const components: Ref<ComponentMap> = ref({});
const userFunctions: Ref<UserFunction[]> = ref([]);
const userState: Ref<Record<string, any>> = ref({});
const evaluatedExpressions: Ref<Record<string, any>> = ref({});
let webSocket: WebSocket;
const syncHealth: Ref<"idle" | "connected" | "offline" | "suspended"> =
ref("idle");
Expand Down Expand Up @@ -146,7 +147,7 @@ export function generateCore() {
*/

const mutationFlag = key.charAt(0);
const accessor = parseAccessor(key.substring(1));
const accessor = parseAccessor(key.substring(0));
const lastElementIndex = accessor.length - 1;
let stateRef = userState.value;

Expand Down Expand Up @@ -217,6 +218,7 @@ export function generateCore() {
message.messageType == "eventResponse" ||
message.messageType == "stateEnquiryResponse"
) {
evaluatedExpressions.value = message.payload?.evaluatedExpressions;
ingestMutations(message.payload?.mutations);
collateMail(message.payload?.mail);
ingestComponents(message.payload?.components);
Expand Down Expand Up @@ -567,6 +569,7 @@ export function generateCore() {
webSocket,
syncHealth,
frontendMessageMap: readonly(frontendMessageMap),
evaluatedExpressions: readonly(evaluatedExpressions),
mode: readonly(mode),
userFunctions: readonly(userFunctions),
addMailSubscription,
Expand Down
23 changes: 22 additions & 1 deletion src/ui/src/renderer/useEvaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { Component, Core, FieldType, InstancePath } from "@/writerTypes";
export function useEvaluator(wf: Core) {
const templateRegex = /[\\]?@{([^}]*)}/g;

const expressionsTemplateRegex = /\{\{(.*?)\}\}/g;

/**
* Returns the expression as an array of static accessors.
* For example, turns a.b.c into ["a", "b", "c"].
Expand Down Expand Up @@ -119,7 +121,7 @@ export function useEvaluator(wf: Core) {
instancePath: InstancePath,
): string {
if (template === undefined || template === null) return "";
const evaluatedTemplate = template.replace(
let evaluatedTemplate = template.replace(
templateRegex,
(match, captured) => {
if (match.charAt(0) == "\\") return match.substring(1); // Escaped @, don't evaluate, return without \
Expand All @@ -139,6 +141,25 @@ export function useEvaluator(wf: Core) {
},
);

evaluatedTemplate = evaluatedTemplate.replaceAll(expressionsTemplateRegex, (match, captured) => {
if (match.charAt(0) == "\\") return match.substring(1);


const expr = captured.trim();
if (!expr) return "";

const exprValue = wf.evaluatedExpressions.value[expr];

if (typeof exprValue == "undefined") {
return "";
} else if (typeof exprValue == "object") {
return JSON.stringify(exprValue);
}

return exprValue.toString();

});

return evaluatedTemplate;
}

Expand Down
14 changes: 12 additions & 2 deletions src/writer/app_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,11 +154,13 @@ def _handle_session_init(self, payload: InitSessionRequestPayload) -> InitSessio
session.session_state.add_log_entry(
"error", "Serialisation error", tb.format_exc())

self._execute_user_app_code(session.globals)

ui_component_tree = core_ui.export_component_tree(
session.session_component_tree, mode=writer.Config.mode)

res_payload = InitSessionResponsePayload(
userState=user_state,
userState=session.get_serialized_globals(),
sessionId=session.session_id,
mail=session.session_state.mail,
components=ui_component_tree,
Expand All @@ -176,6 +178,7 @@ def _handle_event(self, session: WriterSession, event: WriterEvent) -> EventResp
result = session.event_handler.handle(event)

mutations = {}
session.session_state.user_state.apply_mutation_marker(recursive=True)

try:
mutations = session.session_state.user_state.get_mutations_as_dict()
Expand All @@ -192,7 +195,8 @@ def _handle_event(self, session: WriterSession, event: WriterEvent) -> EventResp

res_payload = EventResponsePayload(
result=result,
mutations=mutations,
mutations=session.get_serialized_globals(),
evaluatedExpressions=session.get_serialized_evaluated_expressions(),
components=ui_component_tree,
mail=mail
)
Expand Down Expand Up @@ -315,6 +319,12 @@ def _handle_message(self, session_id: str, request: AppProcessServerRequest) ->

raise MessageHandlingException("Invalid event.")

def _execute_user_app_code(self, session_globals) -> Dict:
code_path = os.path.join(self.app_path, "main.py")
code = compile(self.run_code, code_path, "exec")
exec(code, session_globals)
return session_globals

def _execute_user_code(self) -> None:
"""
Executes the user code and captures standard output.
Expand Down
2 changes: 1 addition & 1 deletion src/writer/blocks/base_block.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def __init__(self, component_id: str, runner: "WorkflowRunner", execution_enviro
self.result = None
self.return_value = None
self.instance_path: InstancePath = [{"componentId": component_id, "instanceNumber": 0}]
self.evaluator = writer.evaluator.Evaluator(runner.session.session_state, runner.session.session_component_tree)
self.evaluator = writer.evaluator.Evaluator(runner.session.globals, runner.session.session_component_tree)

def _handle_missing_field(self, field_key):
component_tree = self.runner.session.session_component_tree
Expand Down
35 changes: 29 additions & 6 deletions src/writer/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,15 +144,33 @@ def __init__(self, session_id: str, cookies: Optional[Dict[str, str]], headers:
self.headers = headers
self.last_active_timestamp: int = int(time.time())
new_state = WriterState.get_new()
new_state.user_state.mutated = set()
self.session_state = new_state
self.session_component_tree = core_ui.build_session_component_tree(base_component_tree)
self.globals = {}
self.event_handler = EventHandler(self)
self.userinfo: Optional[dict] = None

def update_last_active_timestamp(self) -> None:
self.last_active_timestamp = int(time.time())

def get_serialized_evaluated_expressions(self):
serializer = StateSerialiser()
evaluated_expressions = {}
expressions = self.session_component_tree.scan_expressions()
for expression in (expressions or {}):
print(f"evaluating {expression}")
try:
evaluated_expressions[expression] = serializer.serialise(eval(expression, self.globals))
except Exception as e:
evaluated_expressions[expression] = None
return evaluated_expressions

def get_serialized_globals(self):
serializer = StateSerialiser()
globals_excluding_builtins = {k: v for k, v in self.globals.items() if k != "__builtins__"}

return serializer.serialise(globals_excluding_builtins)


@dataclasses.dataclass
class MutationSubscription:
Expand Down Expand Up @@ -338,8 +356,10 @@ def serialise(self, v: Any) -> Union[Dict, List, str, bool, int, float, None]:
# Covers Altair charts, Plotly graphs
return self._serialise_dict_recursively(v.to_dict())

raise StateSerialiserException(
f"Object of type { type(v) } (MRO: {v_mro}) cannot be serialised.")
return f"Object of type { type(v) } (MRO: {v_mro}) cannot be serialised."

# raise StateSerialiserException(
# f"Object of type { type(v) } (MRO: {v_mro}) cannot be serialised.")

def _serialise_dict_recursively(self, d: Dict) -> Dict:
return {str(k): self.serialise(v) for k, v in d.items()}
Expand Down Expand Up @@ -1619,9 +1639,11 @@ def _get_handler_callable(self, handler: str) -> Optional[Callable]:
workflow_id = handler[17:]
return self._get_workflow_callable(workflow_id=workflow_id)

current_app_process = get_app_process()
handler_registry = current_app_process.handler_registry
callable_handler = handler_registry.find_handler_callable(handler)
callable_handler = self.session.globals[handler]

# current_app_process = get_app_process()
# handler_registry = current_app_process.handler_registry
# callable_handler = handler_registry.find_handler_callable(handler)
return callable_handler

def _get_calling_arguments(self, ev: WriterEvent, instance_path: Optional[InstancePath] = None):
Expand Down Expand Up @@ -1712,6 +1734,7 @@ def handle(self, ev: WriterEvent) -> WriterEventResult:
else:
return {"ok": True, "result": self._handle_component_event(ev)}
except BaseException as e:
raise e
return {"ok": False, "result": str(e)}


Expand Down
15 changes: 15 additions & 0 deletions src/writer/core_ui.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import contextlib
import copy
import logging
import re
import uuid
from contextvars import ContextVar
from enum import Enum
Expand Down Expand Up @@ -251,6 +252,20 @@ def to_dict(self) -> Dict:

return components

def scan_expressions(self) -> List:
pattern = re.compile(r"\{\{(.*?)\}\}")
trees = reversed(self.tree_branches)
expressions = []
for tree in trees:
all_components = tree.components.values()
for component in all_components:
for field_value in component.content.values():
matches = pattern.findall(field_value)
for match in matches:
expressions.append(match.strip())
return expressions


def next_page_id(self) -> str:
return f"page-{self.page_counter}"

Expand Down
92 changes: 7 additions & 85 deletions src/writer/evaluator.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@ class Evaluator:
It allows for the sanitisation of frontend inputs.
"""

TEMPLATE_REGEX = re.compile(r"[\\]?@{([^{]*?)}")
EXPRESSIONS_TEMPLATE_REGEX = re.compile(r"\{\{(.*?)\}\}")

def __init__(self, state: "WriterState", component_tree: "ComponentTree"):
self.state = state
def __init__(self, globals: Dict, component_tree: "ComponentTree"):
self.globals = globals
self.component_tree = component_tree
self.serializer = writer.core.StateSerialiser()

Expand All @@ -40,7 +40,7 @@ def replacer(matched):
if matched.string[0] == "\\": # Escaped @, don't evaluate
return matched.string
expr = matched.group(1).strip()
expr_value = self.evaluate_expression(expr, instance_path, base_context)
expr_value = eval(expr, self.globals)

return expr_value

Expand All @@ -51,10 +51,10 @@ def replacer(matched):

field_value = component.content.get(field_key) or default_field_value
replaced = None
full_match = self.TEMPLATE_REGEX.fullmatch(field_value)
full_match = self.EXPRESSIONS_TEMPLATE_REGEX.fullmatch(field_value)

if full_match is None:
replaced = self.TEMPLATE_REGEX.sub(lambda m: str(replacer(m)), field_value)
replaced = self.EXPRESSIONS_TEMPLATE_REGEX.sub(lambda m: str(replacer(m)), field_value)
if as_json:
replaced = decode_json(replaced)
else:
Expand Down Expand Up @@ -118,82 +118,4 @@ def set_state(self, expr: str, instance_path: InstancePath, value: Any, base_con
raise ValueError(
f'Reference "{expr}" cannot be translated to state. Found value of type "{type(state_ref)}".')

state_ref[accessors[-1]] = value

def parse_expression(self, expr: str, instance_path: Optional[InstancePath] = None, base_context = {}) -> List[str]:

""" Returns a list of accessors from an expression. """

if not isinstance(expr, str):
raise ValueError(f'Expression must be of type string. Value of type "{ type(expr) }" found.')

accessors: List[str] = []
s = ""
level = 0

i = 0
while i < len(expr):
character = expr[i]
if character == "\\":
if i + 1 < len(expr):
s += expr[i + 1]
i += 1
elif character == ".":
if level == 0:
accessors.append(s)
s = ""
else:
s += character
elif character == "[":
if level == 0:
accessors.append(s)
s = ""
else:
s += character
level += 1
elif character == "]":
level -= 1
if level == 0:
s = str(self.evaluate_expression(s, instance_path, base_context))
else:
s += character
else:
s += character

i += 1

if s:
accessors.append(s)

return accessors

def get_env_variable_value(self, expr: str):
return os.getenv(expr[1:])

def evaluate_expression(self, expr: str, instance_path: Optional[InstancePath] = None, base_context = {}) -> Any:
context_data = base_context
result = None
if instance_path:
context_data = self.get_context_data(instance_path, base_context)
context_ref: Any = context_data
state_ref: Any = self.state.user_state
accessors: List[str] = self.parse_expression(expr, instance_path, base_context)

for accessor in accessors:
if isinstance(state_ref, (writer.core.StateProxy, dict)) and accessor in state_ref:
state_ref = state_ref.get(accessor)
result = state_ref
elif isinstance(state_ref, (list)) and state_ref[int(accessor)] is not None:
state_ref = state_ref[int(accessor)]
result = state_ref
elif isinstance(context_ref, dict) and accessor in context_ref:
context_ref = context_ref.get(accessor)
result = context_ref

if isinstance(result, writer.core.StateProxy):
return result.to_dict()

if result is None and expr.startswith("$"):
return self.get_env_variable_value(expr)

return result
state_ref[accessors[-1]] = value
1 change: 1 addition & 0 deletions src/writer/ss_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ class InitSessionResponse(AppProcessServerResponse):
class EventResponsePayload(BaseModel):
result: Any
mutations: Dict[str, Any]
evaluatedExpressions: Dict[str, Any]
mail: List
components: Optional[Dict] = None

Expand Down
Loading