more std, and buffing @node
This commit is contained in:
@@ -18,12 +18,17 @@ from .dsl import (
|
||||
from .ops import (
|
||||
BoolOutput,
|
||||
CoalesceInput,
|
||||
ConstantInput,
|
||||
CountOutput,
|
||||
ItemOutput,
|
||||
MaybeItemOutput,
|
||||
PickKeyInput,
|
||||
SequenceInput,
|
||||
TruthyInput,
|
||||
ValueOutput,
|
||||
coalesce,
|
||||
constant,
|
||||
default_if_none,
|
||||
first_item,
|
||||
first_item_maybe,
|
||||
first_item_or_none,
|
||||
@@ -31,6 +36,8 @@ from .ops import (
|
||||
last_item,
|
||||
last_item_or_none,
|
||||
length,
|
||||
pick_key,
|
||||
truthy,
|
||||
)
|
||||
from .nodes import (
|
||||
AsyncRegistryHandler,
|
||||
@@ -48,15 +55,18 @@ __all__ = [
|
||||
"NodeCatalogEntry",
|
||||
"BoolOutput",
|
||||
"CoalesceInput",
|
||||
"ConstantInput",
|
||||
"CountOutput",
|
||||
"GraphPath",
|
||||
"ItemOutput",
|
||||
"MaybeItemOutput",
|
||||
"PickKeyInput",
|
||||
"NodeReturn",
|
||||
"NodeSpec",
|
||||
"AsyncRegistryHandler",
|
||||
"SyncRegistryHandler",
|
||||
"SequenceInput",
|
||||
"TruthyInput",
|
||||
"ValueOutput",
|
||||
"WorkflowBuilder",
|
||||
"bind_fields",
|
||||
@@ -64,6 +74,8 @@ __all__ = [
|
||||
"build_registry",
|
||||
"bind_state",
|
||||
"coalesce",
|
||||
"constant",
|
||||
"default_if_none",
|
||||
"merge_maps",
|
||||
"context",
|
||||
"context_path",
|
||||
@@ -79,8 +91,10 @@ __all__ = [
|
||||
"last_item",
|
||||
"last_item_or_none",
|
||||
"length",
|
||||
"pick_key",
|
||||
"node",
|
||||
"state",
|
||||
"state_path",
|
||||
"subgraph_node",
|
||||
"truthy",
|
||||
]
|
||||
|
||||
@@ -13,17 +13,39 @@ from .spec import NodeSpec
|
||||
|
||||
@overload
|
||||
def node(
|
||||
fn: NodeCallable[InputT, OutputT] | AsyncNodeCallable[InputT, OutputT],
|
||||
fn: NodeSpec[InputT, OutputT]
|
||||
| NodeCallable[InputT, OutputT]
|
||||
| AsyncNodeCallable[InputT, OutputT],
|
||||
/,
|
||||
) -> NodeSpec[InputT, OutputT]: ...
|
||||
|
||||
|
||||
@overload
|
||||
def node(
|
||||
fn: NodeSpec[InputT, OutputT]
|
||||
| NodeCallable[InputT, OutputT]
|
||||
| AsyncNodeCallable[InputT, OutputT],
|
||||
/,
|
||||
*,
|
||||
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,
|
||||
) -> NodeSpec[InputT, OutputT]: ...
|
||||
|
||||
|
||||
@overload
|
||||
def node(
|
||||
fn: None = None,
|
||||
/,
|
||||
) -> Callable[
|
||||
[NodeCallable[InputT, OutputT] | AsyncNodeCallable[InputT, OutputT]],
|
||||
[
|
||||
NodeSpec[InputT, OutputT]
|
||||
| NodeCallable[InputT, OutputT]
|
||||
| AsyncNodeCallable[InputT, OutputT]
|
||||
],
|
||||
NodeSpec[InputT, OutputT],
|
||||
]: ...
|
||||
|
||||
@@ -40,13 +62,18 @@ def node(
|
||||
description: str | None = None,
|
||||
is_async: bool | None = None,
|
||||
) -> Callable[
|
||||
[NodeCallable[InputT, OutputT] | AsyncNodeCallable[InputT, OutputT]],
|
||||
[
|
||||
NodeSpec[InputT, OutputT]
|
||||
| NodeCallable[InputT, OutputT]
|
||||
| AsyncNodeCallable[InputT, OutputT]
|
||||
],
|
||||
NodeSpec[InputT, OutputT],
|
||||
]: ...
|
||||
|
||||
|
||||
def node(
|
||||
fn: NodeCallable[InputT, OutputT]
|
||||
fn: NodeSpec[InputT, OutputT]
|
||||
| NodeCallable[InputT, OutputT]
|
||||
| AsyncNodeCallable[InputT, OutputT]
|
||||
| None = None,
|
||||
*,
|
||||
@@ -58,9 +85,24 @@ def node(
|
||||
is_async: bool | None = None,
|
||||
) -> Any:
|
||||
"""Convert a typed Python function into a reusable workflow node spec."""
|
||||
|
||||
def decorator(
|
||||
fn: NodeCallable[InputT, OutputT] | AsyncNodeCallable[InputT, OutputT],
|
||||
fn: NodeSpec[InputT, OutputT]
|
||||
| NodeCallable[InputT, OutputT]
|
||||
| AsyncNodeCallable[InputT, OutputT],
|
||||
) -> NodeSpec[InputT, OutputT]:
|
||||
if isinstance(fn, NodeSpec):
|
||||
return NodeSpec(
|
||||
name=name or fn.name,
|
||||
input_model=input_model or fn.input_model,
|
||||
output_model=output_model or fn.output_model,
|
||||
outcomes=outcomes if outcomes != ("ok",) else fn.outcomes,
|
||||
fn=fn.fn,
|
||||
description=description or fn.description,
|
||||
is_async=is_async if is_async is not None else fn.is_async,
|
||||
accepts_context=fn.accepts_context,
|
||||
)
|
||||
|
||||
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:
|
||||
|
||||
@@ -12,17 +12,33 @@ from .sequences import (
|
||||
last_item_or_none,
|
||||
length,
|
||||
)
|
||||
from .values import CoalesceInput, ValueOutput, coalesce
|
||||
from .values import (
|
||||
CoalesceInput,
|
||||
ConstantInput,
|
||||
PickKeyInput,
|
||||
TruthyInput,
|
||||
ValueOutput,
|
||||
coalesce,
|
||||
constant,
|
||||
default_if_none,
|
||||
pick_key,
|
||||
truthy,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"BoolOutput",
|
||||
"CoalesceInput",
|
||||
"ConstantInput",
|
||||
"CountOutput",
|
||||
"ItemOutput",
|
||||
"MaybeItemOutput",
|
||||
"PickKeyInput",
|
||||
"SequenceInput",
|
||||
"TruthyInput",
|
||||
"ValueOutput",
|
||||
"coalesce",
|
||||
"constant",
|
||||
"default_if_none",
|
||||
"first_item",
|
||||
"first_item_maybe",
|
||||
"first_item_or_none",
|
||||
@@ -30,4 +46,6 @@ __all__ = [
|
||||
"last_item",
|
||||
"last_item_or_none",
|
||||
"length",
|
||||
"pick_key",
|
||||
"truthy",
|
||||
]
|
||||
|
||||
@@ -8,22 +8,32 @@ from wf_authoring.nodes import NodeReturn, node
|
||||
|
||||
|
||||
class SequenceInput(BaseModel):
|
||||
"""Input model for ops that consume an ordered sequence."""
|
||||
|
||||
items: list[Any]
|
||||
|
||||
|
||||
class ItemOutput(BaseModel):
|
||||
"""Output model for ops that return a selected item."""
|
||||
|
||||
item: Any
|
||||
|
||||
|
||||
class MaybeItemOutput(BaseModel):
|
||||
"""Output model for ops that may not find an item."""
|
||||
|
||||
item: Any | None = None
|
||||
|
||||
|
||||
class CountOutput(BaseModel):
|
||||
"""Output model for ops that return a count."""
|
||||
|
||||
count: int
|
||||
|
||||
|
||||
class BoolOutput(BaseModel):
|
||||
"""Output model for ops that return a boolean value."""
|
||||
|
||||
value: bool
|
||||
|
||||
|
||||
@@ -34,6 +44,7 @@ class BoolOutput(BaseModel):
|
||||
description="Select the first item from a non-empty sequence.",
|
||||
)
|
||||
def first_item(input: SequenceInput) -> ItemOutput:
|
||||
"""Select the first item from a non-empty sequence."""
|
||||
if not input.items:
|
||||
raise ValueError("first_item requires at least one item")
|
||||
return ItemOutput(item=input.items[0])
|
||||
@@ -46,6 +57,7 @@ def first_item(input: SequenceInput) -> ItemOutput:
|
||||
description="Select the first item from a sequence, or None when it is empty.",
|
||||
)
|
||||
def first_item_or_none(input: SequenceInput) -> ItemOutput:
|
||||
"""Select the first item from a sequence, or None if it is empty."""
|
||||
return ItemOutput(item=input.items[0] if input.items else None)
|
||||
|
||||
|
||||
@@ -57,6 +69,7 @@ def first_item_or_none(input: SequenceInput) -> ItemOutput:
|
||||
description="Select the first item from a sequence, routing to found or missing.",
|
||||
)
|
||||
def first_item_maybe(input: SequenceInput) -> NodeReturn[MaybeItemOutput]:
|
||||
"""Select the first item and route by whether one exists."""
|
||||
if not input.items:
|
||||
return NodeReturn(outcome="missing", output=MaybeItemOutput())
|
||||
return NodeReturn(outcome="found", output=MaybeItemOutput(item=input.items[0]))
|
||||
@@ -69,6 +82,7 @@ def first_item_maybe(input: SequenceInput) -> NodeReturn[MaybeItemOutput]:
|
||||
description="Select the last item from a non-empty sequence.",
|
||||
)
|
||||
def last_item(input: SequenceInput) -> ItemOutput:
|
||||
"""Select the last item from a non-empty sequence."""
|
||||
if not input.items:
|
||||
raise ValueError("last_item requires at least one item")
|
||||
return ItemOutput(item=input.items[-1])
|
||||
@@ -81,6 +95,7 @@ def last_item(input: SequenceInput) -> ItemOutput:
|
||||
description="Select the last item from a sequence, or None when it is empty.",
|
||||
)
|
||||
def last_item_or_none(input: SequenceInput) -> ItemOutput:
|
||||
"""Select the last item from a sequence, or None if it is empty."""
|
||||
return ItemOutput(item=input.items[-1] if input.items else None)
|
||||
|
||||
|
||||
@@ -91,6 +106,7 @@ def last_item_or_none(input: SequenceInput) -> ItemOutput:
|
||||
description="Count the items in a sequence.",
|
||||
)
|
||||
def length(input: SequenceInput) -> CountOutput:
|
||||
"""Count the number of items in a sequence."""
|
||||
return CountOutput(count=len(input.items))
|
||||
|
||||
|
||||
@@ -101,4 +117,5 @@ def length(input: SequenceInput) -> CountOutput:
|
||||
description="Return whether a sequence is empty.",
|
||||
)
|
||||
def is_empty(input: SequenceInput) -> BoolOutput:
|
||||
"""Return whether a sequence has no items."""
|
||||
return BoolOutput(value=not input.items)
|
||||
|
||||
@@ -4,15 +4,38 @@ from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from wf_authoring.nodes import node
|
||||
from wf_authoring.nodes import NodeReturn, node
|
||||
|
||||
|
||||
class CoalesceInput(BaseModel):
|
||||
"""Input model for selecting the first non-None value."""
|
||||
|
||||
value: Any | None = None
|
||||
fallback: Any
|
||||
|
||||
|
||||
class ValueOutput(BaseModel):
|
||||
"""Output model for ops that emit an arbitrary value."""
|
||||
|
||||
value: Any
|
||||
|
||||
|
||||
class ConstantInput(BaseModel):
|
||||
"""Input model for passing through a configured value."""
|
||||
|
||||
value: Any
|
||||
|
||||
|
||||
class PickKeyInput(BaseModel):
|
||||
"""Input model for selecting a value from a mapping by key."""
|
||||
|
||||
mapping: dict[str, Any]
|
||||
key: str
|
||||
|
||||
|
||||
class TruthyInput(BaseModel):
|
||||
"""Input model for routing by Python truthiness."""
|
||||
|
||||
value: Any
|
||||
|
||||
|
||||
@@ -23,4 +46,48 @@ class ValueOutput(BaseModel):
|
||||
description="Return value when it is not None, otherwise return fallback.",
|
||||
)
|
||||
def coalesce(input: CoalesceInput) -> ValueOutput:
|
||||
"""Return value when it is not None, otherwise return fallback."""
|
||||
return ValueOutput(value=input.value if input.value is not None else input.fallback)
|
||||
|
||||
|
||||
default_if_none = node(
|
||||
name="authoring.default_if_none",
|
||||
description="Alias for coalesce: return fallback only when value is None.",
|
||||
)(coalesce)
|
||||
"""Alias for coalesce with a more explicit name for None-defaulting workflows."""
|
||||
|
||||
|
||||
@node(
|
||||
name="authoring.constant",
|
||||
input_model=ConstantInput,
|
||||
output_model=ValueOutput,
|
||||
description="Return the provided value unchanged.",
|
||||
)
|
||||
def constant(input: ConstantInput) -> ValueOutput:
|
||||
"""Return the provided value unchanged."""
|
||||
return ValueOutput(value=input.value)
|
||||
|
||||
|
||||
@node(
|
||||
name="authoring.pick_key",
|
||||
input_model=PickKeyInput,
|
||||
output_model=ValueOutput,
|
||||
description="Select a value from a mapping by key, returning None if missing.",
|
||||
)
|
||||
def pick_key(input: PickKeyInput) -> ValueOutput:
|
||||
"""Select a value from a mapping by key, returning None if missing."""
|
||||
return ValueOutput(value=input.mapping.get(input.key))
|
||||
|
||||
|
||||
@node(
|
||||
name="authoring.truthy",
|
||||
input_model=TruthyInput,
|
||||
output_model=ValueOutput,
|
||||
outcomes=("truthy", "falsey"),
|
||||
description="Route based on Python truthiness of a value.",
|
||||
)
|
||||
def truthy(input: TruthyInput) -> NodeReturn[ValueOutput]:
|
||||
"""Route based on Python truthiness of a value."""
|
||||
value = bool(input.value)
|
||||
outcome = "truthy" if value else "falsey"
|
||||
return NodeReturn(outcome=outcome, output=ValueOutput(value=value))
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from wf_authoring import build_registry, node
|
||||
from wf_core import RuntimeContext
|
||||
|
||||
|
||||
class AliasInput(BaseModel):
|
||||
value: str
|
||||
|
||||
|
||||
class AliasOutput(BaseModel):
|
||||
value: str
|
||||
|
||||
|
||||
def test_node_decorator_can_alias_existing_node_spec() -> None:
|
||||
@node(name="test.alias", description="Alias spec.")
|
||||
@node(name="test.original", description="Original spec.")
|
||||
def echo(input: AliasInput) -> AliasOutput:
|
||||
"""Echo input."""
|
||||
return AliasOutput(value=input.value)
|
||||
|
||||
registry = build_registry(echo)
|
||||
result = registry["test.alias"](
|
||||
{"value": "hello"},
|
||||
RuntimeContext(current_node_id="echo"),
|
||||
)
|
||||
|
||||
assert echo.name == "test.alias"
|
||||
assert echo.description == "Alias spec."
|
||||
assert echo.input_model is AliasInput
|
||||
assert echo.output_model is AliasOutput
|
||||
assert result == {"outcome": "ok", "output": {"value": "hello"}}
|
||||
|
||||
|
||||
def test_node_can_wrap_function_with_direct_metadata_call() -> None:
|
||||
def echo(input: AliasInput) -> AliasOutput:
|
||||
"""Echo input."""
|
||||
return AliasOutput(value=input.value)
|
||||
|
||||
spec = node(echo, name="test.direct_fn", description="Direct function spec.")
|
||||
|
||||
assert spec.name == "test.direct_fn"
|
||||
assert spec.description == "Direct function spec."
|
||||
assert spec.input_model is AliasInput
|
||||
assert spec.output_model is AliasOutput
|
||||
|
||||
|
||||
def test_node_can_alias_spec_with_direct_metadata_call() -> None:
|
||||
@node(name="test.original")
|
||||
def echo(input: AliasInput) -> AliasOutput:
|
||||
"""Echo input."""
|
||||
return AliasOutput(value=input.value)
|
||||
|
||||
alias = node(echo, name="test.direct_alias", description="Direct alias spec.")
|
||||
|
||||
assert alias.name == "test.direct_alias"
|
||||
assert alias.description == "Direct alias spec."
|
||||
assert alias.fn is echo.fn
|
||||
assert alias.input_model is AliasInput
|
||||
assert alias.output_model is AliasOutput
|
||||
@@ -8,6 +8,8 @@ from wf_authoring import (
|
||||
bind_state,
|
||||
build_registry,
|
||||
coalesce,
|
||||
constant,
|
||||
default_if_none,
|
||||
first_item,
|
||||
first_item_maybe,
|
||||
first_item_or_none,
|
||||
@@ -15,7 +17,9 @@ from wf_authoring import (
|
||||
last_item,
|
||||
last_item_or_none,
|
||||
length,
|
||||
pick_key,
|
||||
state_path,
|
||||
truthy,
|
||||
)
|
||||
from wf_core import (
|
||||
RunStatus,
|
||||
@@ -197,3 +201,60 @@ def test_coalesce_returns_value_or_fallback() -> None:
|
||||
|
||||
assert present == {"outcome": "ok", "output": {"value": "x"}}
|
||||
assert missing == {"outcome": "ok", "output": {"value": "fallback"}}
|
||||
|
||||
|
||||
def test_default_if_none_is_coalesce_alias() -> None:
|
||||
registry = build_registry(default_if_none)
|
||||
|
||||
result = registry["authoring.default_if_none"](
|
||||
{"value": None, "fallback": "fallback"},
|
||||
RuntimeContext(current_node_id="default_if_none"),
|
||||
)
|
||||
|
||||
assert default_if_none.name == "authoring.default_if_none"
|
||||
assert default_if_none.fn is coalesce.fn
|
||||
assert result == {"outcome": "ok", "output": {"value": "fallback"}}
|
||||
|
||||
|
||||
def test_constant_returns_configured_value() -> None:
|
||||
registry = build_registry(constant)
|
||||
|
||||
result = registry["authoring.constant"](
|
||||
{"value": {"source": "fixture"}},
|
||||
RuntimeContext(current_node_id="constant"),
|
||||
)
|
||||
|
||||
assert result == {"outcome": "ok", "output": {"value": {"source": "fixture"}}}
|
||||
|
||||
|
||||
def test_pick_key_selects_value_from_mapping() -> None:
|
||||
registry = build_registry(pick_key)
|
||||
|
||||
result = registry["authoring.pick_key"](
|
||||
{"mapping": {"name": "Ada", "age": 36}, "key": "name"},
|
||||
RuntimeContext(current_node_id="pick_key"),
|
||||
)
|
||||
|
||||
assert result == {"outcome": "ok", "output": {"value": "Ada"}}
|
||||
|
||||
|
||||
def test_pick_key_returns_none_when_missing() -> None:
|
||||
registry = build_registry(pick_key)
|
||||
|
||||
result = registry["authoring.pick_key"](
|
||||
{"mapping": {"name": "Ada"}, "key": "missing"},
|
||||
RuntimeContext(current_node_id="pick_key"),
|
||||
)
|
||||
|
||||
assert result == {"outcome": "ok", "output": {"value": None}}
|
||||
|
||||
|
||||
def test_truthy_routes_truthy_and_falsey_outcomes() -> None:
|
||||
registry = build_registry(truthy)
|
||||
ctx = RuntimeContext(current_node_id="truthy")
|
||||
|
||||
truthy_result = registry["authoring.truthy"]({"value": "yes"}, ctx)
|
||||
falsey_result = registry["authoring.truthy"]({"value": ""}, ctx)
|
||||
|
||||
assert truthy_result == {"outcome": "truthy", "output": {"value": True}}
|
||||
assert falsey_result == {"outcome": "falsey", "output": {"value": False}}
|
||||
|
||||
Reference in New Issue
Block a user