From 5be2652de024e8900339e2a83c99f354f4b39620 Mon Sep 17 00:00:00 2001
From: Chris Trevino <chtrevin@microsoft.com>
Date: Mon, 24 Jun 2024 15:45:44 -0700
Subject: [PATCH 1/3] add reactive dataflow library

---
 .../datashaper/datashaper/verbs/__init__.py   |   4 +-
 .../datashaper/datashaper/verbs/aggregate.py  |  19 +-
 python/datashaper/datashaper/verbs/bin.py     |  27 ++-
 .../datashaper/datashaper/verbs/binarize.py   |   4 +-
 python/datashaper/datashaper/verbs/boolean.py |   4 +-
 python/datashaper/datashaper/verbs/concat.py  |   4 +-
 python/datashaper/datashaper/verbs/convert.py |   4 +-
 python/datashaper/datashaper/verbs/copy.py    |   4 +-
 .../datashaper/verbs/decorators/__init__.py   |   6 +-
 .../verbs/decorators/copy_input_tables.py     |  42 ++++
 .../datashaper/verbs/decorators/inputs.py     |  51 -----
 .../datashaper/verbs/decorators/verb.py       |  46 -----
 .../{outputs.py => wrap_verb_result.py}       |   2 +-
 python/datashaper/datashaper/verbs/dedupe.py  |   7 +-
 python/datashaper/datashaper/verbs/derive.py  |   4 +-
 .../datashaper/verbs/destructure.py           |   4 +-
 .../datashaper/datashaper/verbs/difference.py |   4 +-
 python/datashaper/datashaper/verbs/drop.py    |   4 +-
 .../datashaper/verbs/engine/verb_manager.py   |  51 -----
 python/datashaper/datashaper/verbs/erase.py   |   4 +-
 python/datashaper/datashaper/verbs/fill.py    |   4 +-
 python/datashaper/datashaper/verbs/fold.py    |   4 +-
 python/datashaper/datashaper/verbs/groupby.py |   4 +-
 python/datashaper/datashaper/verbs/impute.py  |   4 +-
 .../datashaper/datashaper/verbs/intersect.py  |   4 +-
 python/datashaper/datashaper/verbs/join.py    |   4 +-
 python/datashaper/datashaper/verbs/lookup.py  |   4 +-
 python/datashaper/datashaper/verbs/merge.py   |   4 +-
 python/datashaper/datashaper/verbs/onehot.py  |   4 +-
 python/datashaper/datashaper/verbs/orderby.py |   4 +-
 python/datashaper/datashaper/verbs/pivot.py   |   4 +-
 python/datashaper/datashaper/verbs/print.py   |   4 +-
 python/datashaper/datashaper/verbs/recode.py  |   4 +-
 python/datashaper/datashaper/verbs/rename.py  |   4 +-
 python/datashaper/datashaper/verbs/rollup.py  |   4 +-
 python/datashaper/datashaper/verbs/sample.py  |   4 +-
 python/datashaper/datashaper/verbs/select.py  |   4 +-
 .../datashaper/datashaper/verbs/snapshot.py   |   4 +-
 python/datashaper/datashaper/verbs/spread.py  |   4 +-
 .../datashaper/verbs/strings/lower.py         |   4 +-
 .../datashaper/verbs/strings/replace.py       |   4 +-
 .../datashaper/verbs/strings/upper.py         |   4 +-
 python/datashaper/datashaper/verbs/unfold.py  |   4 +-
 python/datashaper/datashaper/verbs/ungroup.py |   4 +-
 python/datashaper/datashaper/verbs/unhot.py   |   4 +-
 python/datashaper/datashaper/verbs/union.py   |   4 +-
 python/datashaper/datashaper/verbs/unorder.py |   4 +-
 python/datashaper/datashaper/verbs/unroll.py  |   7 +-
 python/datashaper/datashaper/verbs/window.py  |   4 +-
 .../datashaper/datashaper/verbs/workflow.py   |   4 +-
 python/datashaper/poetry.lock                 | 184 +++++++++++++++++-
 python/datashaper/pyproject.toml              |   1 +
 52 files changed, 350 insertions(+), 253 deletions(-)
 create mode 100644 python/datashaper/datashaper/verbs/decorators/copy_input_tables.py
 delete mode 100644 python/datashaper/datashaper/verbs/decorators/inputs.py
 delete mode 100644 python/datashaper/datashaper/verbs/decorators/verb.py
 rename python/datashaper/datashaper/verbs/decorators/{outputs.py => wrap_verb_result.py} (98%)
 delete mode 100644 python/datashaper/datashaper/verbs/engine/verb_manager.py

diff --git a/python/datashaper/datashaper/verbs/__init__.py b/python/datashaper/datashaper/verbs/__init__.py
index ef9653341..ac006263e 100644
--- a/python/datashaper/datashaper/verbs/__init__.py
+++ b/python/datashaper/datashaper/verbs/__init__.py
@@ -16,9 +16,9 @@
     OutputMode,
     ParallelizationMode,
     inputs,
-    outputs,
     parallel_verb,
     verb,
+    wrap_verb_result,
 )
 from .dedupe import dedupe
 from .derive import derive
@@ -145,7 +145,7 @@
     # Decorators
     "verb",
     "inputs",
-    "outputs",
+    "wrap_verb_result",
     "parallel_verb",
     "OutputMode",
     "ParallelizationMode",
diff --git a/python/datashaper/datashaper/verbs/aggregate.py b/python/datashaper/datashaper/verbs/aggregate.py
index 711458f4f..8d07d29d9 100644
--- a/python/datashaper/datashaper/verbs/aggregate.py
+++ b/python/datashaper/datashaper/verbs/aggregate.py
@@ -8,22 +8,28 @@
 from typing import Any, cast
 
 import pandas as pd
+from reactivedataflow import ConfigPort, InputPort, verb
+
+from datashaper.constants import DEFAULT_INPUT_NAME
 
 from .decorators import (
     OutputMode,
-    inputs,
-    outputs,
-    verb,
+    wrap_verb_result,
 )
 from .types import FieldAggregateOperation
 
 
 @verb(
     name="aggregate",
-    immutable_input=True,
+    ports=[
+        InputPort(name=DEFAULT_INPUT_NAME, parameter="table", required=True),
+        ConfigPort(name="to", required=True),
+        ConfigPort(name="groupby", required=True),
+        ConfigPort(name="column", required=True),
+        ConfigPort(name="operation", required=True),
+    ],
     adapters=[
-        inputs(default_input_argname="table"),
-        outputs(mode=OutputMode.Table),
+        wrap_verb_result(mode=OutputMode.Table),
     ],
 )
 def aggregate(
@@ -32,7 +38,6 @@ def aggregate(
     groupby: list[str],
     column: str,
     operation: FieldAggregateOperation,
-    **_kwargs: Any,
 ) -> pd.DataFrame:
     """Aggregate verb implementation."""
     result = cast(
diff --git a/python/datashaper/datashaper/verbs/bin.py b/python/datashaper/datashaper/verbs/bin.py
index db3eda3be..0879206d5 100644
--- a/python/datashaper/datashaper/verbs/bin.py
+++ b/python/datashaper/datashaper/verbs/bin.py
@@ -8,13 +8,11 @@
 
 import numpy as np
 import pandas as pd
+from reactivedataflow import ConfigPort, InputPort, verb
 
-from .decorators import (
-    OutputMode,
-    inputs,
-    outputs,
-    verb,
-)
+from datashaper.constants import DEFAULT_INPUT_NAME
+
+from .decorators import OutputMode, copy_input_tables, wrap_verb_result
 from .types import BinStrategy
 
 
@@ -79,9 +77,21 @@ def __get_bucket_value(
 
 @verb(
     name="bin",
+    ports=[
+        InputPort(name=DEFAULT_INPUT_NAME, parameter="table", required=True),
+        ConfigPort(name="to", required=True),
+        ConfigPort(name="column", required=True),
+        ConfigPort(name="strategy", required=True),
+        ConfigPort(name="min", required=False),
+        ConfigPort(name="max", required=False),
+        ConfigPort(name="fixedcount", required=False),
+        ConfigPort(name="fixedwidth", required=False),
+        ConfigPort(name="clamped", required=False),
+        ConfigPort(name="printRange", required=False),
+    ],
     adapters=[
-        inputs(default_input_argname="table"),
-        outputs(mode=OutputMode.Table),
+        copy_input_tables("table"),
+        wrap_verb_result(mode=OutputMode.Table),
     ],
 )
 def bin(  # noqa A001 - use ds verb name
@@ -95,7 +105,6 @@ def bin(  # noqa A001 - use ds verb name
     fixedwidth: int | None = None,
     clamped: bool | None = False,
     printRange: bool | None = False,  # noqa: N803
-    **_kwargs: Any,
 ) -> pd.DataFrame:
     """Bin verb implementation."""
     bin_strategy = BinStrategy(strategy)
diff --git a/python/datashaper/datashaper/verbs/binarize.py b/python/datashaper/datashaper/verbs/binarize.py
index 65b78b0ee..d05741cec 100644
--- a/python/datashaper/datashaper/verbs/binarize.py
+++ b/python/datashaper/datashaper/verbs/binarize.py
@@ -8,7 +8,7 @@
 
 import pandas as pd
 
-from .decorators import OutputMode, inputs, outputs, verb
+from .decorators import OutputMode, inputs, verb, wrap_verb_result
 from .filter import filter, get_comparison_operator
 from .types import (
     ComparisonStrategy,
@@ -21,7 +21,7 @@
     name="binarize",
     adapters=[
         inputs(default_input_argname="table"),
-        outputs(mode=OutputMode.Table),
+        wrap_verb_result(mode=OutputMode.Table),
     ],
 )
 def binarize(
diff --git a/python/datashaper/datashaper/verbs/boolean.py b/python/datashaper/datashaper/verbs/boolean.py
index b8df11bab..4941e5394 100644
--- a/python/datashaper/datashaper/verbs/boolean.py
+++ b/python/datashaper/datashaper/verbs/boolean.py
@@ -8,7 +8,7 @@
 
 import pandas as pd
 
-from .decorators import OutputMode, inputs, outputs, verb
+from .decorators import OutputMode, inputs, verb, wrap_verb_result
 from .filter import boolean_function_map
 from .types import BooleanLogicalOperator
 
@@ -17,7 +17,7 @@
     name="boolean",
     adapters=[
         inputs(default_input_argname="table"),
-        outputs(mode=OutputMode.Table),
+        wrap_verb_result(mode=OutputMode.Table),
     ],
 )
 def boolean(
diff --git a/python/datashaper/datashaper/verbs/concat.py b/python/datashaper/datashaper/verbs/concat.py
index 7332b3554..760b8d3f9 100644
--- a/python/datashaper/datashaper/verbs/concat.py
+++ b/python/datashaper/datashaper/verbs/concat.py
@@ -8,7 +8,7 @@
 
 import pandas as pd
 
-from .decorators import OutputMode, inputs, outputs, verb
+from .decorators import OutputMode, inputs, verb, wrap_verb_result
 
 
 @verb(
@@ -16,7 +16,7 @@
     immutable_input=True,
     adapters=[
         inputs(default_input_argname="table", variadic_input_argname="others"),
-        outputs(mode=OutputMode.Table),
+        wrap_verb_result(mode=OutputMode.Table),
     ],
 )
 def concat(
diff --git a/python/datashaper/datashaper/verbs/convert.py b/python/datashaper/datashaper/verbs/convert.py
index 031284512..c26a77428 100644
--- a/python/datashaper/datashaper/verbs/convert.py
+++ b/python/datashaper/datashaper/verbs/convert.py
@@ -13,7 +13,7 @@
 import pandas as pd
 from pandas.api.types import is_bool_dtype, is_datetime64_any_dtype, is_numeric_dtype
 
-from .decorators import OutputMode, inputs, outputs, verb
+from .decorators import OutputMode, inputs, verb, wrap_verb_result
 from .types import ParseType
 
 
@@ -118,7 +118,7 @@ def convert_value(value: Any) -> list:
     name="convert",
     adapters=[
         inputs(default_input_argname="table"),
-        outputs(mode=OutputMode.Table),
+        wrap_verb_result(mode=OutputMode.Table),
     ],
 )
 def convert(
diff --git a/python/datashaper/datashaper/verbs/copy.py b/python/datashaper/datashaper/verbs/copy.py
index 3494fd7b1..4517e2c04 100644
--- a/python/datashaper/datashaper/verbs/copy.py
+++ b/python/datashaper/datashaper/verbs/copy.py
@@ -8,14 +8,14 @@
 
 import pandas as pd
 
-from .decorators import OutputMode, inputs, outputs, verb
+from .decorators import OutputMode, inputs, verb, wrap_verb_result
 
 
 @verb(
     name="copy",
     adapters=[
         inputs(default_input_argname="table"),
-        outputs(mode=OutputMode.Table),
+        wrap_verb_result(mode=OutputMode.Table),
     ],
 )
 def copy(
diff --git a/python/datashaper/datashaper/verbs/decorators/__init__.py b/python/datashaper/datashaper/verbs/decorators/__init__.py
index 22f38f2c8..647159e82 100644
--- a/python/datashaper/datashaper/verbs/decorators/__init__.py
+++ b/python/datashaper/datashaper/verbs/decorators/__init__.py
@@ -1,18 +1,20 @@
 """Datashaper Function Decorators."""
 
 from .apply_decorators import apply_decorators
+from .copy_input_tables import copy_input_tables
 from .inputs import inputs
-from .outputs import OutputMode, outputs
 from .parallel_verb import ParallelizationMode, new_row, parallel_verb
 from .verb import verb
+from .wrap_verb_result import OutputMode, wrap_verb_result
 
 __all__ = [
     "new_row",
     "verb",
+    "copy_input_tables",
     "parallel_verb",
     "ParallelizationMode",
     "inputs",
     "apply_decorators",
-    "outputs",
+    "wrap_verb_result",
     "OutputMode",
 ]
diff --git a/python/datashaper/datashaper/verbs/decorators/copy_input_tables.py b/python/datashaper/datashaper/verbs/decorators/copy_input_tables.py
new file mode 100644
index 000000000..a2d67a9b7
--- /dev/null
+++ b/python/datashaper/datashaper/verbs/decorators/copy_input_tables.py
@@ -0,0 +1,42 @@
+#
+# Copyright (c) Microsoft. All rights reserved.
+# Licensed under the MIT license. See LICENSE file in the project.
+#
+"""Verb inputs decorator."""
+
+from collections.abc import Callable
+from typing import Any, ParamSpec, TypeVar, cast
+
+import pandas as pd
+
+from datashaper.verbs.engine import VerbInput
+
+T = TypeVar("T")
+P = ParamSpec("P")
+
+
+def copy_input_tables(
+    *names: str,
+) -> Callable[[Callable[P, T]], Callable[[VerbInput], T]]:
+    """Decorate an execution function with input conditions.
+
+    Args:
+        required (list[str] | None): The list of required input names. Defaults to None. If present, the function will only execute if all required inputs are present.
+    """
+
+    def wrap_executor_function(
+        fn: Callable[P, T],
+    ) -> Callable[[VerbInput], T]:
+        def wrapped_fn(*args: P.args, **kwargs: P.kwargs) -> T:
+            fn_args: dict[str, Any] = {
+                **kwargs,
+            }
+            for name in names:
+                if name not in fn_args:
+                    raise ValueError
+                fn_args[name] = cast(pd.DataFrame, fn_args[name].copy())
+            return fn(*args, **fn_args)
+
+        return wrapped_fn
+
+    return wrap_executor_function
diff --git a/python/datashaper/datashaper/verbs/decorators/inputs.py b/python/datashaper/datashaper/verbs/decorators/inputs.py
deleted file mode 100644
index 75fe6b88c..000000000
--- a/python/datashaper/datashaper/verbs/decorators/inputs.py
+++ /dev/null
@@ -1,51 +0,0 @@
-#
-# Copyright (c) Microsoft. All rights reserved.
-# Licensed under the MIT license. See LICENSE file in the project.
-#
-"""Verb inputs decorator."""
-
-from collections.abc import Callable
-from typing import Any, ParamSpec, TypeVar
-
-from datashaper.verbs.engine import VerbInput
-
-T = TypeVar("T")
-P = ParamSpec("P")
-
-
-def inputs(
-    input_argnames: dict[str, str] | None = None,
-    default_input_argname: str | None = None,
-    variadic_input_argname: str | None = None,
-    input_dict_argname: str | None = None,
-) -> Callable[[Callable[P, T]], Callable[[VerbInput], T]]:
-    """Decorate an execution function with input conditions.
-
-    Args:
-        required (list[str] | None): The list of required input names. Defaults to None. If present, the function will only execute if all required inputs are present.
-    """
-
-    def wrap_executor_function(
-        fn: Callable[P, T],
-    ) -> Callable[[VerbInput], T]:
-        def wrapped_fn(input: VerbInput, *args: P.args, **kwargs: P.kwargs) -> T:
-            fn_args: dict[str, Any] = {
-                v: input.get_named_input(k) for k, v in (input_argnames or {}).items()
-            }
-            if default_input_argname is not None:
-                fn_args[default_input_argname] = input.get_input()
-            if variadic_input_argname is not None:
-                fn_args[variadic_input_argname] = input.get_others()
-            if input_dict_argname is not None:
-                fn_args[input_dict_argname] = input.get_named_inputs()
-
-            for k, v in kwargs.items():
-                if k not in fn_args:
-                    fn_args[k] = v
-
-            # Use named_args as needed
-            return fn(*args, **fn_args)
-
-        return wrapped_fn
-
-    return wrap_executor_function
diff --git a/python/datashaper/datashaper/verbs/decorators/verb.py b/python/datashaper/datashaper/verbs/decorators/verb.py
deleted file mode 100644
index 7970f0d7b..000000000
--- a/python/datashaper/datashaper/verbs/decorators/verb.py
+++ /dev/null
@@ -1,46 +0,0 @@
-#
-# Copyright (c) Microsoft. All rights reserved.
-# Licensed under the MIT license. See LICENSE file in the project.
-#
-"""Verb decorators and manager."""
-
-import logging
-from collections.abc import Callable
-from typing import Any, cast
-
-from datashaper.verbs.engine import (
-    VerbDetails,
-    VerbManager,
-    VerbResult,
-)
-
-from .apply_decorators import apply_decorators
-
-log = logging.getLogger(__name__)
-
-
-def verb(
-    name: str,
-    override: bool = False,
-    immutable_input: bool = False,
-    adapters: list[Callable[[Callable], Callable]] | None = None,
-    registry: VerbManager | None = None,
-    **_kwargs: Any,
-) -> Callable:
-    """Apply a decorator for registering a verb."""
-    registry = registry or VerbManager.get()
-
-    def registered_verb(verb_function: Callable) -> Callable[..., VerbResult]:
-        fn = verb_function
-        if adapters:
-            fn = apply_decorators(adapters, fn)
-        fn = cast(Callable[..., VerbResult], fn)
-        verb = VerbDetails(
-            name=name,
-            func=fn,
-            treats_input_tables_as_immutable=immutable_input,
-        )
-        registry.register(verb, override)
-        return verb_function
-
-    return registered_verb
diff --git a/python/datashaper/datashaper/verbs/decorators/outputs.py b/python/datashaper/datashaper/verbs/decorators/wrap_verb_result.py
similarity index 98%
rename from python/datashaper/datashaper/verbs/decorators/outputs.py
rename to python/datashaper/datashaper/verbs/decorators/wrap_verb_result.py
index e9dcbcae5..0755a01a4 100644
--- a/python/datashaper/datashaper/verbs/decorators/outputs.py
+++ b/python/datashaper/datashaper/verbs/decorators/wrap_verb_result.py
@@ -23,7 +23,7 @@ class OutputMode(str, Enum):
 P = ParamSpec("P")
 
 
-def outputs(
+def wrap_verb_result(
     mode: OutputMode,
     output_names: list[str] | None = None,
 ) -> Callable[[Callable[..., Any]], Callable[..., VerbResult]]:
diff --git a/python/datashaper/datashaper/verbs/dedupe.py b/python/datashaper/datashaper/verbs/dedupe.py
index 40c7869c0..e1d43b432 100644
--- a/python/datashaper/datashaper/verbs/dedupe.py
+++ b/python/datashaper/datashaper/verbs/dedupe.py
@@ -8,13 +8,16 @@
 
 import pandas as pd
 
-from .decorators import OutputMode, inputs, outputs, verb
+from .decorators import OutputMode, inputs, verb, wrap_verb_result
 
 
 @verb(
     name="dedupe",
     immutable_input=True,
-    adapters=[inputs(default_input_argname="table"), outputs(mode=OutputMode.Table)],
+    adapters=[
+        inputs(default_input_argname="table"),
+        wrap_verb_result(mode=OutputMode.Table),
+    ],
 )
 def dedupe(
     table: pd.DataFrame, columns: list[str] | None = None, **_kwargs: Any
diff --git a/python/datashaper/datashaper/verbs/derive.py b/python/datashaper/datashaper/verbs/derive.py
index 8c187854b..e2c5154ab 100644
--- a/python/datashaper/datashaper/verbs/derive.py
+++ b/python/datashaper/datashaper/verbs/derive.py
@@ -13,7 +13,7 @@
 
 from datashaper.errors import VerbOperationNotSupportedError
 
-from .decorators import OutputMode, inputs, outputs, verb
+from .decorators import OutputMode, inputs, verb, wrap_verb_result
 from .types import MathOperator
 
 
@@ -42,7 +42,7 @@ def __concatenate(col1: pd.Series, col2: pd.Series) -> pd.Series:
     name="derive",
     adapters=[
         inputs(default_input_argname="table"),
-        outputs(mode=OutputMode.Table),
+        wrap_verb_result(mode=OutputMode.Table),
     ],
 )
 def derive(
diff --git a/python/datashaper/datashaper/verbs/destructure.py b/python/datashaper/datashaper/verbs/destructure.py
index 17bac8cce..94aad05d2 100644
--- a/python/datashaper/datashaper/verbs/destructure.py
+++ b/python/datashaper/datashaper/verbs/destructure.py
@@ -9,14 +9,14 @@
 
 import pandas as pd
 
-from .decorators import OutputMode, inputs, outputs, verb
+from .decorators import OutputMode, inputs, verb, wrap_verb_result
 
 
 @verb(
     name="destructure",
     adapters=[
         inputs(default_input_argname="table"),
-        outputs(mode=OutputMode.Table),
+        wrap_verb_result(mode=OutputMode.Table),
     ],
 )
 def destructure(
diff --git a/python/datashaper/datashaper/verbs/difference.py b/python/datashaper/datashaper/verbs/difference.py
index 791f82c63..8e10a642d 100644
--- a/python/datashaper/datashaper/verbs/difference.py
+++ b/python/datashaper/datashaper/verbs/difference.py
@@ -8,14 +8,14 @@
 
 import pandas as pd
 
-from .decorators import OutputMode, inputs, outputs, verb
+from .decorators import OutputMode, inputs, verb, wrap_verb_result
 
 
 @verb(
     name="difference",
     adapters=[
         inputs(default_input_argname="table", variadic_input_argname="others"),
-        outputs(mode=OutputMode.Table),
+        wrap_verb_result(mode=OutputMode.Table),
     ],
 )
 def difference(
diff --git a/python/datashaper/datashaper/verbs/drop.py b/python/datashaper/datashaper/verbs/drop.py
index 0130bfae4..e9741f5fe 100644
--- a/python/datashaper/datashaper/verbs/drop.py
+++ b/python/datashaper/datashaper/verbs/drop.py
@@ -8,14 +8,14 @@
 
 import pandas as pd
 
-from .decorators import OutputMode, inputs, outputs, verb
+from .decorators import OutputMode, inputs, verb, wrap_verb_result
 
 
 @verb(
     name="drop",
     adapters=[
         inputs(default_input_argname="table"),
-        outputs(mode=OutputMode.Table),
+        wrap_verb_result(mode=OutputMode.Table),
     ],
 )
 def drop(table: pd.DataFrame, columns: list[str], **_kwargs: Any) -> pd.DataFrame:
diff --git a/python/datashaper/datashaper/verbs/engine/verb_manager.py b/python/datashaper/datashaper/verbs/engine/verb_manager.py
deleted file mode 100644
index 6bb4a4aae..000000000
--- a/python/datashaper/datashaper/verbs/engine/verb_manager.py
+++ /dev/null
@@ -1,51 +0,0 @@
-#
-# Copyright (c) Microsoft. All rights reserved.
-# Licensed under the MIT license. See LICENSE file in the project.
-#
-"""Verb decorators and manager."""
-
-from collections.abc import Callable
-from dataclasses import dataclass, field
-from functools import cache
-
-from datashaper.errors import VerbAlreadyRegisteredError
-
-from .types import VerbDetails
-
-
-@dataclass
-class VerbManager:
-    """Manages the verbs and their functions."""
-
-    _verbs: dict[str, VerbDetails] = field(default_factory=dict)
-
-    def __getitem__(self, verb: str) -> VerbDetails | None:
-        """Get a verb by name."""
-        return self.get_verb(verb)
-
-    def __contains__(self, verb: str) -> bool:
-        """Check if a verb is registered."""
-        return verb in self._verbs
-
-    def register_verbs(
-        self, verbs: dict[str, Callable], override_existing: bool = False
-    ) -> None:
-        """Register verbs."""
-        for name, func in verbs.items():
-            self.register(VerbDetails(name=name, func=func), override_existing)
-
-    def register(self, verb: VerbDetails, override_existing: bool = False) -> None:
-        """Register a verb."""
-        if not override_existing and verb.name in self._verbs:
-            raise VerbAlreadyRegisteredError(verb.name)
-        self._verbs.update({verb.name: verb})
-
-    def get_verb(self, verb: str) -> VerbDetails | None:
-        """Get a verb by name."""
-        return self._verbs.get(verb)
-
-    @classmethod
-    @cache
-    def get(cls) -> "VerbManager":
-        """Get the verb manager."""
-        return cls()
diff --git a/python/datashaper/datashaper/verbs/erase.py b/python/datashaper/datashaper/verbs/erase.py
index df76ee393..d2e22eb4d 100644
--- a/python/datashaper/datashaper/verbs/erase.py
+++ b/python/datashaper/datashaper/verbs/erase.py
@@ -8,14 +8,14 @@
 
 import pandas as pd
 
-from .decorators import OutputMode, inputs, outputs, verb
+from .decorators import OutputMode, inputs, verb, wrap_verb_result
 
 
 @verb(
     name="erase",
     adapters=[
         inputs(default_input_argname="table"),
-        outputs(mode=OutputMode.Table),
+        wrap_verb_result(mode=OutputMode.Table),
     ],
 )
 def erase(
diff --git a/python/datashaper/datashaper/verbs/fill.py b/python/datashaper/datashaper/verbs/fill.py
index 64ad91b33..5613bf766 100644
--- a/python/datashaper/datashaper/verbs/fill.py
+++ b/python/datashaper/datashaper/verbs/fill.py
@@ -8,14 +8,14 @@
 
 import pandas as pd
 
-from .decorators import OutputMode, inputs, outputs, verb
+from .decorators import OutputMode, inputs, verb, wrap_verb_result
 
 
 @verb(
     name="fill",
     adapters=[
         inputs(default_input_argname="table"),
-        outputs(mode=OutputMode.Table),
+        wrap_verb_result(mode=OutputMode.Table),
     ],
 )
 def fill(
diff --git a/python/datashaper/datashaper/verbs/fold.py b/python/datashaper/datashaper/verbs/fold.py
index 355406d86..6570662de 100644
--- a/python/datashaper/datashaper/verbs/fold.py
+++ b/python/datashaper/datashaper/verbs/fold.py
@@ -8,14 +8,14 @@
 
 import pandas as pd
 
-from .decorators import OutputMode, inputs, outputs, verb
+from .decorators import OutputMode, inputs, verb, wrap_verb_result
 
 
 @verb(
     name="fold",
     adapters=[
         inputs(default_input_argname="table"),
-        outputs(mode=OutputMode.Table),
+        wrap_verb_result(mode=OutputMode.Table),
     ],
 )
 def fold(
diff --git a/python/datashaper/datashaper/verbs/groupby.py b/python/datashaper/datashaper/verbs/groupby.py
index 8aca2960e..b2877fa0b 100644
--- a/python/datashaper/datashaper/verbs/groupby.py
+++ b/python/datashaper/datashaper/verbs/groupby.py
@@ -9,14 +9,14 @@
 import pandas as pd
 from pandas.core.groupby import DataFrameGroupBy
 
-from .decorators import OutputMode, inputs, outputs, verb
+from .decorators import OutputMode, inputs, verb, wrap_verb_result
 
 
 @verb(
     name="groupby",
     adapters=[
         inputs(default_input_argname="table"),
-        outputs(mode=OutputMode.Table),
+        wrap_verb_result(mode=OutputMode.Table),
     ],
 )
 def groupby(
diff --git a/python/datashaper/datashaper/verbs/impute.py b/python/datashaper/datashaper/verbs/impute.py
index d448f1b58..95a342e82 100644
--- a/python/datashaper/datashaper/verbs/impute.py
+++ b/python/datashaper/datashaper/verbs/impute.py
@@ -8,14 +8,14 @@
 
 import pandas as pd
 
-from .decorators import OutputMode, inputs, outputs, verb
+from .decorators import OutputMode, inputs, verb, wrap_verb_result
 
 
 @verb(
     name="impute",
     adapters=[
         inputs(default_input_argname="table"),
-        outputs(mode=OutputMode.Table),
+        wrap_verb_result(mode=OutputMode.Table),
     ],
 )
 def impute(
diff --git a/python/datashaper/datashaper/verbs/intersect.py b/python/datashaper/datashaper/verbs/intersect.py
index 978c0216d..55cba4bc3 100644
--- a/python/datashaper/datashaper/verbs/intersect.py
+++ b/python/datashaper/datashaper/verbs/intersect.py
@@ -8,7 +8,7 @@
 
 import pandas as pd
 
-from .decorators import OutputMode, inputs, outputs, verb
+from .decorators import OutputMode, inputs, verb, wrap_verb_result
 
 
 @verb(
@@ -16,7 +16,7 @@
     immutable_input=True,
     adapters=[
         inputs(default_input_argname="table", variadic_input_argname="others"),
-        outputs(mode=OutputMode.Table),
+        wrap_verb_result(mode=OutputMode.Table),
     ],
 )
 def intersect(
diff --git a/python/datashaper/datashaper/verbs/join.py b/python/datashaper/datashaper/verbs/join.py
index f9241650d..1068e7a54 100644
--- a/python/datashaper/datashaper/verbs/join.py
+++ b/python/datashaper/datashaper/verbs/join.py
@@ -9,7 +9,7 @@
 import pandas as pd
 from pandas._typing import MergeHow, Suffixes
 
-from .decorators import OutputMode, inputs, outputs, verb
+from .decorators import OutputMode, inputs, verb, wrap_verb_result
 from .types import JoinStrategy
 
 __strategy_mapping: dict[JoinStrategy, MergeHow] = {
@@ -51,7 +51,7 @@ def __clean_result(
     immutable_input=True,
     adapters=[
         inputs(default_input_argname="table", input_argnames={"other": "other"}),
-        outputs(mode=OutputMode.Table),
+        wrap_verb_result(mode=OutputMode.Table),
     ],
 )
 def join(
diff --git a/python/datashaper/datashaper/verbs/lookup.py b/python/datashaper/datashaper/verbs/lookup.py
index df5f9b0aa..fb8df5154 100644
--- a/python/datashaper/datashaper/verbs/lookup.py
+++ b/python/datashaper/datashaper/verbs/lookup.py
@@ -8,7 +8,7 @@
 
 import pandas as pd
 
-from .decorators import OutputMode, inputs, outputs, verb
+from .decorators import OutputMode, inputs, verb, wrap_verb_result
 
 
 @verb(
@@ -16,7 +16,7 @@
     immutable_input=True,
     adapters=[
         inputs(default_input_argname="table", input_argnames={"other": "other"}),
-        outputs(mode=OutputMode.Table),
+        wrap_verb_result(mode=OutputMode.Table),
     ],
 )
 def lookup(
diff --git a/python/datashaper/datashaper/verbs/merge.py b/python/datashaper/datashaper/verbs/merge.py
index 022a4438a..92d962165 100644
--- a/python/datashaper/datashaper/verbs/merge.py
+++ b/python/datashaper/datashaper/verbs/merge.py
@@ -11,7 +11,7 @@
 import pandas as pd
 from pandas.api.types import is_bool
 
-from .decorators import OutputMode, inputs, outputs, verb
+from .decorators import OutputMode, inputs, verb, wrap_verb_result
 from .types import MergeStrategy
 
 
@@ -19,7 +19,7 @@
     name="merge",
     adapters=[
         inputs(default_input_argname="table"),
-        outputs(mode=OutputMode.Table),
+        wrap_verb_result(mode=OutputMode.Table),
     ],
 )
 def merge(
diff --git a/python/datashaper/datashaper/verbs/onehot.py b/python/datashaper/datashaper/verbs/onehot.py
index a89c03e60..f4c1fd75b 100644
--- a/python/datashaper/datashaper/verbs/onehot.py
+++ b/python/datashaper/datashaper/verbs/onehot.py
@@ -9,14 +9,14 @@
 import numpy as np
 import pandas as pd
 
-from .decorators import OutputMode, inputs, outputs, verb
+from .decorators import OutputMode, inputs, verb, wrap_verb_result
 
 
 @verb(
     name="onehot",
     adapters=[
         inputs(default_input_argname="table"),
-        outputs(mode=OutputMode.Table),
+        wrap_verb_result(mode=OutputMode.Table),
     ],
 )
 def onehot(
diff --git a/python/datashaper/datashaper/verbs/orderby.py b/python/datashaper/datashaper/verbs/orderby.py
index d450082db..07237e29e 100644
--- a/python/datashaper/datashaper/verbs/orderby.py
+++ b/python/datashaper/datashaper/verbs/orderby.py
@@ -8,7 +8,7 @@
 
 import pandas as pd
 
-from .decorators import OutputMode, inputs, outputs, verb
+from .decorators import OutputMode, inputs, verb, wrap_verb_result
 from .types import OrderByInstruction, SortDirection
 
 
@@ -16,7 +16,7 @@
     name="orderby",
     adapters=[
         inputs(default_input_argname="table"),
-        outputs(mode=OutputMode.Table),
+        wrap_verb_result(mode=OutputMode.Table),
     ],
 )
 def orderby(table: pd.DataFrame, orders: list[dict], **_kwargs: Any) -> pd.DataFrame:
diff --git a/python/datashaper/datashaper/verbs/pivot.py b/python/datashaper/datashaper/verbs/pivot.py
index 8381023be..ed332265b 100644
--- a/python/datashaper/datashaper/verbs/pivot.py
+++ b/python/datashaper/datashaper/verbs/pivot.py
@@ -9,7 +9,7 @@
 import pandas as pd
 
 from .aggregate import aggregate_operation_mapping
-from .decorators import OutputMode, inputs, outputs, verb
+from .decorators import OutputMode, inputs, verb, wrap_verb_result
 from .types import FieldAggregateOperation
 
 
@@ -18,7 +18,7 @@
     immutable_input=True,
     adapters=[
         inputs(default_input_argname="table"),
-        outputs(mode=OutputMode.Table),
+        wrap_verb_result(mode=OutputMode.Table),
     ],
 )
 def pivot(
diff --git a/python/datashaper/datashaper/verbs/print.py b/python/datashaper/datashaper/verbs/print.py
index a26a3b0d6..ed05b4103 100644
--- a/python/datashaper/datashaper/verbs/print.py
+++ b/python/datashaper/datashaper/verbs/print.py
@@ -8,7 +8,7 @@
 
 import pandas as pd
 
-from .decorators import OutputMode, inputs, outputs, verb
+from .decorators import OutputMode, inputs, verb, wrap_verb_result
 
 raw_print = print
 
@@ -18,7 +18,7 @@
     immutable_input=True,
     adapters=[
         inputs(default_input_argname="table"),
-        outputs(mode=OutputMode.Table),
+        wrap_verb_result(mode=OutputMode.Table),
     ],
 )
 def print(  # noqa A001 - use ds verb name
diff --git a/python/datashaper/datashaper/verbs/recode.py b/python/datashaper/datashaper/verbs/recode.py
index 4972ab78f..ed90d9507 100644
--- a/python/datashaper/datashaper/verbs/recode.py
+++ b/python/datashaper/datashaper/verbs/recode.py
@@ -8,7 +8,7 @@
 
 import pandas as pd
 
-from .decorators import OutputMode, inputs, outputs, verb
+from .decorators import OutputMode, inputs, verb, wrap_verb_result
 
 
 class RecodeMap(dict):
@@ -23,7 +23,7 @@ def __missing__(self, key: str):
     name="recode",
     adapters=[
         inputs(default_input_argname="table"),
-        outputs(mode=OutputMode.Table),
+        wrap_verb_result(mode=OutputMode.Table),
     ],
 )
 def recode(
diff --git a/python/datashaper/datashaper/verbs/rename.py b/python/datashaper/datashaper/verbs/rename.py
index b64eabc1f..5ada8118d 100644
--- a/python/datashaper/datashaper/verbs/rename.py
+++ b/python/datashaper/datashaper/verbs/rename.py
@@ -8,14 +8,14 @@
 
 import pandas as pd
 
-from .decorators import OutputMode, inputs, outputs, verb
+from .decorators import OutputMode, inputs, verb, wrap_verb_result
 
 
 @verb(
     name="rename",
     adapters=[
         inputs(default_input_argname="table"),
-        outputs(mode=OutputMode.Table),
+        wrap_verb_result(mode=OutputMode.Table),
     ],
 )
 def rename(
diff --git a/python/datashaper/datashaper/verbs/rollup.py b/python/datashaper/datashaper/verbs/rollup.py
index 80ba2b37d..e26e2edbf 100644
--- a/python/datashaper/datashaper/verbs/rollup.py
+++ b/python/datashaper/datashaper/verbs/rollup.py
@@ -10,7 +10,7 @@
 import pandas as pd
 
 from .aggregate import aggregate_operation_mapping
-from .decorators import OutputMode, inputs, outputs, verb
+from .decorators import OutputMode, inputs, verb, wrap_verb_result
 from .types import FieldAggregateOperation
 
 
@@ -19,7 +19,7 @@
     immutable_input=True,
     adapters=[
         inputs(default_input_argname="table"),
-        outputs(mode=OutputMode.Table),
+        wrap_verb_result(mode=OutputMode.Table),
     ],
 )
 def rollup(
diff --git a/python/datashaper/datashaper/verbs/sample.py b/python/datashaper/datashaper/verbs/sample.py
index 0c54ef30c..48f9facde 100644
--- a/python/datashaper/datashaper/verbs/sample.py
+++ b/python/datashaper/datashaper/verbs/sample.py
@@ -8,7 +8,7 @@
 
 import pandas as pd
 
-from .decorators import OutputMode, inputs, outputs, verb
+from .decorators import OutputMode, inputs, verb, wrap_verb_result
 
 
 @verb(
@@ -16,7 +16,7 @@
     immutable_input=True,
     adapters=[
         inputs(default_input_argname="table"),
-        outputs(
+        wrap_verb_result(
             mode=OutputMode.Tuple,
             output_names=["remainder"],
         ),
diff --git a/python/datashaper/datashaper/verbs/select.py b/python/datashaper/datashaper/verbs/select.py
index 31118fbe3..fe032a170 100644
--- a/python/datashaper/datashaper/verbs/select.py
+++ b/python/datashaper/datashaper/verbs/select.py
@@ -8,7 +8,7 @@
 
 import pandas as pd
 
-from .decorators import OutputMode, inputs, outputs, verb
+from .decorators import OutputMode, inputs, verb, wrap_verb_result
 
 
 @verb(
@@ -16,7 +16,7 @@
     immutable_input=True,
     adapters=[
         inputs(default_input_argname="table"),
-        outputs(mode=OutputMode.Table),
+        wrap_verb_result(mode=OutputMode.Table),
     ],
 )
 def select(table: pd.DataFrame, columns: list[str], **_kwargs: Any) -> pd.DataFrame:
diff --git a/python/datashaper/datashaper/verbs/snapshot.py b/python/datashaper/datashaper/verbs/snapshot.py
index 679c8e733..ccf2bf9aa 100644
--- a/python/datashaper/datashaper/verbs/snapshot.py
+++ b/python/datashaper/datashaper/verbs/snapshot.py
@@ -8,7 +8,7 @@
 
 import pandas as pd
 
-from .decorators import OutputMode, inputs, outputs, verb
+from .decorators import OutputMode, inputs, verb, wrap_verb_result
 from .types import FileFormat
 
 
@@ -17,7 +17,7 @@
     immutable_input=True,
     adapters=[
         inputs(default_input_argname="table"),
-        outputs(mode=OutputMode.Table),
+        wrap_verb_result(mode=OutputMode.Table),
     ],
 )
 def snapshot(
diff --git a/python/datashaper/datashaper/verbs/spread.py b/python/datashaper/datashaper/verbs/spread.py
index a2d6acf40..7fefe36b1 100644
--- a/python/datashaper/datashaper/verbs/spread.py
+++ b/python/datashaper/datashaper/verbs/spread.py
@@ -8,14 +8,14 @@
 
 import pandas as pd
 
-from .decorators import OutputMode, inputs, outputs, verb
+from .decorators import OutputMode, inputs, verb, wrap_verb_result
 
 
 @verb(
     name="spread",
     adapters=[
         inputs(default_input_argname="table"),
-        outputs(mode=OutputMode.Table),
+        wrap_verb_result(mode=OutputMode.Table),
     ],
 )
 def spread(
diff --git a/python/datashaper/datashaper/verbs/strings/lower.py b/python/datashaper/datashaper/verbs/strings/lower.py
index ccc8ff99e..0177b8f13 100644
--- a/python/datashaper/datashaper/verbs/strings/lower.py
+++ b/python/datashaper/datashaper/verbs/strings/lower.py
@@ -10,8 +10,8 @@
     OutputMode,
     apply_decorators,
     inputs,
-    outputs,
     verb,
+    wrap_verb_result,
 )
 
 
@@ -25,7 +25,7 @@ def lower(table: pd.DataFrame, column: str, to: str, **_kwargs: dict) -> pd.Data
     [
         verb(name="strings.lower"),
         inputs(default_input_argname="table"),
-        outputs(mode=OutputMode.Table),
+        wrap_verb_result(mode=OutputMode.Table),
     ],
     lower,
 )
diff --git a/python/datashaper/datashaper/verbs/strings/replace.py b/python/datashaper/datashaper/verbs/strings/replace.py
index e460ed874..200693eb8 100644
--- a/python/datashaper/datashaper/verbs/strings/replace.py
+++ b/python/datashaper/datashaper/verbs/strings/replace.py
@@ -12,8 +12,8 @@
     OutputMode,
     apply_decorators,
     inputs,
-    outputs,
     verb,
+    wrap_verb_result,
 )
 
 
@@ -38,7 +38,7 @@ def replace(
     [
         verb(name="strings.replace"),
         inputs(default_input_argname="table"),
-        outputs(mode=OutputMode.Table),
+        wrap_verb_result(mode=OutputMode.Table),
     ],
     replace,
 )
diff --git a/python/datashaper/datashaper/verbs/strings/upper.py b/python/datashaper/datashaper/verbs/strings/upper.py
index a22acd5e7..4fff10895 100644
--- a/python/datashaper/datashaper/verbs/strings/upper.py
+++ b/python/datashaper/datashaper/verbs/strings/upper.py
@@ -10,8 +10,8 @@
     OutputMode,
     apply_decorators,
     inputs,
-    outputs,
     verb,
+    wrap_verb_result,
 )
 
 
@@ -25,7 +25,7 @@ def upper(table: pd.DataFrame, column: str, to: str, **_kwargs: dict) -> pd.Data
     [
         verb(name="strings.upper"),
         inputs(default_input_argname="table"),
-        outputs(mode=OutputMode.Table),
+        wrap_verb_result(mode=OutputMode.Table),
     ],
     upper,
 )
diff --git a/python/datashaper/datashaper/verbs/unfold.py b/python/datashaper/datashaper/verbs/unfold.py
index 8934afd23..c920355db 100644
--- a/python/datashaper/datashaper/verbs/unfold.py
+++ b/python/datashaper/datashaper/verbs/unfold.py
@@ -9,14 +9,14 @@
 import numpy as np
 import pandas as pd
 
-from .decorators import OutputMode, inputs, outputs, verb
+from .decorators import OutputMode, inputs, verb, wrap_verb_result
 
 
 @verb(
     name="unfold",
     adapters=[
         inputs(default_input_argname="table"),
-        outputs(mode=OutputMode.Table),
+        wrap_verb_result(mode=OutputMode.Table),
     ],
 )
 def unfold(table: pd.DataFrame, key: str, value: str, **_kwargs: Any) -> pd.DataFrame:
diff --git a/python/datashaper/datashaper/verbs/ungroup.py b/python/datashaper/datashaper/verbs/ungroup.py
index f21b32007..eafa750d7 100644
--- a/python/datashaper/datashaper/verbs/ungroup.py
+++ b/python/datashaper/datashaper/verbs/ungroup.py
@@ -8,7 +8,7 @@
 
 import pandas as pd
 
-from .decorators import OutputMode, inputs, outputs, verb
+from .decorators import OutputMode, inputs, verb, wrap_verb_result
 
 
 @verb(
@@ -16,7 +16,7 @@
     immutable_input=True,
     adapters=[
         inputs(default_input_argname="table"),
-        outputs(mode=OutputMode.Table),
+        wrap_verb_result(mode=OutputMode.Table),
     ],
 )
 def ungroup(table: pd.DataFrame, **_kwargs: Any) -> pd.DataFrame:
diff --git a/python/datashaper/datashaper/verbs/unhot.py b/python/datashaper/datashaper/verbs/unhot.py
index 8323b0683..391813b24 100644
--- a/python/datashaper/datashaper/verbs/unhot.py
+++ b/python/datashaper/datashaper/verbs/unhot.py
@@ -4,14 +4,14 @@
 
 import pandas as pd
 
-from .decorators import OutputMode, inputs, outputs, verb
+from .decorators import OutputMode, inputs, verb, wrap_verb_result
 
 
 @verb(
     name="unhot",
     adapters=[
         inputs(default_input_argname="table"),
-        outputs(mode=OutputMode.Table),
+        wrap_verb_result(mode=OutputMode.Table),
     ],
 )
 def unhot(
diff --git a/python/datashaper/datashaper/verbs/union.py b/python/datashaper/datashaper/verbs/union.py
index 0e3765c56..d28158747 100644
--- a/python/datashaper/datashaper/verbs/union.py
+++ b/python/datashaper/datashaper/verbs/union.py
@@ -8,7 +8,7 @@
 
 import pandas as pd
 
-from .decorators import OutputMode, inputs, outputs, verb
+from .decorators import OutputMode, inputs, verb, wrap_verb_result
 
 
 @verb(
@@ -16,7 +16,7 @@
     immutable_input=True,
     adapters=[
         inputs(default_input_argname="table", variadic_input_argname="others"),
-        outputs(mode=OutputMode.Table),
+        wrap_verb_result(mode=OutputMode.Table),
     ],
 )
 def union(
diff --git a/python/datashaper/datashaper/verbs/unorder.py b/python/datashaper/datashaper/verbs/unorder.py
index b3ef05c9e..437ad12cd 100644
--- a/python/datashaper/datashaper/verbs/unorder.py
+++ b/python/datashaper/datashaper/verbs/unorder.py
@@ -8,7 +8,7 @@
 
 import pandas as pd
 
-from .decorators import OutputMode, inputs, outputs, verb
+from .decorators import OutputMode, inputs, verb, wrap_verb_result
 
 
 @verb(
@@ -16,7 +16,7 @@
     immutable_input=True,
     adapters=[
         inputs(default_input_argname="table"),
-        outputs(mode=OutputMode.Table),
+        wrap_verb_result(mode=OutputMode.Table),
     ],
 )
 def unorder(table: pd.DataFrame, **_kwargs: Any) -> pd.DataFrame:
diff --git a/python/datashaper/datashaper/verbs/unroll.py b/python/datashaper/datashaper/verbs/unroll.py
index cf075843e..388af7575 100644
--- a/python/datashaper/datashaper/verbs/unroll.py
+++ b/python/datashaper/datashaper/verbs/unroll.py
@@ -8,13 +8,16 @@
 
 import pandas as pd
 
-from .decorators import OutputMode, inputs, outputs, verb
+from .decorators import OutputMode, inputs, verb, wrap_verb_result
 
 
 @verb(
     name="unroll",
     immutable_input=True,
-    adapters=[inputs(default_input_argname="table"), outputs(mode=OutputMode.Table)],
+    adapters=[
+        inputs(default_input_argname="table"),
+        wrap_verb_result(mode=OutputMode.Table),
+    ],
 )
 def unroll(table: pd.DataFrame, column: str, **_kwargs: Any) -> pd.DataFrame:
     """Unroll a column."""
diff --git a/python/datashaper/datashaper/verbs/window.py b/python/datashaper/datashaper/verbs/window.py
index f9a55742f..5dab18593 100644
--- a/python/datashaper/datashaper/verbs/window.py
+++ b/python/datashaper/datashaper/verbs/window.py
@@ -11,7 +11,7 @@
 import pandas as pd
 from pandas.core.groupby import DataFrameGroupBy
 
-from .decorators import OutputMode, inputs, outputs, verb
+from .decorators import OutputMode, inputs, verb, wrap_verb_result
 from .types import WindowFunction
 
 
@@ -61,7 +61,7 @@ def _get_window_indexer(
     name="window",
     adapters=[
         inputs(default_input_argname="table"),
-        outputs(mode=OutputMode.Table),
+        wrap_verb_result(mode=OutputMode.Table),
     ],
 )
 def window(
diff --git a/python/datashaper/datashaper/verbs/workflow.py b/python/datashaper/datashaper/verbs/workflow.py
index 2d7fd55f5..71efa4558 100644
--- a/python/datashaper/datashaper/verbs/workflow.py
+++ b/python/datashaper/datashaper/verbs/workflow.py
@@ -6,7 +6,7 @@
 
 from datashaper.constants import DEFAULT_INPUT_NAME
 
-from .decorators import OutputMode, inputs, outputs, verb
+from .decorators import OutputMode, inputs, verb, wrap_verb_result
 from .types import Table
 
 
@@ -14,7 +14,7 @@
     name="workflow",
     adapters=[
         inputs(default_input_argname="table", input_dict_argname="tables"),
-        outputs(mode=OutputMode.Table),
+        wrap_verb_result(mode=OutputMode.Table),
     ],
 )
 async def workflow(
diff --git a/python/datashaper/poetry.lock b/python/datashaper/poetry.lock
index eb07e0f78..e7b43b674 100644
--- a/python/datashaper/poetry.lock
+++ b/python/datashaper/poetry.lock
@@ -1,4 +1,15 @@
-# This file is automatically @generated by Poetry 1.5.0 and should not be changed by hand.
+# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
+
+[[package]]
+name = "annotated-types"
+version = "0.7.0"
+description = "Reusable constraint types to use with typing.Annotated"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"},
+    {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"},
+]
 
 [[package]]
 name = "attrs"
@@ -298,6 +309,24 @@ files = [
     {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"},
 ]
 
+[[package]]
+name = "networkx"
+version = "3.3"
+description = "Python package for creating and manipulating graphs and networks"
+optional = false
+python-versions = ">=3.10"
+files = [
+    {file = "networkx-3.3-py3-none-any.whl", hash = "sha256:28575580c6ebdaf4505b22c6256a2b9de86b316dc63ba9e93abde3d78dfdbcf2"},
+    {file = "networkx-3.3.tar.gz", hash = "sha256:0c127d8b2f4865f59ae9cb8aafcd60b5c70f3241ebd66f7defad7c4ab90126c9"},
+]
+
+[package.extras]
+default = ["matplotlib (>=3.6)", "numpy (>=1.23)", "pandas (>=1.4)", "scipy (>=1.9,!=1.11.0,!=1.11.1)"]
+developer = ["changelist (==0.5)", "mypy (>=1.1)", "pre-commit (>=3.2)", "rtoml"]
+doc = ["myst-nb (>=1.0)", "numpydoc (>=1.7)", "pillow (>=9.4)", "pydata-sphinx-theme (>=0.14)", "sphinx (>=7)", "sphinx-gallery (>=0.14)", "texext (>=0.6.7)"]
+extra = ["lxml (>=4.6)", "pydot (>=2.0)", "pygraphviz (>=1.12)", "sympy (>=1.10)"]
+test = ["pytest (>=7.2)", "pytest-cov (>=4.0)"]
+
 [[package]]
 name = "nodeenv"
 version = "1.8.0"
@@ -532,6 +561,116 @@ files = [
 [package.dependencies]
 numpy = ">=1.16.6,<2"
 
+[[package]]
+name = "pydantic"
+version = "2.7.4"
+description = "Data validation using Python type hints"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "pydantic-2.7.4-py3-none-any.whl", hash = "sha256:ee8538d41ccb9c0a9ad3e0e5f07bf15ed8015b481ced539a1759d8cc89ae90d0"},
+    {file = "pydantic-2.7.4.tar.gz", hash = "sha256:0c84efd9548d545f63ac0060c1e4d39bb9b14db8b3c0652338aecc07b5adec52"},
+]
+
+[package.dependencies]
+annotated-types = ">=0.4.0"
+pydantic-core = "2.18.4"
+typing-extensions = ">=4.6.1"
+
+[package.extras]
+email = ["email-validator (>=2.0.0)"]
+
+[[package]]
+name = "pydantic-core"
+version = "2.18.4"
+description = "Core functionality for Pydantic validation and serialization"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "pydantic_core-2.18.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:f76d0ad001edd426b92233d45c746fd08f467d56100fd8f30e9ace4b005266e4"},
+    {file = "pydantic_core-2.18.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:59ff3e89f4eaf14050c8022011862df275b552caef8082e37b542b066ce1ff26"},
+    {file = "pydantic_core-2.18.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a55b5b16c839df1070bc113c1f7f94a0af4433fcfa1b41799ce7606e5c79ce0a"},
+    {file = "pydantic_core-2.18.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4d0dcc59664fcb8974b356fe0a18a672d6d7cf9f54746c05f43275fc48636851"},
+    {file = "pydantic_core-2.18.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8951eee36c57cd128f779e641e21eb40bc5073eb28b2d23f33eb0ef14ffb3f5d"},
+    {file = "pydantic_core-2.18.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4701b19f7e3a06ea655513f7938de6f108123bf7c86bbebb1196eb9bd35cf724"},
+    {file = "pydantic_core-2.18.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e00a3f196329e08e43d99b79b286d60ce46bed10f2280d25a1718399457e06be"},
+    {file = "pydantic_core-2.18.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:97736815b9cc893b2b7f663628e63f436018b75f44854c8027040e05230eeddb"},
+    {file = "pydantic_core-2.18.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6891a2ae0e8692679c07728819b6e2b822fb30ca7445f67bbf6509b25a96332c"},
+    {file = "pydantic_core-2.18.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bc4ff9805858bd54d1a20efff925ccd89c9d2e7cf4986144b30802bf78091c3e"},
+    {file = "pydantic_core-2.18.4-cp310-none-win32.whl", hash = "sha256:1b4de2e51bbcb61fdebd0ab86ef28062704f62c82bbf4addc4e37fa4b00b7cbc"},
+    {file = "pydantic_core-2.18.4-cp310-none-win_amd64.whl", hash = "sha256:6a750aec7bf431517a9fd78cb93c97b9b0c496090fee84a47a0d23668976b4b0"},
+    {file = "pydantic_core-2.18.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:942ba11e7dfb66dc70f9ae66b33452f51ac7bb90676da39a7345e99ffb55402d"},
+    {file = "pydantic_core-2.18.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b2ebef0e0b4454320274f5e83a41844c63438fdc874ea40a8b5b4ecb7693f1c4"},
+    {file = "pydantic_core-2.18.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a642295cd0c8df1b86fc3dced1d067874c353a188dc8e0f744626d49e9aa51c4"},
+    {file = "pydantic_core-2.18.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f09baa656c904807e832cf9cce799c6460c450c4ad80803517032da0cd062e2"},
+    {file = "pydantic_core-2.18.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:98906207f29bc2c459ff64fa007afd10a8c8ac080f7e4d5beff4c97086a3dabd"},
+    {file = "pydantic_core-2.18.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:19894b95aacfa98e7cb093cd7881a0c76f55731efad31073db4521e2b6ff5b7d"},
+    {file = "pydantic_core-2.18.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fbbdc827fe5e42e4d196c746b890b3d72876bdbf160b0eafe9f0334525119c8"},
+    {file = "pydantic_core-2.18.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f85d05aa0918283cf29a30b547b4df2fbb56b45b135f9e35b6807cb28bc47951"},
+    {file = "pydantic_core-2.18.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e85637bc8fe81ddb73fda9e56bab24560bdddfa98aa64f87aaa4e4b6730c23d2"},
+    {file = "pydantic_core-2.18.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:2f5966897e5461f818e136b8451d0551a2e77259eb0f73a837027b47dc95dab9"},
+    {file = "pydantic_core-2.18.4-cp311-none-win32.whl", hash = "sha256:44c7486a4228413c317952e9d89598bcdfb06399735e49e0f8df643e1ccd0558"},
+    {file = "pydantic_core-2.18.4-cp311-none-win_amd64.whl", hash = "sha256:8a7164fe2005d03c64fd3b85649891cd4953a8de53107940bf272500ba8a788b"},
+    {file = "pydantic_core-2.18.4-cp311-none-win_arm64.whl", hash = "sha256:4e99bc050fe65c450344421017f98298a97cefc18c53bb2f7b3531eb39bc7805"},
+    {file = "pydantic_core-2.18.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:6f5c4d41b2771c730ea1c34e458e781b18cc668d194958e0112455fff4e402b2"},
+    {file = "pydantic_core-2.18.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2fdf2156aa3d017fddf8aea5adfba9f777db1d6022d392b682d2a8329e087cef"},
+    {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4748321b5078216070b151d5271ef3e7cc905ab170bbfd27d5c83ee3ec436695"},
+    {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847a35c4d58721c5dc3dba599878ebbdfd96784f3fb8bb2c356e123bdcd73f34"},
+    {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c40d4eaad41f78e3bbda31b89edc46a3f3dc6e171bf0ecf097ff7a0ffff7cb1"},
+    {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:21a5e440dbe315ab9825fcd459b8814bb92b27c974cbc23c3e8baa2b76890077"},
+    {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01dd777215e2aa86dfd664daed5957704b769e726626393438f9c87690ce78c3"},
+    {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4b06beb3b3f1479d32befd1f3079cc47b34fa2da62457cdf6c963393340b56e9"},
+    {file = "pydantic_core-2.18.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:564d7922e4b13a16b98772441879fcdcbe82ff50daa622d681dd682175ea918c"},
+    {file = "pydantic_core-2.18.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0eb2a4f660fcd8e2b1c90ad566db2b98d7f3f4717c64fe0a83e0adb39766d5b8"},
+    {file = "pydantic_core-2.18.4-cp312-none-win32.whl", hash = "sha256:8b8bab4c97248095ae0c4455b5a1cd1cdd96e4e4769306ab19dda135ea4cdb07"},
+    {file = "pydantic_core-2.18.4-cp312-none-win_amd64.whl", hash = "sha256:14601cdb733d741b8958224030e2bfe21a4a881fb3dd6fbb21f071cabd48fa0a"},
+    {file = "pydantic_core-2.18.4-cp312-none-win_arm64.whl", hash = "sha256:c1322d7dd74713dcc157a2b7898a564ab091ca6c58302d5c7b4c07296e3fd00f"},
+    {file = "pydantic_core-2.18.4-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:823be1deb01793da05ecb0484d6c9e20baebb39bd42b5d72636ae9cf8350dbd2"},
+    {file = "pydantic_core-2.18.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ebef0dd9bf9b812bf75bda96743f2a6c5734a02092ae7f721c048d156d5fabae"},
+    {file = "pydantic_core-2.18.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae1d6df168efb88d7d522664693607b80b4080be6750c913eefb77e34c12c71a"},
+    {file = "pydantic_core-2.18.4-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f9899c94762343f2cc2fc64c13e7cae4c3cc65cdfc87dd810a31654c9b7358cc"},
+    {file = "pydantic_core-2.18.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:99457f184ad90235cfe8461c4d70ab7dd2680e28821c29eca00252ba90308c78"},
+    {file = "pydantic_core-2.18.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18f469a3d2a2fdafe99296a87e8a4c37748b5080a26b806a707f25a902c040a8"},
+    {file = "pydantic_core-2.18.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7cdf28938ac6b8b49ae5e92f2735056a7ba99c9b110a474473fd71185c1af5d"},
+    {file = "pydantic_core-2.18.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:938cb21650855054dc54dfd9120a851c974f95450f00683399006aa6e8abb057"},
+    {file = "pydantic_core-2.18.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:44cd83ab6a51da80fb5adbd9560e26018e2ac7826f9626bc06ca3dc074cd198b"},
+    {file = "pydantic_core-2.18.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:972658f4a72d02b8abfa2581d92d59f59897d2e9f7e708fdabe922f9087773af"},
+    {file = "pydantic_core-2.18.4-cp38-none-win32.whl", hash = "sha256:1d886dc848e60cb7666f771e406acae54ab279b9f1e4143babc9c2258213daa2"},
+    {file = "pydantic_core-2.18.4-cp38-none-win_amd64.whl", hash = "sha256:bb4462bd43c2460774914b8525f79b00f8f407c945d50881568f294c1d9b4443"},
+    {file = "pydantic_core-2.18.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:44a688331d4a4e2129140a8118479443bd6f1905231138971372fcde37e43528"},
+    {file = "pydantic_core-2.18.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a2fdd81edd64342c85ac7cf2753ccae0b79bf2dfa063785503cb85a7d3593223"},
+    {file = "pydantic_core-2.18.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:86110d7e1907ab36691f80b33eb2da87d780f4739ae773e5fc83fb272f88825f"},
+    {file = "pydantic_core-2.18.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46387e38bd641b3ee5ce247563b60c5ca098da9c56c75c157a05eaa0933ed154"},
+    {file = "pydantic_core-2.18.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:123c3cec203e3f5ac7b000bd82235f1a3eced8665b63d18be751f115588fea30"},
+    {file = "pydantic_core-2.18.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dc1803ac5c32ec324c5261c7209e8f8ce88e83254c4e1aebdc8b0a39f9ddb443"},
+    {file = "pydantic_core-2.18.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53db086f9f6ab2b4061958d9c276d1dbe3690e8dd727d6abf2321d6cce37fa94"},
+    {file = "pydantic_core-2.18.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abc267fa9837245cc28ea6929f19fa335f3dc330a35d2e45509b6566dc18be23"},
+    {file = "pydantic_core-2.18.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:a0d829524aaefdebccb869eed855e2d04c21d2d7479b6cada7ace5448416597b"},
+    {file = "pydantic_core-2.18.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:509daade3b8649f80d4e5ff21aa5673e4ebe58590b25fe42fac5f0f52c6f034a"},
+    {file = "pydantic_core-2.18.4-cp39-none-win32.whl", hash = "sha256:ca26a1e73c48cfc54c4a76ff78df3727b9d9f4ccc8dbee4ae3f73306a591676d"},
+    {file = "pydantic_core-2.18.4-cp39-none-win_amd64.whl", hash = "sha256:c67598100338d5d985db1b3d21f3619ef392e185e71b8d52bceacc4a7771ea7e"},
+    {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:574d92eac874f7f4db0ca653514d823a0d22e2354359d0759e3f6a406db5d55d"},
+    {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1f4d26ceb5eb9eed4af91bebeae4b06c3fb28966ca3a8fb765208cf6b51102ab"},
+    {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77450e6d20016ec41f43ca4a6c63e9fdde03f0ae3fe90e7c27bdbeaece8b1ed4"},
+    {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d323a01da91851a4f17bf592faf46149c9169d68430b3146dcba2bb5e5719abc"},
+    {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:43d447dd2ae072a0065389092a231283f62d960030ecd27565672bd40746c507"},
+    {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:578e24f761f3b425834f297b9935e1ce2e30f51400964ce4801002435a1b41ef"},
+    {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:81b5efb2f126454586d0f40c4d834010979cb80785173d1586df845a632e4e6d"},
+    {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ab86ce7c8f9bea87b9d12c7f0af71102acbf5ecbc66c17796cff45dae54ef9a5"},
+    {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:90afc12421df2b1b4dcc975f814e21bc1754640d502a2fbcc6d41e77af5ec312"},
+    {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:51991a89639a912c17bef4b45c87bd83593aee0437d8102556af4885811d59f5"},
+    {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:293afe532740370aba8c060882f7d26cfd00c94cae32fd2e212a3a6e3b7bc15e"},
+    {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b48ece5bde2e768197a2d0f6e925f9d7e3e826f0ad2271120f8144a9db18d5c8"},
+    {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eae237477a873ab46e8dd748e515c72c0c804fb380fbe6c85533c7de51f23a8f"},
+    {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:834b5230b5dfc0c1ec37b2fda433b271cbbc0e507560b5d1588e2cc1148cf1ce"},
+    {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e858ac0a25074ba4bce653f9b5d0a85b7456eaddadc0ce82d3878c22489fa4ee"},
+    {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2fd41f6eff4c20778d717af1cc50eca52f5afe7805ee530a4fbd0bae284f16e9"},
+    {file = "pydantic_core-2.18.4.tar.gz", hash = "sha256:ec3beeada09ff865c344ff3bc2f427f5e6c26401cc6113d77e372c3fdac73864"},
+]
+
+[package.dependencies]
+typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
+
 [[package]]
 name = "pyright"
 version = "1.1.350"
@@ -633,6 +772,36 @@ files = [
     {file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"},
 ]
 
+[[package]]
+name = "reactivedataflow"
+version = "0.1.0"
+description = "Reactive Dataflow Graphs"
+optional = false
+python-versions = "<4.0,>=3.10"
+files = [
+    {file = "reactivedataflow-0.1.0-py3-none-any.whl", hash = "sha256:65eb577a461001b407202b3985327a6eccdb49f3921e7be087c7d1325c049351"},
+    {file = "reactivedataflow-0.1.0.tar.gz", hash = "sha256:d88dab401fab8eae92492c083782eba511cd8fbd8798712cd6efd5376ac0a9ed"},
+]
+
+[package.dependencies]
+networkx = ">=3.3,<4.0"
+pydantic = ">=2.7.4,<3.0.0"
+reactivex = ">=4.0.4,<5.0.0"
+
+[[package]]
+name = "reactivex"
+version = "4.0.4"
+description = "ReactiveX (Rx) for Python"
+optional = false
+python-versions = ">=3.7,<4.0"
+files = [
+    {file = "reactivex-4.0.4-py3-none-any.whl", hash = "sha256:0004796c420bd9e68aad8e65627d85a8e13f293de76656165dffbcb3a0e3fb6a"},
+    {file = "reactivex-4.0.4.tar.gz", hash = "sha256:e912e6591022ab9176df8348a653fe8c8fa7a301f26f9931c9d8c78a650e04e8"},
+]
+
+[package.dependencies]
+typing-extensions = ">=4.1.1,<5.0.0"
+
 [[package]]
 name = "referencing"
 version = "0.33.0"
@@ -847,6 +1016,17 @@ files = [
     {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
 ]
 
+[[package]]
+name = "typing-extensions"
+version = "4.12.2"
+description = "Backported and Experimental Type Hints for Python 3.8+"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
+    {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
+]
+
 [[package]]
 name = "tzdata"
 version = "2024.1"
@@ -861,4 +1041,4 @@ files = [
 [metadata]
 lock-version = "2.0"
 python-versions = ">=3.10,<4"
-content-hash = "26c3d35b236837b75919dfac79fb6ccf5d16038145a3e6e5d40fba3088783bce"
+content-hash = "95074890cfe3bd9c2e91276983f0b33527af68e4345ece6753297d6df882ed87"
diff --git a/python/datashaper/pyproject.toml b/python/datashaper/pyproject.toml
index d762df4c0..72ef96b8f 100644
--- a/python/datashaper/pyproject.toml
+++ b/python/datashaper/pyproject.toml
@@ -17,6 +17,7 @@ pyarrow = "^15.0.0"
 diskcache = "^5.6.3"
 dacite = "^1.8.1"
 
+reactivedataflow = "^0.1.0"
 [tool.poetry.group.dev.dependencies]
 codespell = "^2.2.6"
 poethepoet = "^0.24.4"

From f82903506b2e07ce477a7572b089ad0a03a66f05 Mon Sep 17 00:00:00 2001
From: Chris Trevino <chtrevin@microsoft.com>
Date: Mon, 24 Jun 2024 15:48:41 -0700
Subject: [PATCH 2/3] add binarize

---
 python/datashaper/datashaper/verbs/bin.py      | 12 ++++++------
 python/datashaper/datashaper/verbs/binarize.py | 15 +++++++++++++--
 2 files changed, 19 insertions(+), 8 deletions(-)

diff --git a/python/datashaper/datashaper/verbs/bin.py b/python/datashaper/datashaper/verbs/bin.py
index 0879206d5..88618ca4c 100644
--- a/python/datashaper/datashaper/verbs/bin.py
+++ b/python/datashaper/datashaper/verbs/bin.py
@@ -82,12 +82,12 @@ def __get_bucket_value(
         ConfigPort(name="to", required=True),
         ConfigPort(name="column", required=True),
         ConfigPort(name="strategy", required=True),
-        ConfigPort(name="min", required=False),
-        ConfigPort(name="max", required=False),
-        ConfigPort(name="fixedcount", required=False),
-        ConfigPort(name="fixedwidth", required=False),
-        ConfigPort(name="clamped", required=False),
-        ConfigPort(name="printRange", required=False),
+        ConfigPort(name="min"),
+        ConfigPort(name="max"),
+        ConfigPort(name="fixedcount"),
+        ConfigPort(name="fixedwidth"),
+        ConfigPort(name="clamped"),
+        ConfigPort(name="printRange"),
     ],
     adapters=[
         copy_input_tables("table"),
diff --git a/python/datashaper/datashaper/verbs/binarize.py b/python/datashaper/datashaper/verbs/binarize.py
index d05741cec..e020867ff 100644
--- a/python/datashaper/datashaper/verbs/binarize.py
+++ b/python/datashaper/datashaper/verbs/binarize.py
@@ -7,8 +7,11 @@
 from typing import Any, cast
 
 import pandas as pd
+from reactivedataflow import ConfigPort, InputPort, verb
 
-from .decorators import OutputMode, inputs, verb, wrap_verb_result
+from datashaper.constants import DEFAULT_INPUT_NAME
+
+from .decorators import OutputMode, copy_input_tables, wrap_verb_result
 from .filter import filter, get_comparison_operator
 from .types import (
     ComparisonStrategy,
@@ -19,8 +22,16 @@
 
 @verb(
     name="binarize",
+    ports=[
+        InputPort(name=DEFAULT_INPUT_NAME, parameter="table", required=True),
+        ConfigPort(name="to", required=True),
+        ConfigPort(name="column", required=True),
+        ConfigPort(name="value", required=True),
+        ConfigPort(name="strategy"),
+        ConfigPort(name="operator"),
+    ],
     adapters=[
-        inputs(default_input_argname="table"),
+        copy_input_tables("table"),
         wrap_verb_result(mode=OutputMode.Table),
     ],
 )

From bb6e0fae72be54ccc8a14ea1c6b0812333ca460b Mon Sep 17 00:00:00 2001
From: Chris Trevino <chtrevin@microsoft.com>
Date: Mon, 24 Jun 2024 16:25:54 -0700
Subject: [PATCH 3/3] add reactiveworkflow annotations to verbs

---
 python/datashaper/datashaper/__init__.py      |  6 ----
 .../datashaper/datashaper/verbs/__init__.py   |  6 ----
 .../datashaper/datashaper/verbs/aggregate.py  |  2 +-
 python/datashaper/datashaper/verbs/boolean.py | 13 ++++++--
 python/datashaper/datashaper/verbs/concat.py  | 16 +++++----
 python/datashaper/datashaper/verbs/convert.py | 17 ++++++++--
 python/datashaper/datashaper/verbs/copy.py    | 15 ++++++---
 .../datashaper/verbs/decorators/__init__.py   |  4 ---
 python/datashaper/datashaper/verbs/dedupe.py  | 17 +++++-----
 python/datashaper/datashaper/verbs/derive.py  | 15 ++++++---
 .../datashaper/verbs/destructure.py           | 14 ++++++--
 .../datashaper/datashaper/verbs/difference.py | 17 ++++++----
 python/datashaper/datashaper/verbs/drop.py    | 15 ++++++---
 .../datashaper/verbs/engine/__init__.py       |  2 --
 python/datashaper/datashaper/verbs/erase.py   | 18 ++++++----
 python/datashaper/datashaper/verbs/fill.py    | 18 ++++++----
 python/datashaper/datashaper/verbs/fold.py    | 13 ++++++--
 python/datashaper/datashaper/verbs/groupby.py | 18 +++++-----
 python/datashaper/datashaper/verbs/impute.py  | 18 ++++++----
 .../datashaper/datashaper/verbs/intersect.py  | 15 ++++++---
 python/datashaper/datashaper/verbs/join.py    | 15 ++++++---
 python/datashaper/datashaper/verbs/lookup.py  | 21 +++++++-----
 python/datashaper/datashaper/verbs/merge.py   | 16 +++++++--
 python/datashaper/datashaper/verbs/onehot.py  | 16 ++++++---
 python/datashaper/datashaper/verbs/orderby.py | 15 ++++++---
 python/datashaper/datashaper/verbs/pivot.py   | 18 ++++++----
 python/datashaper/datashaper/verbs/print.py   | 15 +++++----
 python/datashaper/datashaper/verbs/recode.py  | 17 +++++++---
 python/datashaper/datashaper/verbs/rename.py  | 17 ++++++----
 python/datashaper/datashaper/verbs/rollup.py  | 18 ++++++----
 python/datashaper/datashaper/verbs/sample.py  | 14 ++++++--
 python/datashaper/datashaper/verbs/select.py  | 15 ++++++---
 .../datashaper/datashaper/verbs/snapshot.py   | 17 ++++++----
 python/datashaper/datashaper/verbs/spread.py  | 14 ++++++--
 .../datashaper/verbs/strings/lower.py         | 30 +++++++++--------
 .../datashaper/verbs/strings/replace.py       | 33 +++++++++++--------
 .../datashaper/verbs/strings/upper.py         | 30 +++++++++--------
 python/datashaper/datashaper/verbs/unfold.py  | 16 ++++++---
 python/datashaper/datashaper/verbs/ungroup.py | 14 ++++----
 python/datashaper/datashaper/verbs/unhot.py   | 14 ++++++--
 python/datashaper/datashaper/verbs/union.py   | 18 +++++-----
 python/datashaper/datashaper/verbs/unorder.py | 14 ++++----
 python/datashaper/datashaper/verbs/unroll.py  | 15 +++++----
 python/datashaper/datashaper/verbs/window.py  | 15 ++++++---
 .../datashaper/datashaper/verbs/workflow.py   |  9 +++--
 .../datashaper/workflow/workflow.py           |  2 +-
 python/datashaper/pyproject.toml              |  2 +-
 47 files changed, 443 insertions(+), 256 deletions(-)

diff --git a/python/datashaper/datashaper/__init__.py b/python/datashaper/datashaper/__init__.py
index b757ed4e0..07879cc58 100644
--- a/python/datashaper/datashaper/__init__.py
+++ b/python/datashaper/datashaper/__init__.py
@@ -51,13 +51,10 @@
     VerbCallbacks,
     VerbDetails,
     VerbInput,
-    VerbManager,
     VerbResult,
     WindowFunction,
-    inputs,
     load_verbs,
     parallel_verb,
-    verb,
 )
 from .workflow import (
     DEFAULT_INPUT_NAME,
@@ -114,10 +111,7 @@
     "Bin",
     "Category",
     "VerbInput",
-    "VerbManager",
     "load_verbs",
-    "verb",
-    "inputs",
     "parallel_verb",
     "ParallelizationMode",
     "VerbDetails",
diff --git a/python/datashaper/datashaper/verbs/__init__.py b/python/datashaper/datashaper/verbs/__init__.py
index ac006263e..e512651dc 100644
--- a/python/datashaper/datashaper/verbs/__init__.py
+++ b/python/datashaper/datashaper/verbs/__init__.py
@@ -15,9 +15,7 @@
 from .decorators import (
     OutputMode,
     ParallelizationMode,
-    inputs,
     parallel_verb,
-    verb,
     wrap_verb_result,
 )
 from .dedupe import dedupe
@@ -28,7 +26,6 @@
 from .engine import (
     VerbDetails,
     VerbInput,
-    VerbManager,
     VerbResult,
     load_verbs,
 )
@@ -140,11 +137,8 @@
     "replace",
     # Verb Authoring
     "VerbInput",
-    "VerbManager",
     "load_verbs",
     # Decorators
-    "verb",
-    "inputs",
     "wrap_verb_result",
     "parallel_verb",
     "OutputMode",
diff --git a/python/datashaper/datashaper/verbs/aggregate.py b/python/datashaper/datashaper/verbs/aggregate.py
index 8d07d29d9..b1e8263b6 100644
--- a/python/datashaper/datashaper/verbs/aggregate.py
+++ b/python/datashaper/datashaper/verbs/aggregate.py
@@ -5,7 +5,7 @@
 """Aggregate verb implementation."""
 
 from functools import reduce
-from typing import Any, cast
+from typing import cast
 
 import pandas as pd
 from reactivedataflow import ConfigPort, InputPort, verb
diff --git a/python/datashaper/datashaper/verbs/boolean.py b/python/datashaper/datashaper/verbs/boolean.py
index 4941e5394..e3f0bde65 100644
--- a/python/datashaper/datashaper/verbs/boolean.py
+++ b/python/datashaper/datashaper/verbs/boolean.py
@@ -7,16 +7,25 @@
 from typing import Any
 
 import pandas as pd
+from reactivedataflow import ConfigPort, InputPort, verb
 
-from .decorators import OutputMode, inputs, verb, wrap_verb_result
+from datashaper.constants import DEFAULT_INPUT_NAME
+
+from .decorators import OutputMode, copy_input_tables, wrap_verb_result
 from .filter import boolean_function_map
 from .types import BooleanLogicalOperator
 
 
 @verb(
     name="boolean",
+    ports=[
+        InputPort(name=DEFAULT_INPUT_NAME, parameter="table", required=True),
+        ConfigPort(name="to", required=True),
+        ConfigPort(name="columns", required=True),
+        ConfigPort(name="operator", required=True),
+    ],
     adapters=[
-        inputs(default_input_argname="table"),
+        copy_input_tables("table"),
         wrap_verb_result(mode=OutputMode.Table),
     ],
 )
diff --git a/python/datashaper/datashaper/verbs/concat.py b/python/datashaper/datashaper/verbs/concat.py
index 760b8d3f9..dd1cec70f 100644
--- a/python/datashaper/datashaper/verbs/concat.py
+++ b/python/datashaper/datashaper/verbs/concat.py
@@ -4,23 +4,25 @@
 #
 """Concat verb implementation."""
 
-from typing import Any
-
 import pandas as pd
+from reactivedataflow import ArrayInputPort, InputPort, verb
+
+from datashaper.constants import DEFAULT_INPUT_NAME
 
-from .decorators import OutputMode, inputs, verb, wrap_verb_result
+from .decorators import OutputMode, wrap_verb_result
 
 
 @verb(
     name="concat",
     immutable_input=True,
+    ports=[
+        InputPort(name=DEFAULT_INPUT_NAME, parameter="table", required=True),
+        ArrayInputPort(name="others", required=True),
+    ],
     adapters=[
-        inputs(default_input_argname="table", variadic_input_argname="others"),
         wrap_verb_result(mode=OutputMode.Table),
     ],
 )
-def concat(
-    table: pd.DataFrame, others: list[pd.DataFrame], **_kwargs: Any
-) -> pd.DataFrame:
+def concat(table: pd.DataFrame, others: list[pd.DataFrame]) -> pd.DataFrame:
     """Concat verb implementation."""
     return pd.concat([table] + others, ignore_index=True)
diff --git a/python/datashaper/datashaper/verbs/convert.py b/python/datashaper/datashaper/verbs/convert.py
index c26a77428..90f2fda5d 100644
--- a/python/datashaper/datashaper/verbs/convert.py
+++ b/python/datashaper/datashaper/verbs/convert.py
@@ -12,8 +12,11 @@
 import numpy as np
 import pandas as pd
 from pandas.api.types import is_bool_dtype, is_datetime64_any_dtype, is_numeric_dtype
+from reactivedataflow import ConfigPort, InputPort, verb
 
-from .decorators import OutputMode, inputs, verb, wrap_verb_result
+from datashaper import DEFAULT_INPUT_NAME
+
+from .decorators import OutputMode, copy_input_tables, wrap_verb_result
 from .types import ParseType
 
 
@@ -116,8 +119,17 @@ def convert_value(value: Any) -> list:
 
 @verb(
     name="convert",
+    ports=[
+        InputPort(name=DEFAULT_INPUT_NAME, parameter="table", required=True),
+        ConfigPort(name="column", required=True),
+        ConfigPort(name="to", required=True),
+        ConfigPort(name="type", required=True),
+        ConfigPort(name="radix"),
+        ConfigPort(name="delimiter"),
+        ConfigPort(name="formatPattern"),
+    ],
     adapters=[
-        inputs(default_input_argname="table"),
+        copy_input_tables("table"),
         wrap_verb_result(mode=OutputMode.Table),
     ],
 )
@@ -129,7 +141,6 @@ def convert(
     radix: int | None = None,
     delimiter: str | None = ",",
     formatPattern: str = "%Y-%m-%d",  # noqa: N803
-    **_kwargs: Any,
 ) -> pd.DataFrame:
     """Convert verb implementation."""
     parse_type = ParseType(type)
diff --git a/python/datashaper/datashaper/verbs/copy.py b/python/datashaper/datashaper/verbs/copy.py
index 4517e2c04..7708a3006 100644
--- a/python/datashaper/datashaper/verbs/copy.py
+++ b/python/datashaper/datashaper/verbs/copy.py
@@ -4,17 +4,23 @@
 #
 """Copy verb implementation."""
 
-from typing import Any
-
 import pandas as pd
+from reactivedataflow import ConfigPort, InputPort, verb
+
+from datashaper import DEFAULT_INPUT_NAME
 
-from .decorators import OutputMode, inputs, verb, wrap_verb_result
+from .decorators import OutputMode, copy_input_tables, wrap_verb_result
 
 
 @verb(
     name="copy",
+    ports=[
+        InputPort(name=DEFAULT_INPUT_NAME, parameter="table", required=True),
+        ConfigPort(name="to", required=True),
+        ConfigPort(name="column", required=True),
+    ],
     adapters=[
-        inputs(default_input_argname="table"),
+        copy_input_tables("table"),
         wrap_verb_result(mode=OutputMode.Table),
     ],
 )
@@ -22,7 +28,6 @@ def copy(
     table: pd.DataFrame,
     to: str,
     column: str,
-    **_kwargs: Any,
 ) -> pd.DataFrame:
     """Copy verb implementation."""
     table[to] = table[column]
diff --git a/python/datashaper/datashaper/verbs/decorators/__init__.py b/python/datashaper/datashaper/verbs/decorators/__init__.py
index 647159e82..ec965f9e7 100644
--- a/python/datashaper/datashaper/verbs/decorators/__init__.py
+++ b/python/datashaper/datashaper/verbs/decorators/__init__.py
@@ -2,18 +2,14 @@
 
 from .apply_decorators import apply_decorators
 from .copy_input_tables import copy_input_tables
-from .inputs import inputs
 from .parallel_verb import ParallelizationMode, new_row, parallel_verb
-from .verb import verb
 from .wrap_verb_result import OutputMode, wrap_verb_result
 
 __all__ = [
     "new_row",
-    "verb",
     "copy_input_tables",
     "parallel_verb",
     "ParallelizationMode",
-    "inputs",
     "apply_decorators",
     "wrap_verb_result",
     "OutputMode",
diff --git a/python/datashaper/datashaper/verbs/dedupe.py b/python/datashaper/datashaper/verbs/dedupe.py
index e1d43b432..f85eb94b5 100644
--- a/python/datashaper/datashaper/verbs/dedupe.py
+++ b/python/datashaper/datashaper/verbs/dedupe.py
@@ -4,23 +4,24 @@
 #
 """Dedupe verb implementation."""
 
-from typing import Any
-
 import pandas as pd
+from reactivedataflow import ConfigPort, InputPort, verb
+
+from datashaper import DEFAULT_INPUT_NAME
 
-from .decorators import OutputMode, inputs, verb, wrap_verb_result
+from .decorators import OutputMode, wrap_verb_result
 
 
 @verb(
     name="dedupe",
-    immutable_input=True,
+    ports=[
+        InputPort(name=DEFAULT_INPUT_NAME, parameter="table", required=True),
+        ConfigPort(name="columns", required=False),
+    ],
     adapters=[
-        inputs(default_input_argname="table"),
         wrap_verb_result(mode=OutputMode.Table),
     ],
 )
-def dedupe(
-    table: pd.DataFrame, columns: list[str] | None = None, **_kwargs: Any
-) -> pd.DataFrame:
+def dedupe(table: pd.DataFrame, columns: list[str] | None = None) -> pd.DataFrame:
     """Dedupe verb implementation."""
     return table.drop_duplicates(columns)
diff --git a/python/datashaper/datashaper/verbs/derive.py b/python/datashaper/datashaper/verbs/derive.py
index e2c5154ab..b51913f5e 100644
--- a/python/datashaper/datashaper/verbs/derive.py
+++ b/python/datashaper/datashaper/verbs/derive.py
@@ -5,15 +5,16 @@
 """Derive verb implementation."""
 
 from collections.abc import Callable
-from typing import Any
 
 import numpy as np
 import pandas as pd
 from pandas.api.types import is_numeric_dtype
+from reactivedataflow import ConfigPort, InputPort, verb
 
+from datashaper import DEFAULT_INPUT_NAME
 from datashaper.errors import VerbOperationNotSupportedError
 
-from .decorators import OutputMode, inputs, verb, wrap_verb_result
+from .decorators import OutputMode, copy_input_tables, wrap_verb_result
 from .types import MathOperator
 
 
@@ -40,8 +41,15 @@ def __concatenate(col1: pd.Series, col2: pd.Series) -> pd.Series:
 
 @verb(
     name="derive",
+    ports=[
+        InputPort(name=DEFAULT_INPUT_NAME, parameter="table", required=True),
+        ConfigPort(name="to", required=True),
+        ConfigPort(name="column1", required=True),
+        ConfigPort(name="column2", required=True),
+        ConfigPort(name="operator", required=True),
+    ],
     adapters=[
-        inputs(default_input_argname="table"),
+        copy_input_tables("table"),
         wrap_verb_result(mode=OutputMode.Table),
     ],
 )
@@ -51,7 +59,6 @@ def derive(
     column1: str,
     column2: str,
     operator: str,
-    **_kwargs: Any,
 ) -> pd.DataFrame:
     """Derive verb implementation."""
     math_operator = MathOperator(operator)
diff --git a/python/datashaper/datashaper/verbs/destructure.py b/python/datashaper/datashaper/verbs/destructure.py
index 94aad05d2..3e8d296d3 100644
--- a/python/datashaper/datashaper/verbs/destructure.py
+++ b/python/datashaper/datashaper/verbs/destructure.py
@@ -8,14 +8,23 @@
 from typing import Any
 
 import pandas as pd
+from reactivedataflow import ConfigPort, InputPort, verb
 
-from .decorators import OutputMode, inputs, verb, wrap_verb_result
+from datashaper import DEFAULT_INPUT_NAME
+
+from .decorators import OutputMode, copy_input_tables, wrap_verb_result
 
 
 @verb(
     name="destructure",
+    ports=[
+        InputPort(name=DEFAULT_INPUT_NAME, parameter="table", required=True),
+        ConfigPort(name="column", required=True),
+        ConfigPort(name="keys", required=False),
+        ConfigPort(name="preserveSource"),
+    ],
     adapters=[
-        inputs(default_input_argname="table"),
+        copy_input_tables("table"),
         wrap_verb_result(mode=OutputMode.Table),
     ],
 )
@@ -24,7 +33,6 @@ def destructure(
     column: str,
     keys: list[str],
     preserveSource: bool = False,  # noqa: N803
-    **_kwargs: Any,
 ) -> pd.DataFrame:
     """Destructure verb implementation."""
     results = []
diff --git a/python/datashaper/datashaper/verbs/difference.py b/python/datashaper/datashaper/verbs/difference.py
index 8e10a642d..49bace212 100644
--- a/python/datashaper/datashaper/verbs/difference.py
+++ b/python/datashaper/datashaper/verbs/difference.py
@@ -4,23 +4,28 @@
 #
 """Difference verb implementation."""
 
-from typing import Any, cast
+from typing import cast
 
 import pandas as pd
+from reactivedataflow import ArrayInputPort, InputPort, verb
 
-from .decorators import OutputMode, inputs, verb, wrap_verb_result
+from datashaper import DEFAULT_INPUT_NAME
+
+from .decorators import OutputMode, copy_input_tables, wrap_verb_result
 
 
 @verb(
     name="difference",
+    ports=[
+        InputPort(name=DEFAULT_INPUT_NAME, parameter="table", required=True),
+        ArrayInputPort(name="others", required=True),
+    ],
     adapters=[
-        inputs(default_input_argname="table", variadic_input_argname="others"),
+        copy_input_tables("table"),
         wrap_verb_result(mode=OutputMode.Table),
     ],
 )
-def difference(
-    table: pd.DataFrame, others: list[pd.DataFrame], **_kwargs: Any
-) -> pd.DataFrame:
+def difference(table: pd.DataFrame, others: list[pd.DataFrame]) -> pd.DataFrame:
     """Difference verb implementation."""
     output = table.merge(pd.concat(others), how="left", indicator=True)
     output = output[output["_merge"] == "left_only"]
diff --git a/python/datashaper/datashaper/verbs/drop.py b/python/datashaper/datashaper/verbs/drop.py
index e9741f5fe..054d6b1df 100644
--- a/python/datashaper/datashaper/verbs/drop.py
+++ b/python/datashaper/datashaper/verbs/drop.py
@@ -4,20 +4,25 @@
 #
 """Drop verb implementation."""
 
-from typing import Any
-
 import pandas as pd
+from reactivedataflow import ConfigPort, InputPort, verb
+
+from datashaper import DEFAULT_INPUT_NAME
 
-from .decorators import OutputMode, inputs, verb, wrap_verb_result
+from .decorators import OutputMode, copy_input_tables, wrap_verb_result
 
 
 @verb(
     name="drop",
+    ports=[
+        InputPort(name=DEFAULT_INPUT_NAME, parameter="table", required=True),
+        ConfigPort(name="columns", required=True),
+    ],
     adapters=[
-        inputs(default_input_argname="table"),
+        copy_input_tables("table"),
         wrap_verb_result(mode=OutputMode.Table),
     ],
 )
-def drop(table: pd.DataFrame, columns: list[str], **_kwargs: Any) -> pd.DataFrame:
+def drop(table: pd.DataFrame, columns: list[str]) -> pd.DataFrame:
     """Drop verb implementation."""
     return table.drop(columns=columns)
diff --git a/python/datashaper/datashaper/verbs/engine/__init__.py b/python/datashaper/datashaper/verbs/engine/__init__.py
index f5019bfd5..5c453a3b8 100644
--- a/python/datashaper/datashaper/verbs/engine/__init__.py
+++ b/python/datashaper/datashaper/verbs/engine/__init__.py
@@ -6,7 +6,6 @@
 from .load_verbs import load_verbs
 from .types import VerbDetails, VerbResult
 from .verb_input import VerbInput
-from .verb_manager import VerbManager
 
 # Load core verbs into VerbManager
 mod = sys.modules[__name__]
@@ -15,7 +14,6 @@
 
 __all__ = [
     "VerbInput",
-    "VerbManager",
     "create_verb_result",
     "load_verbs",
     "VerbResult",
diff --git a/python/datashaper/datashaper/verbs/erase.py b/python/datashaper/datashaper/verbs/erase.py
index d2e22eb4d..790a712ed 100644
--- a/python/datashaper/datashaper/verbs/erase.py
+++ b/python/datashaper/datashaper/verbs/erase.py
@@ -4,23 +4,27 @@
 #
 """Erase verb implementation."""
 
-from typing import Any
-
 import pandas as pd
+from reactivedataflow import ConfigPort, InputPort, verb
+
+from datashaper import DEFAULT_INPUT_NAME
 
-from .decorators import OutputMode, inputs, verb, wrap_verb_result
+from .decorators import OutputMode, copy_input_tables, wrap_verb_result
 
 
 @verb(
     name="erase",
+    ports=[
+        InputPort(name=DEFAULT_INPUT_NAME, parameter="table", required=True),
+        ConfigPort(name="column", required=True),
+        ConfigPort(name="value", required=True),
+    ],
     adapters=[
-        inputs(default_input_argname="table"),
+        copy_input_tables("table"),
         wrap_verb_result(mode=OutputMode.Table),
     ],
 )
-def erase(
-    table: pd.DataFrame, column: str, value: str | float, **_kwargs: Any
-) -> pd.DataFrame:
+def erase(table: pd.DataFrame, column: str, value: str | float) -> pd.DataFrame:
     """Erase verb implementation."""
     table[column] = table[column].apply(
         lambda df_value: None if df_value == value else df_value
diff --git a/python/datashaper/datashaper/verbs/fill.py b/python/datashaper/datashaper/verbs/fill.py
index 5613bf766..747d5ed1f 100644
--- a/python/datashaper/datashaper/verbs/fill.py
+++ b/python/datashaper/datashaper/verbs/fill.py
@@ -4,23 +4,27 @@
 #
 """Fill verb implementation."""
 
-from typing import Any
-
 import pandas as pd
+from reactivedataflow import ConfigPort, InputPort, verb
+
+from datashaper import DEFAULT_INPUT_NAME
 
-from .decorators import OutputMode, inputs, verb, wrap_verb_result
+from .decorators import OutputMode, copy_input_tables, wrap_verb_result
 
 
 @verb(
     name="fill",
+    ports=[
+        InputPort(name=DEFAULT_INPUT_NAME, parameter="table", required=True),
+        ConfigPort(name="to", required=True),
+        ConfigPort(name="value", required=True),
+    ],
     adapters=[
-        inputs(default_input_argname="table"),
+        copy_input_tables("table"),
         wrap_verb_result(mode=OutputMode.Table),
     ],
 )
-def fill(
-    table: pd.DataFrame, to: str, value: str | float | bool, **_kwargs: Any
-) -> pd.DataFrame:
+def fill(table: pd.DataFrame, to: str, value: str | float | bool) -> pd.DataFrame:
     """Fill verb implementation."""
     table[to] = value
     return table
diff --git a/python/datashaper/datashaper/verbs/fold.py b/python/datashaper/datashaper/verbs/fold.py
index 6570662de..8471b48b9 100644
--- a/python/datashaper/datashaper/verbs/fold.py
+++ b/python/datashaper/datashaper/verbs/fold.py
@@ -7,14 +7,23 @@
 from typing import Any, cast
 
 import pandas as pd
+from reactivedataflow import ConfigPort, InputPort, verb
 
-from .decorators import OutputMode, inputs, verb, wrap_verb_result
+from datashaper import DEFAULT_INPUT_NAME
+
+from .decorators import OutputMode, copy_input_tables, wrap_verb_result
 
 
 @verb(
     name="fold",
+    ports=[
+        InputPort(name=DEFAULT_INPUT_NAME, parameter="table", required=True),
+        ConfigPort(name="to", required=True),
+        ConfigPort(name="columns", required=True),
+        ConfigPort(name="preserveSource"),
+    ],
     adapters=[
-        inputs(default_input_argname="table"),
+        copy_input_tables("table"),
         wrap_verb_result(mode=OutputMode.Table),
     ],
 )
diff --git a/python/datashaper/datashaper/verbs/groupby.py b/python/datashaper/datashaper/verbs/groupby.py
index b2877fa0b..3b6a9716b 100644
--- a/python/datashaper/datashaper/verbs/groupby.py
+++ b/python/datashaper/datashaper/verbs/groupby.py
@@ -3,24 +3,26 @@
 # Licensed under the MIT license. See LICENSE file in the project.
 #
 """Groupby verb implementation."""
-
-from typing import Any
-
 import pandas as pd
 from pandas.core.groupby import DataFrameGroupBy
+from reactivedataflow import ConfigPort, InputPort, verb
 
-from .decorators import OutputMode, inputs, verb, wrap_verb_result
+from datashaper import DEFAULT_INPUT_NAME
+
+from .decorators import OutputMode, copy_input_tables, wrap_verb_result
 
 
 @verb(
     name="groupby",
+    ports=[
+        InputPort(name=DEFAULT_INPUT_NAME, parameter="table", required=True),
+        ConfigPort(name="columns", required=True),
+    ],
     adapters=[
-        inputs(default_input_argname="table"),
+        copy_input_tables("table"),
         wrap_verb_result(mode=OutputMode.Table),
     ],
 )
-def groupby(
-    table: pd.DataFrame, columns: list[str], **_kwargs: Any
-) -> DataFrameGroupBy:
+def groupby(table: pd.DataFrame, columns: list[str]) -> DataFrameGroupBy:
     """Groupby verb implementation."""
     return table.groupby(by=columns)
diff --git a/python/datashaper/datashaper/verbs/impute.py b/python/datashaper/datashaper/verbs/impute.py
index 95a342e82..120344960 100644
--- a/python/datashaper/datashaper/verbs/impute.py
+++ b/python/datashaper/datashaper/verbs/impute.py
@@ -4,23 +4,29 @@
 #
 """Impute verb implementation."""
 
-from typing import Any, cast
+from typing import cast
 
 import pandas as pd
+from reactivedataflow import ConfigPort, InputPort, verb
 
-from .decorators import OutputMode, inputs, verb, wrap_verb_result
+from datashaper import DEFAULT_INPUT_NAME
+
+from .decorators import OutputMode, copy_input_tables, wrap_verb_result
 
 
 @verb(
     name="impute",
+    ports=[
+        InputPort(name=DEFAULT_INPUT_NAME, parameter="table", required=True),
+        ConfigPort(name="column", required=True),
+        ConfigPort(name="value", required=True),
+    ],
     adapters=[
-        inputs(default_input_argname="table"),
+        copy_input_tables("table"),
         wrap_verb_result(mode=OutputMode.Table),
     ],
 )
-def impute(
-    table: pd.DataFrame, column: str, value: str | float | bool, **_kwargs: Any
-) -> pd.DataFrame:
+def impute(table: pd.DataFrame, column: str, value: str | float | bool) -> pd.DataFrame:
     """Impute verb implementation."""
     table[column] = cast(pd.Series, table[column].fillna(value))
     return table
diff --git a/python/datashaper/datashaper/verbs/intersect.py b/python/datashaper/datashaper/verbs/intersect.py
index 55cba4bc3..603d3b126 100644
--- a/python/datashaper/datashaper/verbs/intersect.py
+++ b/python/datashaper/datashaper/verbs/intersect.py
@@ -7,21 +7,26 @@
 from typing import cast
 
 import pandas as pd
+from reactivedataflow import ArrayInputPort, InputPort, verb
 
-from .decorators import OutputMode, inputs, verb, wrap_verb_result
+from datashaper import DEFAULT_INPUT_NAME
+
+from .decorators import OutputMode, copy_input_tables, wrap_verb_result
 
 
 @verb(
     name="intersect",
     immutable_input=True,
+    ports=[
+        InputPort(name=DEFAULT_INPUT_NAME, parameter="table", required=True),
+        ArrayInputPort(name="others", required=True),
+    ],
     adapters=[
-        inputs(default_input_argname="table", variadic_input_argname="others"),
+        copy_input_tables("table"),
         wrap_verb_result(mode=OutputMode.Table),
     ],
 )
-def intersect(
-    table: pd.DataFrame, others: list[pd.DataFrame], **_kwargs: dict
-) -> pd.DataFrame:
+def intersect(table: pd.DataFrame, others: list[pd.DataFrame]) -> pd.DataFrame:
     """Intersect verb implementation."""
     output = table.merge(pd.concat(others), how="left", indicator=True)
     output = output[output["_merge"] == "both"]
diff --git a/python/datashaper/datashaper/verbs/join.py b/python/datashaper/datashaper/verbs/join.py
index 1068e7a54..88c5f4fa1 100644
--- a/python/datashaper/datashaper/verbs/join.py
+++ b/python/datashaper/datashaper/verbs/join.py
@@ -4,12 +4,15 @@
 #
 """Join verb implementation."""
 
-from typing import Any, cast
+from typing import cast
 
 import pandas as pd
 from pandas._typing import MergeHow, Suffixes
+from reactivedataflow import ConfigPort, InputPort, verb
 
-from .decorators import OutputMode, inputs, verb, wrap_verb_result
+from datashaper import DEFAULT_INPUT_NAME
+
+from .decorators import OutputMode, wrap_verb_result
 from .types import JoinStrategy
 
 __strategy_mapping: dict[JoinStrategy, MergeHow] = {
@@ -49,8 +52,13 @@ def __clean_result(
 @verb(
     name="join",
     immutable_input=True,
+    ports=[
+        InputPort(name=DEFAULT_INPUT_NAME, parameter="table", required=True),
+        InputPort(name="other", required=True),
+        ConfigPort(name="on"),
+        ConfigPort(name="strategy"),
+    ],
     adapters=[
-        inputs(default_input_argname="table", input_argnames={"other": "other"}),
         wrap_verb_result(mode=OutputMode.Table),
     ],
 )
@@ -59,7 +67,6 @@ def join(
     other: pd.DataFrame,
     on: list[str] | None = None,
     strategy: str = "inner",
-    **_kwargs: Any,
 ) -> pd.DataFrame:
     """Join verb implementation."""
     join_strategy = JoinStrategy(strategy)
diff --git a/python/datashaper/datashaper/verbs/lookup.py b/python/datashaper/datashaper/verbs/lookup.py
index fb8df5154..b6dc2b81e 100644
--- a/python/datashaper/datashaper/verbs/lookup.py
+++ b/python/datashaper/datashaper/verbs/lookup.py
@@ -4,27 +4,30 @@
 #
 """Lookup verb implementation."""
 
-from typing import Any, cast
+from typing import cast
 
 import pandas as pd
+from reactivedataflow import ConfigPort, InputPort, verb
 
-from .decorators import OutputMode, inputs, verb, wrap_verb_result
+from datashaper import DEFAULT_INPUT_NAME
+
+from .decorators import OutputMode, wrap_verb_result
 
 
 @verb(
     name="lookup",
-    immutable_input=True,
+    ports=[
+        InputPort(name=DEFAULT_INPUT_NAME, parameter="table", required=True),
+        InputPort(name="other", required=True),
+        ConfigPort(name="columns", required=True),
+        ConfigPort(name="on"),
+    ],
     adapters=[
-        inputs(default_input_argname="table", input_argnames={"other": "other"}),
         wrap_verb_result(mode=OutputMode.Table),
     ],
 )
 def lookup(
-    table: pd.DataFrame,
-    other: pd.DataFrame,
-    columns: list[str],
-    on: list[str] | None = None,
-    **_kwargs: Any,
+    table: pd.DataFrame, other: pd.DataFrame, columns: list[str], on: list[str] | None
 ) -> pd.DataFrame:
     """Lookup verb implementation."""
     if on is not None and len(on) > 1:
diff --git a/python/datashaper/datashaper/verbs/merge.py b/python/datashaper/datashaper/verbs/merge.py
index 92d962165..0a7ad676f 100644
--- a/python/datashaper/datashaper/verbs/merge.py
+++ b/python/datashaper/datashaper/verbs/merge.py
@@ -10,15 +10,26 @@
 
 import pandas as pd
 from pandas.api.types import is_bool
+from reactivedataflow import ConfigPort, InputPort, verb
 
-from .decorators import OutputMode, inputs, verb, wrap_verb_result
+from datashaper import DEFAULT_INPUT_NAME
+
+from .decorators import OutputMode, copy_input_tables, wrap_verb_result
 from .types import MergeStrategy
 
 
 @verb(
     name="merge",
+    ports=[
+        InputPort(name=DEFAULT_INPUT_NAME, parameter="table", required=True),
+        ConfigPort(name="to", required=True),
+        ConfigPort(name="columns", required=True),
+        ConfigPort(name="strategy", required=True),
+        ConfigPort(name="delimiter"),
+        ConfigPort(name="preserveSource"),
+    ],
     adapters=[
-        inputs(default_input_argname="table"),
+        copy_input_tables("table"),
         wrap_verb_result(mode=OutputMode.Table),
     ],
 )
@@ -29,7 +40,6 @@ def merge(
     strategy: str,
     delimiter: str = "",
     preserveSource: bool = False,  # noqa: N803
-    **_kwargs: Any,
 ) -> pd.DataFrame:
     """Merge verb implementation."""
     merge_strategy = MergeStrategy(strategy)
diff --git a/python/datashaper/datashaper/verbs/onehot.py b/python/datashaper/datashaper/verbs/onehot.py
index f4c1fd75b..1bd4dc0fe 100644
--- a/python/datashaper/datashaper/verbs/onehot.py
+++ b/python/datashaper/datashaper/verbs/onehot.py
@@ -4,18 +4,25 @@
 #
 """Onehot verb implementation."""
 
-from typing import Any
-
 import numpy as np
 import pandas as pd
+from reactivedataflow import ConfigPort, InputPort, verb
+
+from datashaper import DEFAULT_INPUT_NAME
 
-from .decorators import OutputMode, inputs, verb, wrap_verb_result
+from .decorators import OutputMode, copy_input_tables, wrap_verb_result
 
 
 @verb(
     name="onehot",
+    ports=[
+        InputPort(name=DEFAULT_INPUT_NAME, parameter="table", required=True),
+        ConfigPort(name="column", required=True),
+        ConfigPort(name="prefix"),
+        ConfigPort(name="preserveSource"),
+    ],
     adapters=[
-        inputs(default_input_argname="table"),
+        copy_input_tables("table"),
         wrap_verb_result(mode=OutputMode.Table),
     ],
 )
@@ -24,7 +31,6 @@ def onehot(
     column: str,
     prefix: str = "",
     preserveSource: bool = False,  # noqa: N803
-    **_kwargs: Any,
 ) -> pd.DataFrame:
     """Onehot verb implementation."""
     table[column] = table[column].astype("category")
diff --git a/python/datashaper/datashaper/verbs/orderby.py b/python/datashaper/datashaper/verbs/orderby.py
index 07237e29e..de4d7421e 100644
--- a/python/datashaper/datashaper/verbs/orderby.py
+++ b/python/datashaper/datashaper/verbs/orderby.py
@@ -4,22 +4,27 @@
 #
 """Orderby verb implementation."""
 
-from typing import Any
-
 import pandas as pd
+from reactivedataflow import ConfigPort, InputPort, verb
+
+from datashaper import DEFAULT_INPUT_NAME
 
-from .decorators import OutputMode, inputs, verb, wrap_verb_result
+from .decorators import OutputMode, copy_input_tables, wrap_verb_result
 from .types import OrderByInstruction, SortDirection
 
 
 @verb(
     name="orderby",
+    ports=[
+        InputPort(name=DEFAULT_INPUT_NAME, parameter="table", required=True),
+        ConfigPort(name="orders", required=True),
+    ],
     adapters=[
-        inputs(default_input_argname="table"),
+        copy_input_tables("table"),
         wrap_verb_result(mode=OutputMode.Table),
     ],
 )
-def orderby(table: pd.DataFrame, orders: list[dict], **_kwargs: Any) -> pd.DataFrame:
+def orderby(table: pd.DataFrame, orders: list[dict]) -> pd.DataFrame:
     """Orderby verb implementation."""
     orders_instructions = [
         OrderByInstruction(
diff --git a/python/datashaper/datashaper/verbs/pivot.py b/python/datashaper/datashaper/verbs/pivot.py
index ed332265b..b4cc64c58 100644
--- a/python/datashaper/datashaper/verbs/pivot.py
+++ b/python/datashaper/datashaper/verbs/pivot.py
@@ -4,26 +4,30 @@
 #
 """Pivot verb implementation."""
 
-from typing import Any
 
 import pandas as pd
+from reactivedataflow import ConfigPort, InputPort, verb
+
+from datashaper import DEFAULT_INPUT_NAME
 
 from .aggregate import aggregate_operation_mapping
-from .decorators import OutputMode, inputs, verb, wrap_verb_result
+from .decorators import OutputMode, wrap_verb_result
 from .types import FieldAggregateOperation
 
 
 @verb(
     name="pivot",
-    immutable_input=True,
+    ports=[
+        InputPort(name=DEFAULT_INPUT_NAME, parameter="table", required=True),
+        ConfigPort(name="key", required=True),
+        ConfigPort(name="value", required=True),
+        ConfigPort(name="operation", required=True),
+    ],
     adapters=[
-        inputs(default_input_argname="table"),
         wrap_verb_result(mode=OutputMode.Table),
     ],
 )
-def pivot(
-    table: pd.DataFrame, key: str, value: str, operation: str, **_kwargs: Any
-) -> pd.DataFrame:
+def pivot(table: pd.DataFrame, key: str, value: str, operation: str) -> pd.DataFrame:
     """Pivot verb implementation."""
     aggregate_operation = FieldAggregateOperation(operation)
     return table.pivot_table(
diff --git a/python/datashaper/datashaper/verbs/print.py b/python/datashaper/datashaper/verbs/print.py
index ed05b4103..35edf54df 100644
--- a/python/datashaper/datashaper/verbs/print.py
+++ b/python/datashaper/datashaper/verbs/print.py
@@ -4,25 +4,28 @@
 #
 """Print verb implementation."""
 
-from typing import Any
-
 import pandas as pd
+from reactivedataflow import ConfigPort, InputPort, verb
+
+from datashaper import DEFAULT_INPUT_NAME
 
-from .decorators import OutputMode, inputs, verb, wrap_verb_result
+from .decorators import OutputMode, wrap_verb_result
 
 raw_print = print
 
 
 @verb(
     name="print",
-    immutable_input=True,
+    ports=[
+        InputPort(name=DEFAULT_INPUT_NAME, parameter="table", required=True),
+        ConfigPort(name="message", required=True),
+    ],
     adapters=[
-        inputs(default_input_argname="table"),
         wrap_verb_result(mode=OutputMode.Table),
     ],
 )
 def print(  # noqa A001 - use ds verb name
-    table: pd.DataFrame, message: str, limit: int = 10, **_kwargs: Any
+    table: pd.DataFrame, message: str, limit: int = 10
 ) -> pd.DataFrame:
     """Print verb implementation."""
     # TODO(Chris): should we use a logger for these instead of prints?
diff --git a/python/datashaper/datashaper/verbs/recode.py b/python/datashaper/datashaper/verbs/recode.py
index ed90d9507..b4e39c789 100644
--- a/python/datashaper/datashaper/verbs/recode.py
+++ b/python/datashaper/datashaper/verbs/recode.py
@@ -7,8 +7,11 @@
 from typing import Any, cast
 
 import pandas as pd
+from reactivedataflow import ConfigPort, InputPort, verb
 
-from .decorators import OutputMode, inputs, verb, wrap_verb_result
+from datashaper import DEFAULT_INPUT_NAME
+
+from .decorators import OutputMode, copy_input_tables, wrap_verb_result
 
 
 class RecodeMap(dict):
@@ -21,14 +24,18 @@ def __missing__(self, key: str):
 
 @verb(
     name="recode",
+    ports=[
+        InputPort(name=DEFAULT_INPUT_NAME, parameter="table", required=True),
+        ConfigPort(name="to", required=True),
+        ConfigPort(name="column", required=True),
+        ConfigPort(name="mapping", required=True),
+    ],
     adapters=[
-        inputs(default_input_argname="table"),
+        copy_input_tables("table"),
         wrap_verb_result(mode=OutputMode.Table),
     ],
 )
-def recode(
-    table: pd.DataFrame, to: str, column: str, mapping: dict, **_kwargs: Any
-) -> pd.DataFrame:
+def recode(table: pd.DataFrame, to: str, column: str, mapping: dict) -> pd.DataFrame:
     """Recode verb implementation."""
     mapping = RecodeMap(mapping)
     table[to] = table[column].map(cast(Any, mapping))
diff --git a/python/datashaper/datashaper/verbs/rename.py b/python/datashaper/datashaper/verbs/rename.py
index 5ada8118d..a5d29841e 100644
--- a/python/datashaper/datashaper/verbs/rename.py
+++ b/python/datashaper/datashaper/verbs/rename.py
@@ -4,22 +4,25 @@
 #
 """Rename verb implementation."""
 
-from typing import Any
-
 import pandas as pd
+from reactivedataflow import ConfigPort, InputPort, verb
+
+from datashaper import DEFAULT_INPUT_NAME
 
-from .decorators import OutputMode, inputs, verb, wrap_verb_result
+from .decorators import OutputMode, copy_input_tables, wrap_verb_result
 
 
 @verb(
     name="rename",
+    ports=[
+        InputPort(name=DEFAULT_INPUT_NAME, parameter="table", required=True),
+        ConfigPort(name="columns", required=True),
+    ],
     adapters=[
-        inputs(default_input_argname="table"),
+        copy_input_tables("table"),
         wrap_verb_result(mode=OutputMode.Table),
     ],
 )
-def rename(
-    table: pd.DataFrame, columns: dict[str, str], **_kwargs: Any
-) -> pd.DataFrame:
+def rename(table: pd.DataFrame, columns: dict[str, str]) -> pd.DataFrame:
     """Rename verb implementation."""
     return table.rename(columns=columns)
diff --git a/python/datashaper/datashaper/verbs/rollup.py b/python/datashaper/datashaper/verbs/rollup.py
index e26e2edbf..9ca8b3dae 100644
--- a/python/datashaper/datashaper/verbs/rollup.py
+++ b/python/datashaper/datashaper/verbs/rollup.py
@@ -5,26 +5,30 @@
 """Rollup verb implementation."""
 
 from collections.abc import Iterable
-from typing import Any
 
 import pandas as pd
+from reactivedataflow import ConfigPort, InputPort, verb
+
+from datashaper import DEFAULT_INPUT_NAME
 
 from .aggregate import aggregate_operation_mapping
-from .decorators import OutputMode, inputs, verb, wrap_verb_result
+from .decorators import OutputMode, wrap_verb_result
 from .types import FieldAggregateOperation
 
 
 @verb(
     name="rollup",
-    immutable_input=True,
+    ports=[
+        InputPort(name=DEFAULT_INPUT_NAME, parameter="table", required=True),
+        ConfigPort(name="column", required=True),
+        ConfigPort(name="to", required=True),
+        ConfigPort(name="operation", required=True),
+    ],
     adapters=[
-        inputs(default_input_argname="table"),
         wrap_verb_result(mode=OutputMode.Table),
     ],
 )
-def rollup(
-    table: pd.DataFrame, column: str, to: str, operation: str, **_kwargs: Any
-) -> pd.DataFrame:
+def rollup(table: pd.DataFrame, column: str, to: str, operation: str) -> pd.DataFrame:
     """Rollup verb implementation."""
     aggregate_operation = FieldAggregateOperation(operation)
 
diff --git a/python/datashaper/datashaper/verbs/sample.py b/python/datashaper/datashaper/verbs/sample.py
index 48f9facde..badaac700 100644
--- a/python/datashaper/datashaper/verbs/sample.py
+++ b/python/datashaper/datashaper/verbs/sample.py
@@ -7,15 +7,23 @@
 from typing import Any, cast
 
 import pandas as pd
+from reactivedataflow import ConfigPort, InputPort, verb
 
-from .decorators import OutputMode, inputs, verb, wrap_verb_result
+from datashaper import DEFAULT_INPUT_NAME
+
+from .decorators import OutputMode, wrap_verb_result
 
 
 @verb(
     name="sample",
-    immutable_input=True,
+    ports=[
+        InputPort(name=DEFAULT_INPUT_NAME, parameter="table", required=True),
+        ConfigPort(name="size"),
+        ConfigPort(name="proportion"),
+        ConfigPort(name="seed"),
+        ConfigPort(name="emitRemainder"),
+    ],
     adapters=[
-        inputs(default_input_argname="table"),
         wrap_verb_result(
             mode=OutputMode.Tuple,
             output_names=["remainder"],
diff --git a/python/datashaper/datashaper/verbs/select.py b/python/datashaper/datashaper/verbs/select.py
index fe032a170..126b57537 100644
--- a/python/datashaper/datashaper/verbs/select.py
+++ b/python/datashaper/datashaper/verbs/select.py
@@ -4,21 +4,26 @@
 #
 """Select verb implementation."""
 
-from typing import Any, cast
+from typing import cast
 
 import pandas as pd
+from reactivedataflow import ConfigPort, InputPort, verb
 
-from .decorators import OutputMode, inputs, verb, wrap_verb_result
+from datashaper import DEFAULT_INPUT_NAME
+
+from .decorators import OutputMode, wrap_verb_result
 
 
 @verb(
     name="select",
-    immutable_input=True,
+    ports=[
+        InputPort(name=DEFAULT_INPUT_NAME, parameter="table", required=True),
+        ConfigPort(name="columns", required=True),
+    ],
     adapters=[
-        inputs(default_input_argname="table"),
         wrap_verb_result(mode=OutputMode.Table),
     ],
 )
-def select(table: pd.DataFrame, columns: list[str], **_kwargs: Any) -> pd.DataFrame:
+def select(table: pd.DataFrame, columns: list[str]) -> pd.DataFrame:
     """Select verb implementation."""
     return cast(pd.DataFrame, table[columns])
diff --git a/python/datashaper/datashaper/verbs/snapshot.py b/python/datashaper/datashaper/verbs/snapshot.py
index ccf2bf9aa..950d3e149 100644
--- a/python/datashaper/datashaper/verbs/snapshot.py
+++ b/python/datashaper/datashaper/verbs/snapshot.py
@@ -4,25 +4,28 @@
 #
 """Snapshot verb implementation."""
 
-from typing import Any
 
 import pandas as pd
+from reactivedataflow import ConfigPort, InputPort, verb
 
-from .decorators import OutputMode, inputs, verb, wrap_verb_result
+from datashaper import DEFAULT_INPUT_NAME
+
+from .decorators import OutputMode, wrap_verb_result
 from .types import FileFormat
 
 
 @verb(
     name="snapshot",
-    immutable_input=True,
+    ports=[
+        InputPort(name=DEFAULT_INPUT_NAME, parameter="table", required=True),
+        ConfigPort(name="name", required=True),
+        ConfigPort(name="file_type", required=True),
+    ],
     adapters=[
-        inputs(default_input_argname="table"),
         wrap_verb_result(mode=OutputMode.Table),
     ],
 )
-def snapshot(
-    table: pd.DataFrame, name: str, file_type: FileFormat, **_kwargs: Any
-) -> pd.DataFrame:
+def snapshot(table: pd.DataFrame, name: str, file_type: FileFormat) -> pd.DataFrame:
     """Snapshot verb implementation."""
     file_name = "./" + name + "." + file_type
 
diff --git a/python/datashaper/datashaper/verbs/spread.py b/python/datashaper/datashaper/verbs/spread.py
index 7fefe36b1..34c7a39bd 100644
--- a/python/datashaper/datashaper/verbs/spread.py
+++ b/python/datashaper/datashaper/verbs/spread.py
@@ -7,14 +7,23 @@
 from typing import Any, cast
 
 import pandas as pd
+from reactivedataflow import ConfigPort, InputPort, verb
 
-from .decorators import OutputMode, inputs, verb, wrap_verb_result
+from datashaper import DEFAULT_INPUT_NAME
+
+from .decorators import OutputMode, copy_input_tables, wrap_verb_result
 
 
 @verb(
     name="spread",
+    ports=[
+        InputPort(name=DEFAULT_INPUT_NAME, parameter="table", required=True),
+        ConfigPort(name="column", required=True),
+        ConfigPort(name="to"),
+        ConfigPort(name="preserveSource"),
+    ],
     adapters=[
-        inputs(default_input_argname="table"),
+        copy_input_tables("table"),
         wrap_verb_result(mode=OutputMode.Table),
     ],
 )
@@ -23,7 +32,6 @@ def spread(
     column: str,
     to: str | list[str] | None = None,
     preserveSource: bool = False,  # noqa: N803
-    **_kwargs: Any,
 ) -> pd.DataFrame:
     """Spread verb implementation."""
     output = _spread(table, column, to)
diff --git a/python/datashaper/datashaper/verbs/strings/lower.py b/python/datashaper/datashaper/verbs/strings/lower.py
index 0177b8f13..4a41e82e5 100644
--- a/python/datashaper/datashaper/verbs/strings/lower.py
+++ b/python/datashaper/datashaper/verbs/strings/lower.py
@@ -5,27 +5,29 @@
 """Lower verb implementation."""
 
 import pandas as pd
+from reactivedataflow import ConfigPort, InputPort, verb
 
+from datashaper.constants import DEFAULT_INPUT_NAME
 from datashaper.verbs.decorators import (
     OutputMode,
-    apply_decorators,
-    inputs,
-    verb,
+    copy_input_tables,
     wrap_verb_result,
 )
 
 
-def lower(table: pd.DataFrame, column: str, to: str, **_kwargs: dict) -> pd.DataFrame:
-    """Transform a column by applying a string-lowercase."""
-    table[to] = table[column].str.lower()
-    return table
-
-
-apply_decorators(
-    [
-        verb(name="strings.lower"),
-        inputs(default_input_argname="table"),
+@verb(
+    name="strings.lower",
+    ports=[
+        InputPort(name=DEFAULT_INPUT_NAME, parameter="table", required=True),
+        ConfigPort(name="column", required=True),
+        ConfigPort(name="to", required=True),
+    ],
+    adapters=[
+        copy_input_tables("table"),
         wrap_verb_result(mode=OutputMode.Table),
     ],
-    lower,
 )
+def lower(table: pd.DataFrame, column: str, to: str) -> pd.DataFrame:
+    """Transform a column by applying a string-lowercase."""
+    table[to] = table[column].str.lower()
+    return table
diff --git a/python/datashaper/datashaper/verbs/strings/replace.py b/python/datashaper/datashaper/verbs/strings/replace.py
index 200693eb8..9ceb04608 100644
--- a/python/datashaper/datashaper/verbs/strings/replace.py
+++ b/python/datashaper/datashaper/verbs/strings/replace.py
@@ -7,16 +7,32 @@
 import re
 
 import pandas as pd
+from reactivedataflow import ConfigPort, InputPort, verb
 
+from datashaper.constants import DEFAULT_INPUT_NAME
 from datashaper.verbs.decorators import (
     OutputMode,
-    apply_decorators,
-    inputs,
-    verb,
+    copy_input_tables,
     wrap_verb_result,
 )
 
 
+@verb(
+    name="strings.replace",
+    ports=[
+        InputPort(name=DEFAULT_INPUT_NAME, parameter="table", required=True),
+        ConfigPort(name="column", required=True),
+        ConfigPort(name="to", required=True),
+        ConfigPort(name="pattern", required=True),
+        ConfigPort(name="replacement", required=True),
+        ConfigPort(name="globalMatch"),
+        ConfigPort(name="caseInsensitive"),
+    ],
+    adapters=[
+        copy_input_tables("table"),
+        wrap_verb_result(mode=OutputMode.Table),
+    ],
+)
 def replace(
     table: pd.DataFrame,
     column: str,
@@ -25,20 +41,9 @@ def replace(
     replacement: str,
     globalMatch: bool = False,  # noqa: N803
     caseInsensitive: bool = False,  # noqa: N803
-    **_kwargs: dict,
 ) -> pd.DataFrame:
     """Replace verb implementation."""
     n = 0 if globalMatch else 1
     pat = re.compile(pattern, flags=re.IGNORECASE if caseInsensitive else 0)
     table[to] = table[column].apply(lambda x: pat.sub(replacement, x, count=n))
     return table
-
-
-apply_decorators(
-    [
-        verb(name="strings.replace"),
-        inputs(default_input_argname="table"),
-        wrap_verb_result(mode=OutputMode.Table),
-    ],
-    replace,
-)
diff --git a/python/datashaper/datashaper/verbs/strings/upper.py b/python/datashaper/datashaper/verbs/strings/upper.py
index 4fff10895..af95d4f6e 100644
--- a/python/datashaper/datashaper/verbs/strings/upper.py
+++ b/python/datashaper/datashaper/verbs/strings/upper.py
@@ -5,27 +5,29 @@
 """Upper verb implementation."""
 
 import pandas as pd
+from reactivedataflow import ConfigPort, InputPort, verb
 
+from datashaper.constants import DEFAULT_INPUT_NAME
 from datashaper.verbs.decorators import (
     OutputMode,
-    apply_decorators,
-    inputs,
-    verb,
+    copy_input_tables,
     wrap_verb_result,
 )
 
 
-def upper(table: pd.DataFrame, column: str, to: str, **_kwargs: dict) -> pd.DataFrame:
-    """Upper verb implementation."""
-    table[to] = table[column].str.upper()
-    return table
-
-
-apply_decorators(
-    [
-        verb(name="strings.upper"),
-        inputs(default_input_argname="table"),
+@verb(
+    name="strings.upper",
+    ports=[
+        InputPort(name=DEFAULT_INPUT_NAME, parameter="table", required=True),
+        ConfigPort(name="column", required=True),
+        ConfigPort(name="to", required=True),
+    ],
+    adapters=[
+        copy_input_tables("table"),
         wrap_verb_result(mode=OutputMode.Table),
     ],
-    upper,
 )
+def upper(table: pd.DataFrame, column: str, to: str) -> pd.DataFrame:
+    """Upper verb implementation."""
+    table[to] = table[column].str.upper()
+    return table
diff --git a/python/datashaper/datashaper/verbs/unfold.py b/python/datashaper/datashaper/verbs/unfold.py
index c920355db..995d96a22 100644
--- a/python/datashaper/datashaper/verbs/unfold.py
+++ b/python/datashaper/datashaper/verbs/unfold.py
@@ -4,22 +4,30 @@
 #
 """Unfold verb implementation."""
 
-from typing import Any, cast
+from typing import cast
 
 import numpy as np
 import pandas as pd
+from reactivedataflow import ConfigPort, InputPort, verb
 
-from .decorators import OutputMode, inputs, verb, wrap_verb_result
+from datashaper import DEFAULT_INPUT_NAME
+
+from .decorators import OutputMode, copy_input_tables, wrap_verb_result
 
 
 @verb(
     name="unfold",
+    ports=[
+        InputPort(name=DEFAULT_INPUT_NAME, parameter="table", required=True),
+        ConfigPort(name="key", required=True),
+        ConfigPort(name="value", required=True),
+    ],
     adapters=[
-        inputs(default_input_argname="table"),
+        copy_input_tables("table"),
         wrap_verb_result(mode=OutputMode.Table),
     ],
 )
-def unfold(table: pd.DataFrame, key: str, value: str, **_kwargs: Any) -> pd.DataFrame:
+def unfold(table: pd.DataFrame, key: str, value: str) -> pd.DataFrame:
     """Unfold verb implementation."""
     columns = len(table[key].unique())
 
diff --git a/python/datashaper/datashaper/verbs/ungroup.py b/python/datashaper/datashaper/verbs/ungroup.py
index eafa750d7..82d80ff89 100644
--- a/python/datashaper/datashaper/verbs/ungroup.py
+++ b/python/datashaper/datashaper/verbs/ungroup.py
@@ -4,21 +4,23 @@
 #
 """Ungroup verb implementation."""
 
-from typing import Any
-
 import pandas as pd
+from reactivedataflow import InputPort, verb
+
+from datashaper import DEFAULT_INPUT_NAME
 
-from .decorators import OutputMode, inputs, verb, wrap_verb_result
+from .decorators import OutputMode, wrap_verb_result
 
 
 @verb(
     name="ungroup",
-    immutable_input=True,
+    ports=[
+        InputPort(name=DEFAULT_INPUT_NAME, parameter="table", required=True),
+    ],
     adapters=[
-        inputs(default_input_argname="table"),
         wrap_verb_result(mode=OutputMode.Table),
     ],
 )
-def ungroup(table: pd.DataFrame, **_kwargs: Any) -> pd.DataFrame:
+def ungroup(table: pd.DataFrame) -> pd.DataFrame:
     """Ungroup verb implementation."""
     return table.obj
diff --git a/python/datashaper/datashaper/verbs/unhot.py b/python/datashaper/datashaper/verbs/unhot.py
index 391813b24..b4b7aea50 100644
--- a/python/datashaper/datashaper/verbs/unhot.py
+++ b/python/datashaper/datashaper/verbs/unhot.py
@@ -3,14 +3,24 @@
 from typing import Any, cast
 
 import pandas as pd
+from reactivedataflow import ConfigPort, InputPort, verb
 
-from .decorators import OutputMode, inputs, verb, wrap_verb_result
+from datashaper import DEFAULT_INPUT_NAME
+
+from .decorators import OutputMode, copy_input_tables, wrap_verb_result
 
 
 @verb(
     name="unhot",
+    ports=[
+        InputPort(name=DEFAULT_INPUT_NAME, parameter="table", required=True),
+        ConfigPort(name="to", required=True),
+        ConfigPort(name="columns", required=True),
+        ConfigPort(name="preserveSource"),
+        ConfigPort(name="prefix"),
+    ],
     adapters=[
-        inputs(default_input_argname="table"),
+        copy_input_tables("table"),
         wrap_verb_result(mode=OutputMode.Table),
     ],
 )
diff --git a/python/datashaper/datashaper/verbs/union.py b/python/datashaper/datashaper/verbs/union.py
index d28158747..3c9df2ae5 100644
--- a/python/datashaper/datashaper/verbs/union.py
+++ b/python/datashaper/datashaper/verbs/union.py
@@ -3,24 +3,24 @@
 # Licensed under the MIT license. See LICENSE file in the project.
 #
 """Union verb implementation."""
-
-from typing import Any
-
 import pandas as pd
+from reactivedataflow import ArrayInputPort, InputPort, verb
 
-from .decorators import OutputMode, inputs, verb, wrap_verb_result
+from datashaper import DEFAULT_INPUT_NAME
+
+from .decorators import OutputMode, wrap_verb_result
 
 
 @verb(
     name="union",
-    immutable_input=True,
+    ports=[
+        InputPort(name=DEFAULT_INPUT_NAME, parameter="table", required=True),
+        ArrayInputPort(name="others", parameter="others", required=True),
+    ],
     adapters=[
-        inputs(default_input_argname="table", variadic_input_argname="others"),
         wrap_verb_result(mode=OutputMode.Table),
     ],
 )
-def union(
-    table: pd.DataFrame, others: list[pd.DataFrame], **_kwargs: Any
-) -> pd.DataFrame:
+def union(table: pd.DataFrame, others: list[pd.DataFrame]) -> pd.DataFrame:
     """Union verb implementation."""
     return pd.concat([table, *others], ignore_index=True).drop_duplicates()
diff --git a/python/datashaper/datashaper/verbs/unorder.py b/python/datashaper/datashaper/verbs/unorder.py
index 437ad12cd..e288ea68e 100644
--- a/python/datashaper/datashaper/verbs/unorder.py
+++ b/python/datashaper/datashaper/verbs/unorder.py
@@ -4,21 +4,23 @@
 #
 """Unorder verb implementation."""
 
-from typing import Any
-
 import pandas as pd
+from reactivedataflow import InputPort, verb
+
+from datashaper import DEFAULT_INPUT_NAME
 
-from .decorators import OutputMode, inputs, verb, wrap_verb_result
+from .decorators import OutputMode, wrap_verb_result
 
 
 @verb(
     name="unorder",
-    immutable_input=True,
+    ports=[
+        InputPort(name=DEFAULT_INPUT_NAME, parameter="table", required=True),
+    ],
     adapters=[
-        inputs(default_input_argname="table"),
         wrap_verb_result(mode=OutputMode.Table),
     ],
 )
-def unorder(table: pd.DataFrame, **_kwargs: Any) -> pd.DataFrame:
+def unorder(table: pd.DataFrame) -> pd.DataFrame:
     """Unorder verb implementation."""
     return table.sort_index()
diff --git a/python/datashaper/datashaper/verbs/unroll.py b/python/datashaper/datashaper/verbs/unroll.py
index 388af7575..df2d3b28a 100644
--- a/python/datashaper/datashaper/verbs/unroll.py
+++ b/python/datashaper/datashaper/verbs/unroll.py
@@ -4,21 +4,24 @@
 #
 """Unroll verb implementation."""
 
-from typing import Any
-
 import pandas as pd
+from reactivedataflow import ConfigPort, InputPort, verb
+
+from datashaper import DEFAULT_INPUT_NAME
 
-from .decorators import OutputMode, inputs, verb, wrap_verb_result
+from .decorators import OutputMode, wrap_verb_result
 
 
 @verb(
     name="unroll",
-    immutable_input=True,
+    ports=[
+        InputPort(name=DEFAULT_INPUT_NAME, parameter="table", required=True),
+        ConfigPort(name="column", required=True),
+    ],
     adapters=[
-        inputs(default_input_argname="table"),
         wrap_verb_result(mode=OutputMode.Table),
     ],
 )
-def unroll(table: pd.DataFrame, column: str, **_kwargs: Any) -> pd.DataFrame:
+def unroll(table: pd.DataFrame, column: str) -> pd.DataFrame:
     """Unroll a column."""
     return table.explode(column).reset_index(drop=True)
diff --git a/python/datashaper/datashaper/verbs/window.py b/python/datashaper/datashaper/verbs/window.py
index 5dab18593..e046dc1a4 100644
--- a/python/datashaper/datashaper/verbs/window.py
+++ b/python/datashaper/datashaper/verbs/window.py
@@ -4,14 +4,16 @@
 #
 """Window verb implementation."""
 
-from typing import Any
 from uuid import uuid4
 
 import numpy as np
 import pandas as pd
 from pandas.core.groupby import DataFrameGroupBy
+from reactivedataflow import ConfigPort, InputPort, verb
 
-from .decorators import OutputMode, inputs, verb, wrap_verb_result
+from datashaper import DEFAULT_INPUT_NAME
+
+from .decorators import OutputMode, copy_input_tables, wrap_verb_result
 from .types import WindowFunction
 
 
@@ -59,8 +61,14 @@ def _get_window_indexer(
 
 @verb(
     name="window",
+    ports=[
+        InputPort(name=DEFAULT_INPUT_NAME, parameter="table", required=True),
+        ConfigPort(name="column", required=True),
+        ConfigPort(name="to", required=True),
+        ConfigPort(name="operation", required=True),
+    ],
     adapters=[
-        inputs(default_input_argname="table"),
+        copy_input_tables("table"),
         wrap_verb_result(mode=OutputMode.Table),
     ],
 )
@@ -69,7 +77,6 @@ def window(
     column: str,
     to: str,
     operation: str,
-    **_kwargs: Any,
 ) -> pd.DataFrame | DataFrameGroupBy:
     """Apply a window function to a column in a table."""
     window_operation = WindowFunction(operation)
diff --git a/python/datashaper/datashaper/verbs/workflow.py b/python/datashaper/datashaper/verbs/workflow.py
index 71efa4558..bd50964a2 100644
--- a/python/datashaper/datashaper/verbs/workflow.py
+++ b/python/datashaper/datashaper/verbs/workflow.py
@@ -3,17 +3,22 @@
 from typing import Any
 
 import pandas as pd
+from reactivedataflow import ConfigPort, InputPort, verb
 
 from datashaper.constants import DEFAULT_INPUT_NAME
 
-from .decorators import OutputMode, inputs, verb, wrap_verb_result
+from .decorators import OutputMode, wrap_verb_result
 from .types import Table
 
 
 @verb(
     name="workflow",
+    ports=[
+        ConfigPort(name="workflow", required=True),
+        ConfigPort(name="workflow_instance", required=True),
+    ],
+    # TODO: add support for multiple input tables
     adapters=[
-        inputs(default_input_argname="table", input_dict_argname="tables"),
         wrap_verb_result(mode=OutputMode.Table),
     ],
 )
diff --git a/python/datashaper/datashaper/workflow/workflow.py b/python/datashaper/datashaper/workflow/workflow.py
index 53821665f..602c4776e 100644
--- a/python/datashaper/datashaper/workflow/workflow.py
+++ b/python/datashaper/datashaper/workflow/workflow.py
@@ -29,7 +29,7 @@
 )
 from datashaper.tables import InMemoryTableStore, TableStore
 from datashaper.utils.progress import Progress
-from datashaper.verbs import Table, VerbDetails, VerbInput, VerbManager, VerbResult
+from datashaper.verbs import Table, VerbDetails, VerbInput, VerbResult
 from datashaper.verbs.types import TableContainer
 
 from .callbacks import (
diff --git a/python/datashaper/pyproject.toml b/python/datashaper/pyproject.toml
index 72ef96b8f..e555f81fe 100644
--- a/python/datashaper/pyproject.toml
+++ b/python/datashaper/pyproject.toml
@@ -16,8 +16,8 @@ jsonschema = "^4.21.1"
 pyarrow = "^15.0.0"
 diskcache = "^5.6.3"
 dacite = "^1.8.1"
-
 reactivedataflow = "^0.1.0"
+
 [tool.poetry.group.dev.dependencies]
 codespell = "^2.2.6"
 poethepoet = "^0.24.4"