Type wizardry
This commit is contained in:
@@ -56,6 +56,30 @@ class MarkEmailSkippedOutput(BaseModel):
|
|||||||
email_status: str
|
email_status: str
|
||||||
|
|
||||||
|
|
||||||
|
class InferredEchoInput(BaseModel):
|
||||||
|
value: str
|
||||||
|
|
||||||
|
|
||||||
|
class InferredEchoOutput(BaseModel):
|
||||||
|
echoed: str
|
||||||
|
|
||||||
|
|
||||||
|
class InferredOutcomeInput(BaseModel):
|
||||||
|
value: str
|
||||||
|
|
||||||
|
|
||||||
|
class InferredOutcomeOutput(BaseModel):
|
||||||
|
echoed: str
|
||||||
|
|
||||||
|
|
||||||
|
class InferredAsyncInput(BaseModel):
|
||||||
|
value: str
|
||||||
|
|
||||||
|
|
||||||
|
class InferredAsyncOutput(BaseModel):
|
||||||
|
echoed: str
|
||||||
|
|
||||||
|
|
||||||
@node(
|
@node(
|
||||||
name="drive_list_files",
|
name="drive_list_files",
|
||||||
input_model=DriveListFilesInput,
|
input_model=DriveListFilesInput,
|
||||||
@@ -395,3 +419,60 @@ def test_async_node_spec_cannot_export_sync_registry_handler() -> None:
|
|||||||
assert "async" in str(exc)
|
assert "async" in str(exc)
|
||||||
else:
|
else:
|
||||||
raise AssertionError("expected async node export to fail for sync registry")
|
raise AssertionError("expected async node export to fail for sync registry")
|
||||||
|
|
||||||
|
|
||||||
|
@node()
|
||||||
|
def inferred_echo(
|
||||||
|
payload: InferredEchoInput,
|
||||||
|
ctx: RuntimeContext,
|
||||||
|
) -> InferredEchoOutput:
|
||||||
|
return InferredEchoOutput(echoed=payload.value)
|
||||||
|
|
||||||
|
|
||||||
|
@node(outcomes=("done", "retry"))
|
||||||
|
def inferred_echo_with_outcome(
|
||||||
|
payload: InferredOutcomeInput,
|
||||||
|
ctx: RuntimeContext,
|
||||||
|
) -> NodeReturn[InferredOutcomeOutput]:
|
||||||
|
return NodeReturn(
|
||||||
|
outcome="done",
|
||||||
|
output=InferredOutcomeOutput(echoed=payload.value),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@node()
|
||||||
|
async def inferred_async_echo(
|
||||||
|
payload: InferredAsyncInput,
|
||||||
|
ctx: RuntimeContext,
|
||||||
|
) -> InferredAsyncOutput:
|
||||||
|
return InferredAsyncOutput(echoed=payload.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_node_decorator_infers_models_from_annotations() -> None:
|
||||||
|
assert inferred_echo.input_model is InferredEchoInput
|
||||||
|
assert inferred_echo.output_model is InferredEchoOutput
|
||||||
|
assert inferred_echo.outcomes == ("ok",)
|
||||||
|
assert inferred_echo.is_async is False
|
||||||
|
|
||||||
|
registry = build_registry(inferred_echo)
|
||||||
|
result = registry["inferred_echo"](
|
||||||
|
{"value": "hello"},
|
||||||
|
RuntimeContext(current_node_id="x"),
|
||||||
|
)
|
||||||
|
assert result == {"outcome": "ok", "output": {"echoed": "hello"}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_node_decorator_infers_nodereturn_output_model() -> None:
|
||||||
|
assert inferred_echo_with_outcome.input_model is InferredOutcomeInput
|
||||||
|
assert inferred_echo_with_outcome.output_model is InferredOutcomeOutput
|
||||||
|
|
||||||
|
registry = build_registry(inferred_echo_with_outcome)
|
||||||
|
result = registry["inferred_echo_with_outcome"](
|
||||||
|
{"value": "hello"},
|
||||||
|
RuntimeContext(current_node_id="x"),
|
||||||
|
)
|
||||||
|
assert result == {"outcome": "done", "output": {"echoed": "hello"}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_node_decorator_detects_async_automatically() -> None:
|
||||||
|
assert inferred_async_echo.is_async is True
|
||||||
|
|||||||
+124
-17
@@ -1,8 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from inspect import Parameter, iscoroutinefunction, signature
|
||||||
from collections.abc import Awaitable, 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, cast, get_args, get_origin, get_type_hints, overload
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
@@ -26,6 +27,67 @@ class NodeReturn(Generic[OutputT]):
|
|||||||
output: OutputT
|
output: OutputT
|
||||||
|
|
||||||
|
|
||||||
|
def _is_basemodel_subclass(value: object) -> bool:
|
||||||
|
return isinstance(value, type) and issubclass(value, BaseModel)
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_models(
|
||||||
|
fn: Callable[..., object],
|
||||||
|
) -> tuple[type[BaseModel], type[BaseModel]]:
|
||||||
|
hints = get_type_hints(fn, include_extras=True)
|
||||||
|
params = list(signature(fn).parameters.values())
|
||||||
|
if len(params) < 2:
|
||||||
|
raise TypeError(
|
||||||
|
"node function must accept at least (payload, ctx) parameters"
|
||||||
|
)
|
||||||
|
|
||||||
|
payload_param = params[0]
|
||||||
|
ctx_param = params[1]
|
||||||
|
|
||||||
|
if payload_param.kind not in (
|
||||||
|
Parameter.POSITIONAL_ONLY,
|
||||||
|
Parameter.POSITIONAL_OR_KEYWORD,
|
||||||
|
):
|
||||||
|
raise TypeError("node payload parameter must be positional")
|
||||||
|
if ctx_param.kind not in (
|
||||||
|
Parameter.POSITIONAL_ONLY,
|
||||||
|
Parameter.POSITIONAL_OR_KEYWORD,
|
||||||
|
):
|
||||||
|
raise TypeError("node context parameter must be positional")
|
||||||
|
|
||||||
|
input_model = hints.get(payload_param.name)
|
||||||
|
if not _is_basemodel_subclass(input_model):
|
||||||
|
raise TypeError(
|
||||||
|
"node payload annotation must be a pydantic BaseModel subclass"
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx_type = hints.get(ctx_param.name)
|
||||||
|
if ctx_type is not RuntimeContext:
|
||||||
|
raise TypeError(
|
||||||
|
"node context annotation must be wf_core.RuntimeContext"
|
||||||
|
)
|
||||||
|
|
||||||
|
return_type = hints.get("return")
|
||||||
|
if return_type is None:
|
||||||
|
raise TypeError("node function must declare a return annotation")
|
||||||
|
|
||||||
|
if _is_basemodel_subclass(return_type):
|
||||||
|
return cast(type[BaseModel], input_model), cast(type[BaseModel], return_type)
|
||||||
|
|
||||||
|
origin = get_origin(return_type)
|
||||||
|
if origin is NodeReturn:
|
||||||
|
args = get_args(return_type)
|
||||||
|
if len(args) != 1 or not _is_basemodel_subclass(args[0]):
|
||||||
|
raise TypeError(
|
||||||
|
"NodeReturn return annotation must wrap a BaseModel subclass"
|
||||||
|
)
|
||||||
|
return cast(type[BaseModel], input_model), cast(type[BaseModel], args[0])
|
||||||
|
|
||||||
|
raise TypeError(
|
||||||
|
"node return annotation must be a BaseModel subclass or NodeReturn[BaseModel]"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class NodeSpec(Generic[InputT, OutputT]):
|
class NodeSpec(Generic[InputT, OutputT]):
|
||||||
name: str
|
name: str
|
||||||
@@ -78,33 +140,78 @@ class NodeSpec(Generic[InputT, OutputT]):
|
|||||||
|
|
||||||
return handler
|
return handler
|
||||||
|
|
||||||
|
@overload
|
||||||
def node(
|
def node(
|
||||||
*,
|
fn: NodeCallable[InputT, OutputT] | AsyncNodeCallable[InputT, OutputT],
|
||||||
name: str | None = None,
|
/,
|
||||||
input_model: type[InputT],
|
) -> NodeSpec[InputT, OutputT]:
|
||||||
output_model: type[OutputT],
|
...
|
||||||
outcomes: tuple[str, ...] = ("ok",),
|
|
||||||
description: str | None = None,
|
|
||||||
is_async: bool = False,
|
@overload
|
||||||
|
def node(
|
||||||
|
fn: None = None,
|
||||||
|
/,
|
||||||
) -> Callable[
|
) -> Callable[
|
||||||
[NodeCallable[InputT, OutputT] | AsyncNodeCallable[InputT, OutputT]],
|
[NodeCallable[InputT, OutputT] | AsyncNodeCallable[InputT, OutputT]],
|
||||||
NodeSpec[InputT, OutputT],
|
NodeSpec[InputT, OutputT],
|
||||||
]:
|
]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def node(
|
||||||
|
fn: None = None,
|
||||||
|
/,
|
||||||
|
*,
|
||||||
|
name: str | None = None,
|
||||||
|
input_model: type[InputT] | None = None,
|
||||||
|
output_model: type[OutputT] | None = None,
|
||||||
|
outcomes: tuple[str, ...] = ("ok",),
|
||||||
|
description: str | None = None,
|
||||||
|
is_async: bool | None = None,
|
||||||
|
) -> Callable[
|
||||||
|
[NodeCallable[InputT, OutputT] | AsyncNodeCallable[InputT, OutputT]],
|
||||||
|
NodeSpec[InputT, OutputT],
|
||||||
|
]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def node(
|
||||||
|
fn: NodeCallable[InputT, OutputT] | AsyncNodeCallable[InputT, OutputT] | None = None,
|
||||||
|
*,
|
||||||
|
name: str | None = None,
|
||||||
|
input_model: type[InputT] | None = None,
|
||||||
|
output_model: type[OutputT] | None = None,
|
||||||
|
outcomes: tuple[str, ...] = ("ok",),
|
||||||
|
description: str | None = None,
|
||||||
|
is_async: bool | None = None,
|
||||||
|
) -> Any:
|
||||||
def decorator(
|
def decorator(
|
||||||
fn: NodeCallable[InputT, OutputT] | AsyncNodeCallable[InputT, OutputT],
|
fn: NodeCallable[InputT, OutputT] | AsyncNodeCallable[InputT, OutputT],
|
||||||
) -> NodeSpec[InputT, OutputT]:
|
) -> NodeSpec[InputT, OutputT]:
|
||||||
|
inferred_input_model: type[BaseModel] | None = input_model
|
||||||
|
inferred_output_model: type[BaseModel] | None = output_model
|
||||||
|
if inferred_input_model is None or inferred_output_model is None:
|
||||||
|
inferred_input_model, inferred_output_model = _infer_models(fn)
|
||||||
|
|
||||||
resolved_name = name or getattr(fn, "__name__", "node")
|
resolved_name = name or getattr(fn, "__name__", "node")
|
||||||
return NodeSpec(
|
resolved_is_async = iscoroutinefunction(fn) if is_async is None else is_async
|
||||||
name=resolved_name,
|
return cast(
|
||||||
input_model=input_model,
|
NodeSpec[InputT, OutputT],
|
||||||
output_model=output_model,
|
NodeSpec(
|
||||||
outcomes=outcomes,
|
name=resolved_name,
|
||||||
fn=fn,
|
input_model=inferred_input_model,
|
||||||
description=description or fn.__doc__,
|
output_model=inferred_output_model,
|
||||||
is_async=is_async,
|
outcomes=outcomes,
|
||||||
|
fn=cast(Any, fn),
|
||||||
|
description=description or fn.__doc__,
|
||||||
|
is_async=resolved_is_async,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if fn is not None:
|
||||||
|
return decorator(fn)
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user