some more ops

could we make subgraphs as ops or patterns like this?
This commit is contained in:
lda
2026-05-29 22:19:55 +07:00 Verified
parent c3ce74dd7b
commit 4211d1b7fa
7 changed files with 225 additions and 1 deletions
+20
View File
@@ -22,10 +22,15 @@ from .dsl import (
from .ops import ( from .ops import (
BoolOutput, BoolOutput,
CoalesceInput, CoalesceInput,
ConcatInput,
ConstantInput, ConstantInput,
CountOutput, CountOutput,
ExtractFieldInput,
FilterItemsInput,
FilterItemsPresentInput,
ItemOutput, ItemOutput,
MappingOutput, MappingOutput,
MappingItemsOutput,
MaybeItemOutput, MaybeItemOutput,
PickPathInput, PickPathInput,
PickKeyInput, PickKeyInput,
@@ -33,11 +38,16 @@ from .ops import (
RenameFieldsInput, RenameFieldsInput,
RuntimeErrorInput, RuntimeErrorInput,
SequenceInput, SequenceInput,
TextOutput,
TruthyInput, TruthyInput,
ValueOutput, ValueOutput,
coalesce, coalesce,
concat,
constant, constant,
default_if_none, default_if_none,
extract_field,
filter_items,
filter_items_present,
first_item, first_item,
first_item_maybe, first_item_maybe,
first_item_or_none, first_item_or_none,
@@ -75,11 +85,16 @@ __all__ = [
"async_subgraph_node", "async_subgraph_node",
"BoolOutput", "BoolOutput",
"CoalesceInput", "CoalesceInput",
"ConcatInput",
"ConstantInput", "ConstantInput",
"CountOutput", "CountOutput",
"ExtractFieldInput",
"FilterItemsInput",
"FilterItemsPresentInput",
"GraphPath", "GraphPath",
"ItemOutput", "ItemOutput",
"MappingOutput", "MappingOutput",
"MappingItemsOutput",
"MaybeItemOutput", "MaybeItemOutput",
"PickKeyInput", "PickKeyInput",
"PickPathInput", "PickPathInput",
@@ -95,6 +110,7 @@ __all__ = [
"SyncRegistryHandler", "SyncRegistryHandler",
"SequenceInput", "SequenceInput",
"StateFieldMetadata", "StateFieldMetadata",
"TextOutput",
"TruthyInput", "TruthyInput",
"ValueOutput", "ValueOutput",
"WorkflowBuilder", "WorkflowBuilder",
@@ -103,8 +119,12 @@ __all__ = [
"build_registry", "build_registry",
"bind_state", "bind_state",
"coalesce", "coalesce",
"concat",
"constant", "constant",
"default_if_none", "default_if_none",
"extract_field",
"filter_items",
"filter_items_present",
"merge_maps", "merge_maps",
"context", "context",
"context_path", "context_path",
+22
View File
@@ -1,9 +1,17 @@
from .sequences import ( from .sequences import (
BoolOutput, BoolOutput,
CountOutput, CountOutput,
ExtractFieldInput,
FilterItemsInput,
FilterItemsPresentInput,
ItemOutput, ItemOutput,
MappingItemsOutput,
MaybeItemOutput, MaybeItemOutput,
SequenceInput, SequenceInput,
ValuesOutput,
extract_field,
filter_items,
filter_items_present,
first_item, first_item,
first_item_maybe, first_item_maybe,
first_item_or_none, first_item_or_none,
@@ -14,6 +22,7 @@ from .sequences import (
) )
from .values import ( from .values import (
CoalesceInput, CoalesceInput,
ConcatInput,
ConstantInput, ConstantInput,
MappingOutput, MappingOutput,
PickPathInput, PickPathInput,
@@ -22,8 +31,10 @@ from .values import (
RenameFieldsInput, RenameFieldsInput,
RuntimeErrorInput, RuntimeErrorInput,
TruthyInput, TruthyInput,
TextOutput,
ValueOutput, ValueOutput,
coalesce, coalesce,
concat,
constant, constant,
default_if_none, default_if_none,
pick_path, pick_path,
@@ -37,10 +48,15 @@ from .values import (
__all__ = [ __all__ = [
"BoolOutput", "BoolOutput",
"CoalesceInput", "CoalesceInput",
"ConcatInput",
"ConstantInput", "ConstantInput",
"CountOutput", "CountOutput",
"ExtractFieldInput",
"FilterItemsInput",
"FilterItemsPresentInput",
"ItemOutput", "ItemOutput",
"MappingOutput", "MappingOutput",
"MappingItemsOutput",
"MaybeItemOutput", "MaybeItemOutput",
"PickPathInput", "PickPathInput",
"PickKeyInput", "PickKeyInput",
@@ -49,10 +65,16 @@ __all__ = [
"RuntimeErrorInput", "RuntimeErrorInput",
"SequenceInput", "SequenceInput",
"TruthyInput", "TruthyInput",
"TextOutput",
"ValueOutput", "ValueOutput",
"ValuesOutput",
"coalesce", "coalesce",
"concat",
"constant", "constant",
"default_if_none", "default_if_none",
"extract_field",
"filter_items",
"filter_items_present",
"first_item", "first_item",
"first_item_maybe", "first_item_maybe",
"first_item_or_none", "first_item_or_none",
+71
View File
@@ -37,6 +37,40 @@ class BoolOutput(BaseModel):
value: bool value: bool
class FilterItemsInput(BaseModel):
"""Input model for filtering mapping items by exact key/value match."""
items: list[dict[str, Any]]
key: str
value: Any
class FilterItemsPresentInput(BaseModel):
"""Input model for filtering mapping items that contain a key."""
items: list[dict[str, Any]]
key: str
class MappingItemsOutput(BaseModel):
"""Output model for ops that return mapping items."""
items: list[dict[str, Any]]
class ExtractFieldInput(BaseModel):
"""Input model for extracting one field from mapping items."""
items: list[dict[str, Any]]
field: str
class ValuesOutput(BaseModel):
"""Output model for ops that return arbitrary values."""
values: list[Any]
@node( @node(
name="authoring.first_item", name="authoring.first_item",
input_model=SequenceInput, input_model=SequenceInput,
@@ -119,3 +153,40 @@ def length(input: SequenceInput) -> CountOutput:
def is_empty(input: SequenceInput) -> BoolOutput: def is_empty(input: SequenceInput) -> BoolOutput:
"""Return whether a sequence has no items.""" """Return whether a sequence has no items."""
return BoolOutput(value=not input.items) return BoolOutput(value=not input.items)
@node(
name="authoring.filter_items",
input_model=FilterItemsInput,
output_model=MappingItemsOutput,
description="Filter mapping items by exact key/value match.",
)
def filter_items(input: FilterItemsInput) -> MappingItemsOutput:
"""Return items where item[key] exactly equals value."""
return MappingItemsOutput(
items=[item for item in input.items if item.get(input.key) == input.value]
)
@node(
name="authoring.filter_items_present",
input_model=FilterItemsPresentInput,
output_model=MappingItemsOutput,
description="Filter mapping items to those containing the requested key.",
)
def filter_items_present(input: FilterItemsPresentInput) -> MappingItemsOutput:
"""Return items that contain key, regardless of the stored value."""
return MappingItemsOutput(items=[item for item in input.items if input.key in item])
@node(
name="authoring.extract_field",
input_model=ExtractFieldInput,
output_model=ValuesOutput,
description="Extract one field from each mapping item that contains it.",
)
def extract_field(input: ExtractFieldInput) -> ValuesOutput:
"""Return item[field] for each item containing field."""
return ValuesOutput(
values=[item[input.field] for item in input.items if input.field in item]
)
+24
View File
@@ -73,6 +73,19 @@ class RuntimeErrorInput(BaseModel):
details: dict[str, Any] = Field(default_factory=dict) details: dict[str, Any] = Field(default_factory=dict)
class ConcatInput(BaseModel):
"""Input model for joining string values."""
items: list[str]
separator: str = ""
class TextOutput(BaseModel):
"""Output model for ops that emit text."""
text: str
@node( @node(
name="authoring.coalesce", name="authoring.coalesce",
input_model=CoalesceInput, input_model=CoalesceInput,
@@ -180,6 +193,17 @@ def truthy(input: TruthyInput) -> NodeReturn[ValueOutput]:
return NodeReturn(outcome=outcome, output=ValueOutput(value=value)) return NodeReturn(outcome=outcome, output=ValueOutput(value=value))
@node(
name="authoring.concat",
input_model=ConcatInput,
output_model=TextOutput,
description="Join string items using separator.",
)
def concat(input: ConcatInput) -> TextOutput:
"""Join string items using separator."""
return TextOutput(text=input.separator.join(input.items))
@node( @node(
name="authoring.runtime_error", name="authoring.runtime_error",
input_model=RuntimeErrorInput, input_model=RuntimeErrorInput,
+6 -1
View File
@@ -2,7 +2,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from wf_authoring import NodeSpec, coalesce, constant, default_if_none, first_item from wf_authoring import NodeSpec, coalesce, concat, constant, default_if_none
from wf_authoring import extract_field, filter_items, filter_items_present, first_item
from wf_authoring import first_item_maybe, first_item_or_none, is_empty, last_item from wf_authoring import first_item_maybe, first_item_or_none, is_empty, last_item
from wf_authoring import last_item_or_none, length, node, pick_key, pick_path from wf_authoring import last_item_or_none, length, node, pick_key, pick_path
from wf_authoring import project_fields, rename_fields, runtime_error, truthy from wf_authoring import project_fields, rename_fields, runtime_error, truthy
@@ -43,6 +44,10 @@ AUTHORING_STD_SPECS: tuple[NodeSpec[Any, Any], ...] = (
last_item_or_none, last_item_or_none,
length, length,
is_empty, is_empty,
filter_items,
filter_items_present,
extract_field,
concat,
) )
"""Existing authoring ops that are also exposed through the workflow stdlib.""" """Existing authoring ops that are also exposed through the workflow stdlib."""
+78
View File
@@ -8,6 +8,10 @@ from wf_authoring import (
coalesce, coalesce,
constant, constant,
default_if_none, default_if_none,
concat,
extract_field,
filter_items,
filter_items_present,
first_item, first_item,
first_item_maybe, first_item_maybe,
first_item_or_none, first_item_or_none,
@@ -303,6 +307,80 @@ def test_rename_fields_remaps_existing_keys() -> None:
} }
def test_filter_items_selects_mapping_items_by_exact_match() -> None:
registry = build_registry(filter_items)
result = registry["authoring.filter_items"](
{
"items": [
{"type": "text", "text": "a"},
{"type": "image", "url": "img://1"},
{"type": "text", "text": "b"},
],
"key": "type",
"value": "text",
},
RuntimeContext(current_node_id="filter_items"),
)
output = result["output"]
assert result["outcome"] == "ok"
assert output["items"][0]["text"] == "a"
assert output["items"][1]["text"] == "b"
def test_filter_items_present_selects_mapping_items_containing_key() -> None:
registry = build_registry(filter_items_present)
result = registry["authoring.filter_items_present"](
{
"items": [
{"text": "a"},
{"url": "img://1"},
{"text": None},
],
"key": "text",
},
RuntimeContext(current_node_id="filter_items_present"),
)
output = result["output"]
assert result["outcome"] == "ok"
assert output["items"][0]["text"] == "a"
assert output["items"][1]["text"] is None
def test_extract_field_returns_existing_field_values() -> None:
registry = build_registry(extract_field)
result = registry["authoring.extract_field"](
{
"items": [
{"text": "a"},
{"url": "img://1"},
{"text": "b"},
],
"field": "text",
},
RuntimeContext(current_node_id="extract_field"),
)
assert result["outcome"] == "ok"
assert result["output"]["values"] == ["a", "b"]
def test_concat_joins_strings_with_separator() -> None:
registry = build_registry(concat)
result = registry["authoring.concat"](
{"items": ["a", "b", "c"], "separator": "\n"},
RuntimeContext(current_node_id="concat"),
)
assert result["outcome"] == "ok"
assert result["output"]["text"] == "a\nb\nc"
def test_truthy_routes_truthy_and_falsey_outcomes() -> None: def test_truthy_routes_truthy_and_falsey_outcomes() -> None:
registry = build_registry(truthy) registry = build_registry(truthy)
ctx = RuntimeContext(current_node_id="truthy") ctx = RuntimeContext(current_node_id="truthy")
+4
View File
@@ -237,6 +237,10 @@ def test_wf_std_source_contains_authoring_ops() -> None:
"wf.std.last_item_or_none", "wf.std.last_item_or_none",
"wf.std.length", "wf.std.length",
"wf.std.is_empty", "wf.std.is_empty",
"wf.std.filter_items",
"wf.std.filter_items_present",
"wf.std.extract_field",
"wf.std.concat",
} }
assert set(specs) == expected assert set(specs) == expected