This thing where
This commit is contained in:
+40
-13
@@ -13,7 +13,7 @@ from wf_core import (
|
|||||||
)
|
)
|
||||||
from wf_core.demo_workflow import build_demo_registry, build_demo_workflow
|
from wf_core.demo_workflow import build_demo_registry, build_demo_workflow
|
||||||
from wf_core.run_factory import create_run_state
|
from wf_core.run_factory import create_run_state
|
||||||
from wf_authoring import WorkflowBuilder, node
|
from wf_authoring import NodeReturn, WorkflowBuilder, build_registry, node
|
||||||
|
|
||||||
|
|
||||||
class DriveListFilesInput(BaseModel):
|
class DriveListFilesInput(BaseModel):
|
||||||
@@ -106,11 +106,11 @@ def combine_summaries_spec(
|
|||||||
def send_email_spec(
|
def send_email_spec(
|
||||||
payload: SendEmailInput,
|
payload: SendEmailInput,
|
||||||
ctx: RuntimeContext,
|
ctx: RuntimeContext,
|
||||||
) -> dict[str, object]:
|
) -> NodeReturn[SendEmailOutput]:
|
||||||
return {
|
return NodeReturn(
|
||||||
"outcome": "sent",
|
outcome="sent",
|
||||||
"output": {"email_status": f"sent: {payload.summary}"},
|
output=SendEmailOutput(email_status=f"sent: {payload.summary}"),
|
||||||
}
|
)
|
||||||
|
|
||||||
|
|
||||||
@node(
|
@node(
|
||||||
@@ -210,13 +210,13 @@ def build_authoring_demo_workflow():
|
|||||||
builder.connect("send_email", "sent", END)
|
builder.connect("send_email", "sent", END)
|
||||||
builder.connect("skip_email", "ok", END)
|
builder.connect("skip_email", "ok", END)
|
||||||
|
|
||||||
registry = {
|
registry = build_registry(
|
||||||
drive_list_files_spec.name: drive_list_files_spec.to_registry_handler(),
|
drive_list_files_spec,
|
||||||
summarize_document_spec.name: summarize_document_spec.to_registry_handler(),
|
summarize_document_spec,
|
||||||
combine_summaries_spec.name: combine_summaries_spec.to_registry_handler(),
|
combine_summaries_spec,
|
||||||
send_email_spec.name: send_email_spec.to_registry_handler(),
|
send_email_spec,
|
||||||
mark_email_skipped_spec.name: mark_email_skipped_spec.to_registry_handler(),
|
mark_email_skipped_spec,
|
||||||
}
|
)
|
||||||
return builder.compile(), registry
|
return builder.compile(), registry
|
||||||
|
|
||||||
|
|
||||||
@@ -368,3 +368,30 @@ def test_builder_compiled_workflow_executes_like_declared_demo() -> None:
|
|||||||
assert built_run.output == declared_run.output
|
assert built_run.output == declared_run.output
|
||||||
assert built_run.state == declared_run.state
|
assert built_run.state == declared_run.state
|
||||||
assert built_run.current_node_id == declared_run.current_node_id
|
assert built_run.current_node_id == declared_run.current_node_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_async_node_spec_cannot_export_sync_registry_handler() -> None:
|
||||||
|
class AsyncInput(BaseModel):
|
||||||
|
value: str
|
||||||
|
|
||||||
|
class AsyncOutput(BaseModel):
|
||||||
|
echoed: str
|
||||||
|
|
||||||
|
@node(
|
||||||
|
name="async_echo",
|
||||||
|
input_model=AsyncInput,
|
||||||
|
output_model=AsyncOutput,
|
||||||
|
is_async=True,
|
||||||
|
)
|
||||||
|
async def async_echo(
|
||||||
|
payload: AsyncInput,
|
||||||
|
ctx: RuntimeContext,
|
||||||
|
) -> AsyncOutput:
|
||||||
|
return AsyncOutput(echoed=payload.value)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async_echo.to_registry_handler()
|
||||||
|
except TypeError as exc:
|
||||||
|
assert "async" in str(exc)
|
||||||
|
else:
|
||||||
|
raise AssertionError("expected async node export to fail for sync registry")
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
from .builder import WorkflowBuilder
|
from .builder import WorkflowBuilder
|
||||||
from .catalog import NodeCatalog, NodeCatalogEntry
|
from .catalog import NodeCatalog, NodeCatalogEntry
|
||||||
from .spec import NodeSpec, node
|
from .spec import NodeReturn, NodeSpec, build_registry, node
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"NodeCatalog",
|
"NodeCatalog",
|
||||||
"NodeCatalogEntry",
|
"NodeCatalogEntry",
|
||||||
|
"NodeReturn",
|
||||||
"NodeSpec",
|
"NodeSpec",
|
||||||
"WorkflowBuilder",
|
"WorkflowBuilder",
|
||||||
|
"build_registry",
|
||||||
"node",
|
"node",
|
||||||
]
|
]
|
||||||
|
|||||||
+38
-8
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Generic, TypeVar
|
from typing import Any, Generic, TypeVar
|
||||||
|
|
||||||
@@ -10,19 +10,29 @@ from wf_core import NodeDef, RuntimeContext, SchemaRef
|
|||||||
|
|
||||||
InputT = TypeVar("InputT", bound=BaseModel)
|
InputT = TypeVar("InputT", bound=BaseModel)
|
||||||
OutputT = TypeVar("OutputT", bound=BaseModel)
|
OutputT = TypeVar("OutputT", bound=BaseModel)
|
||||||
|
NodeCallable = Callable[[InputT, RuntimeContext], "NodeReturn[OutputT] | OutputT"]
|
||||||
|
AsyncNodeCallable = Callable[
|
||||||
|
[InputT, RuntimeContext], Awaitable["NodeReturn[OutputT] | OutputT"]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _schema_ref_for(model_type: type[BaseModel]) -> SchemaRef:
|
def _schema_ref_for(model_type: type[BaseModel]) -> SchemaRef:
|
||||||
return SchemaRef.model_validate(model_type.model_json_schema())
|
return SchemaRef.model_validate(model_type.model_json_schema())
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class NodeReturn(Generic[OutputT]):
|
||||||
|
outcome: str
|
||||||
|
output: OutputT
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class NodeSpec(Generic[InputT, OutputT]):
|
class NodeSpec(Generic[InputT, OutputT]):
|
||||||
name: str
|
name: str
|
||||||
input_model: type[InputT]
|
input_model: type[InputT]
|
||||||
output_model: type[OutputT]
|
output_model: type[OutputT]
|
||||||
outcomes: tuple[str, ...]
|
outcomes: tuple[str, ...]
|
||||||
fn: Callable[[InputT, RuntimeContext], OutputT | dict[str, Any]]
|
fn: NodeCallable[InputT, OutputT] | AsyncNodeCallable[InputT, OutputT]
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
is_async: bool = False
|
is_async: bool = False
|
||||||
|
|
||||||
@@ -30,7 +40,7 @@ class NodeSpec(Generic[InputT, OutputT]):
|
|||||||
self,
|
self,
|
||||||
payload: InputT,
|
payload: InputT,
|
||||||
ctx: RuntimeContext,
|
ctx: RuntimeContext,
|
||||||
) -> OutputT | dict[str, Any]:
|
) -> NodeReturn[OutputT] | OutputT | Awaitable[NodeReturn[OutputT] | OutputT]:
|
||||||
return self.fn(payload, ctx)
|
return self.fn(payload, ctx)
|
||||||
|
|
||||||
def to_node_def(self) -> NodeDef:
|
def to_node_def(self) -> NodeDef:
|
||||||
@@ -42,13 +52,26 @@ class NodeSpec(Generic[InputT, OutputT]):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def to_registry_handler(self) -> Callable[[dict[str, Any], RuntimeContext], dict[str, Any]]:
|
def to_registry_handler(self) -> Callable[[dict[str, Any], RuntimeContext], dict[str, Any]]:
|
||||||
|
if self.is_async:
|
||||||
|
raise TypeError(
|
||||||
|
f"node {self.name!r} is async and cannot be exported to the sync registry"
|
||||||
|
)
|
||||||
|
|
||||||
def handler(payload: dict[str, Any], ctx: RuntimeContext) -> dict[str, Any]:
|
def handler(payload: dict[str, Any], ctx: RuntimeContext) -> dict[str, Any]:
|
||||||
parsed = self.input_model.model_validate(payload)
|
parsed = self.input_model.model_validate(payload)
|
||||||
raw = self.fn(parsed, ctx)
|
raw = self.fn(parsed, ctx)
|
||||||
|
if isinstance(raw, NodeReturn):
|
||||||
|
if not isinstance(raw.output, self.output_model):
|
||||||
|
raise TypeError(
|
||||||
|
f"node {self.name!r} returned NodeReturn with unsupported output "
|
||||||
|
f"{type(raw.output)!r}"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"outcome": raw.outcome,
|
||||||
|
"output": raw.output.model_dump(),
|
||||||
|
}
|
||||||
if isinstance(raw, self.output_model):
|
if isinstance(raw, self.output_model):
|
||||||
return {"outcome": self.outcomes[0], "output": raw.model_dump()}
|
return {"outcome": self.outcomes[0], "output": raw.model_dump()}
|
||||||
if isinstance(raw, dict):
|
|
||||||
return raw
|
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
f"node {self.name!r} returned unsupported value {type(raw)!r}"
|
f"node {self.name!r} returned unsupported value {type(raw)!r}"
|
||||||
)
|
)
|
||||||
@@ -63,12 +86,13 @@ def node(
|
|||||||
output_model: type[OutputT],
|
output_model: type[OutputT],
|
||||||
outcomes: tuple[str, ...] = ("ok",),
|
outcomes: tuple[str, ...] = ("ok",),
|
||||||
description: str | None = None,
|
description: str | None = None,
|
||||||
|
is_async: bool = False,
|
||||||
) -> Callable[
|
) -> Callable[
|
||||||
[Callable[[InputT, RuntimeContext], OutputT | dict[str, Any]]],
|
[NodeCallable[InputT, OutputT] | AsyncNodeCallable[InputT, OutputT]],
|
||||||
NodeSpec[InputT, OutputT],
|
NodeSpec[InputT, OutputT],
|
||||||
]:
|
]:
|
||||||
def decorator(
|
def decorator(
|
||||||
fn: Callable[[InputT, RuntimeContext], OutputT | dict[str, Any]],
|
fn: NodeCallable[InputT, OutputT] | AsyncNodeCallable[InputT, OutputT],
|
||||||
) -> NodeSpec[InputT, OutputT]:
|
) -> NodeSpec[InputT, OutputT]:
|
||||||
resolved_name = name or getattr(fn, "__name__", "node")
|
resolved_name = name or getattr(fn, "__name__", "node")
|
||||||
return NodeSpec(
|
return NodeSpec(
|
||||||
@@ -78,7 +102,13 @@ def node(
|
|||||||
outcomes=outcomes,
|
outcomes=outcomes,
|
||||||
fn=fn,
|
fn=fn,
|
||||||
description=description or fn.__doc__,
|
description=description or fn.__doc__,
|
||||||
is_async=False,
|
is_async=is_async,
|
||||||
)
|
)
|
||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def build_registry(
|
||||||
|
*specs: NodeSpec[Any, Any],
|
||||||
|
) -> dict[str, Callable[[dict[str, Any], RuntimeContext], dict[str, Any]]]:
|
||||||
|
return {spec.name: spec.to_registry_handler() for spec in specs}
|
||||||
|
|||||||
Reference in New Issue
Block a user