more std, and buffing @node

This commit is contained in:
lda
2026-05-06 23:17:45 +07:00 Verified
parent 1f8a22d2a9
commit 8d3b88c6e7
7 changed files with 288 additions and 7 deletions
+14
View File
@@ -18,12 +18,17 @@ from .dsl import (
from .ops import ( from .ops import (
BoolOutput, BoolOutput,
CoalesceInput, CoalesceInput,
ConstantInput,
CountOutput, CountOutput,
ItemOutput, ItemOutput,
MaybeItemOutput, MaybeItemOutput,
PickKeyInput,
SequenceInput, SequenceInput,
TruthyInput,
ValueOutput, ValueOutput,
coalesce, coalesce,
constant,
default_if_none,
first_item, first_item,
first_item_maybe, first_item_maybe,
first_item_or_none, first_item_or_none,
@@ -31,6 +36,8 @@ from .ops import (
last_item, last_item,
last_item_or_none, last_item_or_none,
length, length,
pick_key,
truthy,
) )
from .nodes import ( from .nodes import (
AsyncRegistryHandler, AsyncRegistryHandler,
@@ -48,15 +55,18 @@ __all__ = [
"NodeCatalogEntry", "NodeCatalogEntry",
"BoolOutput", "BoolOutput",
"CoalesceInput", "CoalesceInput",
"ConstantInput",
"CountOutput", "CountOutput",
"GraphPath", "GraphPath",
"ItemOutput", "ItemOutput",
"MaybeItemOutput", "MaybeItemOutput",
"PickKeyInput",
"NodeReturn", "NodeReturn",
"NodeSpec", "NodeSpec",
"AsyncRegistryHandler", "AsyncRegistryHandler",
"SyncRegistryHandler", "SyncRegistryHandler",
"SequenceInput", "SequenceInput",
"TruthyInput",
"ValueOutput", "ValueOutput",
"WorkflowBuilder", "WorkflowBuilder",
"bind_fields", "bind_fields",
@@ -64,6 +74,8 @@ __all__ = [
"build_registry", "build_registry",
"bind_state", "bind_state",
"coalesce", "coalesce",
"constant",
"default_if_none",
"merge_maps", "merge_maps",
"context", "context",
"context_path", "context_path",
@@ -79,8 +91,10 @@ __all__ = [
"last_item", "last_item",
"last_item_or_none", "last_item_or_none",
"length", "length",
"pick_key",
"node", "node",
"state", "state",
"state_path", "state_path",
"subgraph_node", "subgraph_node",
"truthy",
] ]
+47 -5
View File
@@ -13,17 +13,39 @@ from .spec import NodeSpec
@overload @overload
def node( def node(
fn: NodeCallable[InputT, OutputT] | AsyncNodeCallable[InputT, OutputT], fn: NodeSpec[InputT, OutputT]
| NodeCallable[InputT, OutputT]
| AsyncNodeCallable[InputT, OutputT],
/, /,
) -> NodeSpec[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 @overload
def node( def node(
fn: None = None, fn: None = None,
/, /,
) -> Callable[ ) -> Callable[
[NodeCallable[InputT, OutputT] | AsyncNodeCallable[InputT, OutputT]], [
NodeSpec[InputT, OutputT]
| NodeCallable[InputT, OutputT]
| AsyncNodeCallable[InputT, OutputT]
],
NodeSpec[InputT, OutputT], NodeSpec[InputT, OutputT],
]: ... ]: ...
@@ -40,13 +62,18 @@ def node(
description: str | None = None, description: str | None = None,
is_async: bool | None = None, is_async: bool | None = None,
) -> Callable[ ) -> Callable[
[NodeCallable[InputT, OutputT] | AsyncNodeCallable[InputT, OutputT]], [
NodeSpec[InputT, OutputT]
| NodeCallable[InputT, OutputT]
| AsyncNodeCallable[InputT, OutputT]
],
NodeSpec[InputT, OutputT], NodeSpec[InputT, OutputT],
]: ... ]: ...
def node( def node(
fn: NodeCallable[InputT, OutputT] fn: NodeSpec[InputT, OutputT]
| NodeCallable[InputT, OutputT]
| AsyncNodeCallable[InputT, OutputT] | AsyncNodeCallable[InputT, OutputT]
| None = None, | None = None,
*, *,
@@ -58,9 +85,24 @@ def node(
is_async: bool | None = None, is_async: bool | None = None,
) -> Any: ) -> Any:
"""Convert a typed Python function into a reusable workflow node spec.""" """Convert a typed Python function into a reusable workflow node spec."""
def decorator( def decorator(
fn: NodeCallable[InputT, OutputT] | AsyncNodeCallable[InputT, OutputT], fn: NodeSpec[InputT, OutputT]
| NodeCallable[InputT, OutputT]
| AsyncNodeCallable[InputT, OutputT],
) -> NodeSpec[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_input_model: type[BaseModel] | None = input_model
inferred_output_model: type[BaseModel] | None = output_model inferred_output_model: type[BaseModel] | None = output_model
if inferred_input_model is None or inferred_output_model is None: if inferred_input_model is None or inferred_output_model is None:
+19 -1
View File
@@ -12,17 +12,33 @@ from .sequences import (
last_item_or_none, last_item_or_none,
length, length,
) )
from .values import CoalesceInput, ValueOutput, coalesce from .values import (
CoalesceInput,
ConstantInput,
PickKeyInput,
TruthyInput,
ValueOutput,
coalesce,
constant,
default_if_none,
pick_key,
truthy,
)
__all__ = [ __all__ = [
"BoolOutput", "BoolOutput",
"CoalesceInput", "CoalesceInput",
"ConstantInput",
"CountOutput", "CountOutput",
"ItemOutput", "ItemOutput",
"MaybeItemOutput", "MaybeItemOutput",
"PickKeyInput",
"SequenceInput", "SequenceInput",
"TruthyInput",
"ValueOutput", "ValueOutput",
"coalesce", "coalesce",
"constant",
"default_if_none",
"first_item", "first_item",
"first_item_maybe", "first_item_maybe",
"first_item_or_none", "first_item_or_none",
@@ -30,4 +46,6 @@ __all__ = [
"last_item", "last_item",
"last_item_or_none", "last_item_or_none",
"length", "length",
"pick_key",
"truthy",
] ]
+17
View File
@@ -8,22 +8,32 @@ from wf_authoring.nodes import NodeReturn, node
class SequenceInput(BaseModel): class SequenceInput(BaseModel):
"""Input model for ops that consume an ordered sequence."""
items: list[Any] items: list[Any]
class ItemOutput(BaseModel): class ItemOutput(BaseModel):
"""Output model for ops that return a selected item."""
item: Any item: Any
class MaybeItemOutput(BaseModel): class MaybeItemOutput(BaseModel):
"""Output model for ops that may not find an item."""
item: Any | None = None item: Any | None = None
class CountOutput(BaseModel): class CountOutput(BaseModel):
"""Output model for ops that return a count."""
count: int count: int
class BoolOutput(BaseModel): class BoolOutput(BaseModel):
"""Output model for ops that return a boolean value."""
value: bool value: bool
@@ -34,6 +44,7 @@ class BoolOutput(BaseModel):
description="Select the first item from a non-empty sequence.", description="Select the first item from a non-empty sequence.",
) )
def first_item(input: SequenceInput) -> ItemOutput: def first_item(input: SequenceInput) -> ItemOutput:
"""Select the first item from a non-empty sequence."""
if not input.items: if not input.items:
raise ValueError("first_item requires at least one item") raise ValueError("first_item requires at least one item")
return ItemOutput(item=input.items[0]) 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.", description="Select the first item from a sequence, or None when it is empty.",
) )
def first_item_or_none(input: SequenceInput) -> ItemOutput: 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) 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.", description="Select the first item from a sequence, routing to found or missing.",
) )
def first_item_maybe(input: SequenceInput) -> NodeReturn[MaybeItemOutput]: def first_item_maybe(input: SequenceInput) -> NodeReturn[MaybeItemOutput]:
"""Select the first item and route by whether one exists."""
if not input.items: if not input.items:
return NodeReturn(outcome="missing", output=MaybeItemOutput()) return NodeReturn(outcome="missing", output=MaybeItemOutput())
return NodeReturn(outcome="found", output=MaybeItemOutput(item=input.items[0])) 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.", description="Select the last item from a non-empty sequence.",
) )
def last_item(input: SequenceInput) -> ItemOutput: def last_item(input: SequenceInput) -> ItemOutput:
"""Select the last item from a non-empty sequence."""
if not input.items: if not input.items:
raise ValueError("last_item requires at least one item") raise ValueError("last_item requires at least one item")
return ItemOutput(item=input.items[-1]) 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.", description="Select the last item from a sequence, or None when it is empty.",
) )
def last_item_or_none(input: SequenceInput) -> ItemOutput: 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) 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.", description="Count the items in a sequence.",
) )
def length(input: SequenceInput) -> CountOutput: def length(input: SequenceInput) -> CountOutput:
"""Count the number of items in a sequence."""
return CountOutput(count=len(input.items)) return CountOutput(count=len(input.items))
@@ -101,4 +117,5 @@ def length(input: SequenceInput) -> CountOutput:
description="Return whether a sequence is empty.", description="Return whether a sequence is empty.",
) )
def is_empty(input: SequenceInput) -> BoolOutput: def is_empty(input: SequenceInput) -> BoolOutput:
"""Return whether a sequence has no items."""
return BoolOutput(value=not input.items) return BoolOutput(value=not input.items)
+68 -1
View File
@@ -4,15 +4,38 @@ from typing import Any
from pydantic import BaseModel from pydantic import BaseModel
from wf_authoring.nodes import node from wf_authoring.nodes import NodeReturn, node
class CoalesceInput(BaseModel): class CoalesceInput(BaseModel):
"""Input model for selecting the first non-None value."""
value: Any | None = None value: Any | None = None
fallback: Any fallback: Any
class ValueOutput(BaseModel): 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 value: Any
@@ -23,4 +46,48 @@ class ValueOutput(BaseModel):
description="Return value when it is not None, otherwise return fallback.", description="Return value when it is not None, otherwise return fallback.",
) )
def coalesce(input: CoalesceInput) -> ValueOutput: 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) 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))
+62
View File
@@ -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
+61
View File
@@ -8,6 +8,8 @@ from wf_authoring import (
bind_state, bind_state,
build_registry, build_registry,
coalesce, coalesce,
constant,
default_if_none,
first_item, first_item,
first_item_maybe, first_item_maybe,
first_item_or_none, first_item_or_none,
@@ -15,7 +17,9 @@ from wf_authoring import (
last_item, last_item,
last_item_or_none, last_item_or_none,
length, length,
pick_key,
state_path, state_path,
truthy,
) )
from wf_core import ( from wf_core import (
RunStatus, RunStatus,
@@ -197,3 +201,60 @@ def test_coalesce_returns_value_or_fallback() -> None:
assert present == {"outcome": "ok", "output": {"value": "x"}} assert present == {"outcome": "ok", "output": {"value": "x"}}
assert missing == {"outcome": "ok", "output": {"value": "fallback"}} 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}}