Files
stack/tests/aco/test_runner.py
kert 7dffb76801 add Pydantic field metadata, pipeline schema validation, col: tag namespace, and stack.toml config
- SQLTable: add column_meta(), field_descriptions(), to_ddl(), _resolve_type()
- generate_models.py: read DuckDB column comments, emit Field(description=...)
- run_pipeline: validate output columns against SQLTable contract (SchemaError)
- Pipeline.run: pass output class through to runner
- Tag.col(): new namespace for column-level descriptions
- bib/meta: collect_column_comments() and apply_column_comments() for Zotero→DuckDB flow
- stack.toml + src/conf/: centralised config loader with attribute access and path()
- 100% test coverage on all changed files
2026-03-12 13:03:44 -04:00

860 lines
29 KiB
Python

"""Tests for aco.pipe.runner._param_to_table, run_pipeline, Expr, and Pipeline."""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock
import narwhals as nw
import polars as pl
import pytest
from aco.express.base import Expr
from aco.pipe.base import Pipeline
from aco.pipe.runner import SchemaError, _param_to_table, run_pipeline
from aco.table.base import SQLTable
from bib.tag import Tag
# ── _param_to_table ────────────────────────────────────────────────────────────
class TestParamToTable:
"""Unit tests for the parameter-name → table-reference converter."""
# ── double-underscore (schema.table) ─────────────────────────────────────
def test_double_underscore_basic(self) -> None:
assert _param_to_table("core__encounter") == "core.encounter"
def test_double_underscore_schema_and_table(self) -> None:
assert (
_param_to_table("input_layer__medical_claim") == "input_layer.medical_claim"
)
def test_double_underscore_claims_preprocessing(self) -> None:
assert _param_to_table("claims_preprocessing__member_months") == (
"claims_preprocessing.member_months"
)
def test_double_underscore_readmissions(self) -> None:
assert _param_to_table("readmissions__encounter") == "readmissions.encounter"
def test_double_underscore_splits_on_first_only(self) -> None:
# schema__table__extra — split only on the FIRST __
result = _param_to_table("input_layer__input_layer__appointment")
assert result == "input_layer.input_layer__appointment"
def test_double_underscore_pfs_rvu(self) -> None:
assert _param_to_table("pfs__rvu") == "pfs.rvu"
def test_double_underscore_hcc_suspecting(self) -> None:
assert _param_to_table("hcc_suspecting__int_patient") == (
"hcc_suspecting.int_patient"
)
# ── triple-underscore (schema._private) ──────────────────────────────────
def test_triple_underscore_basic(self) -> None:
assert _param_to_table("readmissions___int_encounter") == (
"readmissions._int_encounter"
)
def test_triple_underscore_core(self) -> None:
assert _param_to_table("core___stg_clinical_encounter") == (
"core._stg_clinical_encounter"
)
def test_triple_underscore_claims_preprocessing(self) -> None:
assert _param_to_table("claims_preprocessing___int_medical_claim") == (
"claims_preprocessing._int_medical_claim"
)
def test_triple_underscore_hcc_suspecting_private(self) -> None:
assert _param_to_table("hcc_suspecting___int_patient") == (
"hcc_suspecting._int_patient"
)
# ── bare parameter (no conversion) ───────────────────────────────────────
def test_bare_single_word(self) -> None:
assert _param_to_table("df") == "df"
def test_bare_rvu(self) -> None:
assert _param_to_table("rvu") == "rvu"
def test_bare_gpci(self) -> None:
assert _param_to_table("gpci") == "gpci"
def test_bare_claims(self) -> None:
assert _param_to_table("claims") == "claims"
def test_bare_no_underscores(self) -> None:
assert _param_to_table("labor") == "labor"
# ── edge cases ────────────────────────────────────────────────────────────
def test_empty_string(self) -> None:
# Should not raise — return as-is or empty
result = _param_to_table("")
assert isinstance(result, str)
def test_single_underscore_unchanged(self) -> None:
# A single underscore in a name should NOT trigger conversion
result = _param_to_table("some_param")
assert result == "some_param"
def test_output_contains_dot_for_double_underscore(self) -> None:
result = _param_to_table("schema__table")
assert "." in result
def test_output_contains_dot_for_triple_underscore(self) -> None:
result = _param_to_table("schema___private")
assert "." in result
def test_triple_takes_priority_over_double(self) -> None:
"""___ must be detected before __ — both are present in readmissions___int."""
result = _param_to_table("readmissions___int_encounter")
# Must be readmissions._int_encounter, NOT readmissions._int_encounter split wrong
assert result == "readmissions._int_encounter"
assert result.startswith("readmissions.")
assert "_int_encounter" in result
def test_roundtrip_symmetry(self) -> None:
"""Different param encodings must resolve to different table refs."""
double = _param_to_table("core__encounter")
triple = _param_to_table("core___encounter")
assert double != triple
assert double == "core.encounter"
assert triple == "core._encounter"
# ── run_pipeline ───────────────────────────────────────────────────────────────
class TestRunPipeline:
"""Unit tests for run_pipeline — the sequential expression executor."""
# ── simple cases ──────────────────────────────────────────────────────────
def test_single_expression_loaded_from_load(self) -> None:
"""A single expression whose input comes from load()."""
source_df = pl.DataFrame({"x": [1, 2, 3]})
@nw.narwhalify
def identity(df):
return df
load = MagicMock(return_value=source_df)
cache = run_pipeline([("output.table", identity)], load)
assert "output.table" in cache
assert isinstance(cache["output.table"], pl.DataFrame)
def test_pipeline_calls_load_for_unknown_inputs(self) -> None:
"""Inputs not in cache must be fetched via load()."""
input_df = pl.DataFrame({"a": [10]})
load = MagicMock(return_value=input_df)
@nw.narwhalify
def passthrough(df):
return df
run_pipeline([("out.table", passthrough)], load)
load.assert_called_once()
def test_pipeline_uses_cache_before_load(self) -> None:
"""Outputs from earlier steps must be passed to later steps via cache."""
step1_df = pl.DataFrame({"val": [42]})
step2_called_with: list[Any] = []
@nw.narwhalify
def step1(df):
return df
@nw.narwhalify
def step2(step1__result):
step2_called_with.append(step1__result)
return step1__result
load = MagicMock(return_value=step1_df)
run_pipeline(
[("step1.result", step1), ("step2.output", step2)],
load,
)
# step2 received step1's output from cache, not from load
assert len(step2_called_with) == 1
def test_chained_pipeline_produces_both_outputs(self) -> None:
"""Both step outputs must appear in the returned cache."""
base = pl.DataFrame({"n": [1, 2]})
@nw.narwhalify
def double(df):
return df.with_columns(nw.col("n") * 2)
@nw.narwhalify
def triple(step1__doubled):
return step1__doubled.with_columns(nw.col("n") * 3)
load = MagicMock(return_value=base)
cache = run_pipeline(
[("step1.doubled", double), ("step2.tripled", triple)],
load,
)
assert "step1.doubled" in cache
assert "step2.tripled" in cache
def test_empty_pipeline_returns_empty_cache(self) -> None:
load = MagicMock()
cache = run_pipeline([], load)
assert cache == {}
load.assert_not_called()
def test_pipeline_returns_dict(self) -> None:
df = pl.DataFrame({"x": [1]})
load = MagicMock(return_value=df)
@nw.narwhalify
def fn(df):
return df
result = run_pipeline([("out.table", fn)], load)
assert isinstance(result, dict)
def test_pipeline_output_name_is_exact(self) -> None:
"""Cache key must be exactly the output_name string supplied."""
df = pl.DataFrame({"x": [1]})
load = MagicMock(return_value=df)
@nw.narwhalify
def fn(df):
return df
cache = run_pipeline([("my_schema.my_table", fn)], load)
assert "my_schema.my_table" in cache
def test_multiple_inputs_resolved(self) -> None:
"""A function with two inputs should receive both from load/cache."""
rvu = pl.DataFrame(
{"hcpcs": ["99213"], "work_rvu": [0.97], "locality": ["0201"]}
)
gpci = pl.DataFrame({"locality": ["0201"], "work_gpci": [1.0]})
calls: dict[str, Any] = {}
@nw.narwhalify
def join_fn(rvu, gpci):
calls["rvu"] = rvu
calls["gpci"] = gpci
return rvu
def load(ref: str):
return rvu if ref == "rvu" else gpci
run_pipeline([("out.joined", join_fn)], load)
assert "rvu" in calls
assert "gpci" in calls
def test_load_not_called_for_cached_input(self) -> None:
"""After step1 produces output, step2 should NOT call load for that input."""
source = pl.DataFrame({"x": [1]})
load = MagicMock(return_value=source)
@nw.narwhalify
def step1(df):
return df
@nw.narwhalify
def step2(step1__out):
return step1__out
run_pipeline(
[("step1.out", step1), ("step2.out", step2)],
load,
)
# load should only be called once (for step1's `df` input)
load.assert_called_once()
def test_pipeline_preserves_execution_order(self) -> None:
"""Steps must execute in the order given, not dependency-sorted."""
order: list[str] = []
@nw.narwhalify
def first(df):
order.append("first")
return df
@nw.narwhalify
def second(df):
order.append("second")
return df
load = MagicMock(return_value=pl.DataFrame({"x": [1]}))
run_pipeline([("a.first", first), ("b.second", second)], load)
assert order == ["first", "second"]
# ── make_databricks_loader ────────────────────────────────────────────────────
class TestMakeDatabricksLoader:
def test_returns_callable(self) -> None:
from aco.pipe.runner import make_databricks_loader
spark = MagicMock()
load = make_databricks_loader(spark, "my_catalog")
assert callable(load)
def test_calls_spark_table_with_catalog_prefix(self) -> None:
from aco.pipe.runner import make_databricks_loader
spark = MagicMock()
spark.table.return_value = "fake_df"
load = make_databricks_loader(spark, "cat")
result = load("schema.table")
spark.table.assert_called_once_with("cat.schema.table")
assert result == "fake_df"
# ── Schema validation ─────────────────────────────────────────────────────────
class TestSchemaValidation:
"""Tests for output contract validation in run_pipeline."""
def test_matching_output_passes(self) -> None:
class Out(SQLTable):
__schema__ = "t"
__tablename__ = "out"
a: str | None = None
b: int | None = None
@nw.narwhalify
def fn(t__src):
return t__src.select("a", "b")
src = pl.DataFrame({"a": ["x"], "b": [1], "c": [2]})
cache = run_pipeline([("t.out", fn, Out)], lambda _: src)
assert "t.out" in cache
def test_missing_column_raises_schema_error(self) -> None:
class Out(SQLTable):
__schema__ = "t"
__tablename__ = "out"
a: str | None = None
b: int | None = None
@nw.narwhalify
def fn(t__src):
return t__src.select("a") # missing b
src = pl.DataFrame({"a": ["x"], "b": [1]})
with pytest.raises(SchemaError, match="missing columns"):
run_pipeline([("t.out", fn, Out)], lambda _: src)
def test_extra_column_raises_schema_error(self) -> None:
class Out(SQLTable):
__schema__ = "t"
__tablename__ = "out"
a: str | None = None
@nw.narwhalify
def fn(t__src):
return t__src # has extra column b
src = pl.DataFrame({"a": ["x"], "b": [1]})
with pytest.raises(SchemaError, match="extra columns"):
run_pipeline([("t.out", fn, Out)], lambda _: src)
def test_none_output_skips_validation(self) -> None:
@nw.narwhalify
def fn(t__src):
return t__src
src = pl.DataFrame({"any": [1]})
cache = run_pipeline([("t.out", fn, None)], lambda _: src)
assert "t.out" in cache
def test_two_tuple_skips_validation(self) -> None:
@nw.narwhalify
def fn(t__src):
return t__src
src = pl.DataFrame({"any": [1]})
cache = run_pipeline([("t.out", fn)], lambda _: src)
assert "t.out" in cache
def test_pipeline_run_validates_output(self) -> None:
"""Pipeline.run passes output classes to runner for validation."""
class Out(SQLTable):
__schema__ = "t"
__tablename__ = "out"
a: str | None = None
@nw.narwhalify
def fn(t__src):
return t__src # has extra column
src = pl.DataFrame({"a": ["x"], "b": [1]})
expr = Expr(name="t.out", fn=fn, output=Out)
p = Pipeline(exprs=[expr])
with pytest.raises(SchemaError):
p.run(lambda _: src)
# ── Expr ───────────────────────────────────────────────────────────────────────
class TestExprConstruction:
"""Expr must be constructable with valid arguments and reject invalid ones."""
@pytest.fixture
def simple_table(self):
class MyTable(SQLTable):
__schema__ = "core"
__tablename__ = "encounter"
return MyTable
@pytest.fixture
def simple_fn(self):
@nw.narwhalify
def my_fn(core__encounter):
"""Return the encounter table."""
return core__encounter
return my_fn
# ── valid construction ────────────────────────────────────────────────────
def test_minimal_construction(self, simple_fn) -> None:
expr = Expr(name="core.encounter", fn=simple_fn)
assert expr.name == "core.encounter"
assert expr.fn is simple_fn
def test_full_construction(self, simple_fn, simple_table) -> None:
expr = Expr(
name="core.encounter",
fn=simple_fn,
output=simple_table,
after=["input_layer.encounter"],
refs=[Tag.module("aco")],
description="Test expression.",
)
assert expr.output is simple_table
assert expr.after == ["input_layer.encounter"]
assert len(expr.refs) == 1
assert expr.description == "Test expression."
def test_after_defaults_to_empty_list(self, simple_fn) -> None:
expr = Expr(name="core.encounter", fn=simple_fn)
assert expr.after == []
def test_refs_defaults_to_empty_list(self, simple_fn) -> None:
expr = Expr(name="core.encounter", fn=simple_fn)
assert expr.refs == []
def test_output_defaults_to_none(self, simple_fn) -> None:
expr = Expr(name="core.encounter", fn=simple_fn)
assert expr.output is None
def test_description_defaults_to_empty_string(self, simple_fn) -> None:
expr = Expr(name="core.encounter", fn=simple_fn)
assert expr.description == ""
# ── name validation ───────────────────────────────────────────────────────
def test_name_must_be_qualified(self, simple_fn) -> None:
"""Unqualified names (no dot) must be rejected."""
with pytest.raises(Exception): # ValidationError
Expr(name="encounter", fn=simple_fn)
def test_name_with_dot_is_valid(self, simple_fn) -> None:
expr = Expr(name="readmissions.encounter", fn=simple_fn)
assert "." in expr.name
def test_name_with_private_table(self, simple_fn) -> None:
expr = Expr(name="core._stg_encounter", fn=simple_fn)
assert expr.name == "core._stg_encounter"
def test_name_with_multi_part_schema(self, simple_fn) -> None:
# As long as there's at least one dot, it's valid
expr = Expr(name="input_layer.medical_claim", fn=simple_fn)
assert expr.name == "input_layer.medical_claim"
def test_name_empty_string_rejected(self, simple_fn) -> None:
with pytest.raises(Exception):
Expr(name="", fn=simple_fn)
def test_name_no_dot_rejected(self, simple_fn) -> None:
with pytest.raises(Exception):
Expr(name="just_a_name", fn=simple_fn)
class TestExprInputsProperty:
"""Expr.inputs derives table refs from the fn's parameter signature."""
def test_single_double_underscore_param(self) -> None:
@nw.narwhalify
def fn(core__encounter):
"""fn."""
return core__encounter
expr = Expr(name="out.table", fn=fn)
assert expr.inputs == ["core.encounter"]
def test_multiple_params(self) -> None:
@nw.narwhalify
def fn(core__encounter, input_layer__eligibility):
"""fn."""
return core__encounter
expr = Expr(name="out.table", fn=fn)
assert "core.encounter" in expr.inputs
assert "input_layer.eligibility" in expr.inputs
def test_bare_param(self) -> None:
@nw.narwhalify
def fn(df):
"""fn."""
return df
expr = Expr(name="out.table", fn=fn)
assert expr.inputs == ["df"]
def test_triple_underscore_param(self) -> None:
@nw.narwhalify
def fn(readmissions___int_encounter):
"""fn."""
return readmissions___int_encounter
expr = Expr(name="out.table", fn=fn)
assert expr.inputs == ["readmissions._int_encounter"]
def test_inputs_preserves_order(self) -> None:
@nw.narwhalify
def fn(rvu, gpci, labor):
"""fn."""
return rvu
expr = Expr(name="out.table", fn=fn)
assert expr.inputs == ["rvu", "gpci", "labor"]
def test_empty_params_yields_empty_inputs(self) -> None:
@nw.narwhalify
def fn():
"""fn."""
import polars as pl
return pl.DataFrame()
expr = Expr(name="out.table", fn=fn)
assert expr.inputs == []
class TestExprDocProperty:
"""Expr.doc returns description when set, else fn.__doc__."""
def test_doc_returns_description_when_set(self) -> None:
@nw.narwhalify
def fn(df):
"""This is the function docstring."""
return df
expr = Expr(name="out.table", fn=fn, description="Custom description.")
assert expr.doc == "Custom description."
def test_doc_falls_back_to_fn_docstring(self) -> None:
@nw.narwhalify
def fn(df):
"""This is the function docstring."""
return df
expr = Expr(name="out.table", fn=fn)
assert "function docstring" in expr.doc
def test_doc_returns_empty_when_no_description_and_no_docstring(self) -> None:
@nw.narwhalify
def fn(df):
return df
expr = Expr(name="out.table", fn=fn)
assert isinstance(expr.doc, str)
def test_description_takes_priority_over_docstring(self) -> None:
@nw.narwhalify
def fn(df):
"""Function docstring — should NOT appear."""
return df
expr = Expr(name="out.table", fn=fn, description="Override description.")
assert expr.doc == "Override description."
assert "should NOT appear" not in expr.doc
class TestExprQualifiedRefs:
"""Expr.qualified_refs always includes a table tag auto-generated from name."""
def test_qualified_refs_includes_table_tag(self) -> None:
@nw.narwhalify
def fn(df):
"""fn."""
return df
expr = Expr(name="core.encounter", fn=fn)
labels = [t.label for t in expr.qualified_refs]
assert "table:core.encounter" in labels
def test_qualified_refs_includes_user_refs(self) -> None:
@nw.narwhalify
def fn(df):
"""fn."""
return df
user_tag = Tag.module("aco")
expr = Expr(name="core.encounter", fn=fn, refs=[user_tag])
labels = [t.label for t in expr.qualified_refs]
assert "module:aco" in labels
def test_qualified_refs_auto_tag_first(self) -> None:
@nw.narwhalify
def fn(df):
"""fn."""
return df
expr = Expr(name="pfs.rvu", fn=fn)
# Auto-generated table tag should be first
assert expr.qualified_refs[0].namespace == "table"
assert expr.qualified_refs[0].value == "pfs.rvu"
def test_qualified_refs_empty_refs_still_has_auto_tag(self) -> None:
@nw.narwhalify
def fn(df):
"""fn."""
return df
expr = Expr(name="aco.eligibility", fn=fn)
assert len(expr.qualified_refs) == 1
assert expr.qualified_refs[0].label == "table:aco.eligibility"
def test_qualified_refs_multiple_user_tags(self) -> None:
@nw.narwhalify
def fn(df):
"""fn."""
return df
tags = [Tag.module("pfs"), Tag.year(2026), Tag.file("rvu")]
expr = Expr(name="pfs.rvu", fn=fn, refs=tags)
labels = [t.label for t in expr.qualified_refs]
assert "table:pfs.rvu" in labels
assert "module:pfs" in labels
assert "year:2026" in labels
assert "file:rvu" in labels
class TestExprRepr:
"""Expr.__repr__ must include key fields."""
def test_repr_contains_name(self) -> None:
@nw.narwhalify
def fn(df):
"""fn."""
return df
expr = Expr(name="core.encounter", fn=fn)
assert "core.encounter" in repr(expr)
def test_repr_contains_fn_name(self) -> None:
@nw.narwhalify
def my_transform(df):
"""fn."""
return df
expr = Expr(name="core.encounter", fn=my_transform)
assert "my_transform" in repr(expr)
def test_repr_is_string(self) -> None:
@nw.narwhalify
def fn(df):
"""fn."""
return df
expr = Expr(name="core.encounter", fn=fn)
assert isinstance(repr(expr), str)
def test_repr_contains_inputs(self) -> None:
@nw.narwhalify
def fn(core__encounter):
"""fn."""
return core__encounter
expr = Expr(name="out.table", fn=fn)
r = repr(expr)
assert "core.encounter" in r
def test_repr_contains_output_name_none_when_not_set(self) -> None:
@nw.narwhalify
def fn(df):
"""fn."""
return df
expr = Expr(name="out.table", fn=fn)
assert "None" in repr(expr)
def test_repr_contains_output_class_name_when_set(self) -> None:
class MyTable(SQLTable):
__schema__ = "core"
__tablename__ = "encounter"
@nw.narwhalify
def fn(df):
"""fn."""
return df
expr = Expr(name="core.encounter", fn=fn, output=MyTable)
assert "MyTable" in repr(expr)
# ── Pipeline ───────────────────────────────────────────────────────────────────
class TestPipeline:
"""Tests for Pipeline — ordered sequence of Expr objects."""
@pytest.fixture
def simple_expr(self):
@nw.narwhalify
def fn(df):
"""Simple passthrough."""
return df
return Expr(name="out.table", fn=fn)
@pytest.fixture
def two_exprs(self):
@nw.narwhalify
def fn_a(df):
"""Step A."""
return df
@nw.narwhalify
def fn_b(df):
"""Step B."""
return df
return [
Expr(name="a.table", fn=fn_a),
Expr(name="b.table", fn=fn_b),
]
# ── construction ──────────────────────────────────────────────────────────
def test_pipeline_with_single_expr(self, simple_expr) -> None:
p = Pipeline(exprs=[simple_expr])
assert len(p) == 1
def test_pipeline_with_multiple_exprs(self, two_exprs) -> None:
p = Pipeline(exprs=two_exprs)
assert len(p) == 2
def test_empty_pipeline(self) -> None:
p = Pipeline(exprs=[])
assert len(p) == 0
# ── names() ───────────────────────────────────────────────────────────────
def test_names_returns_list(self, simple_expr) -> None:
p = Pipeline(exprs=[simple_expr])
assert isinstance(p.names(), list)
def test_names_single_expr(self, simple_expr) -> None:
p = Pipeline(exprs=[simple_expr])
assert p.names() == ["out.table"]
def test_names_multiple_exprs(self, two_exprs) -> None:
p = Pipeline(exprs=two_exprs)
assert p.names() == ["a.table", "b.table"]
def test_names_empty_pipeline(self) -> None:
p = Pipeline(exprs=[])
assert p.names() == []
def test_names_preserves_order(self, two_exprs) -> None:
p = Pipeline(exprs=two_exprs)
names = p.names()
assert names[0] == "a.table"
assert names[1] == "b.table"
# ── __len__() ─────────────────────────────────────────────────────────────
def test_len_zero(self) -> None:
assert len(Pipeline(exprs=[])) == 0
def test_len_one(self, simple_expr) -> None:
assert len(Pipeline(exprs=[simple_expr])) == 1
def test_len_two(self, two_exprs) -> None:
assert len(Pipeline(exprs=two_exprs)) == 2
# ── run() ─────────────────────────────────────────────────────────────────
def test_run_returns_dict(self, simple_expr) -> None:
df = pl.DataFrame({"x": [1]})
load = MagicMock(return_value=df)
p = Pipeline(exprs=[simple_expr])
result = p.run(load)
assert isinstance(result, dict)
def test_run_output_in_cache(self, simple_expr) -> None:
df = pl.DataFrame({"x": [1]})
load = MagicMock(return_value=df)
p = Pipeline(exprs=[simple_expr])
result = p.run(load)
assert "out.table" in result
def test_run_empty_pipeline_returns_empty_dict(self) -> None:
load = MagicMock()
p = Pipeline(exprs=[])
result = p.run(load)
assert result == {}
def test_run_chained_pipeline(self) -> None:
"""Step 2 should receive step 1's output from cache."""
source = pl.DataFrame({"n": [1, 2, 3]})
received: list[Any] = []
@nw.narwhalify
def step1(df):
"""Step 1."""
return df.with_columns(nw.col("n") + 10)
@nw.narwhalify
def step2(step1__out):
"""Step 2 consumes step1's output."""
received.append(step1__out)
return step1__out
exprs = [
Expr(name="step1.out", fn=step1, after=[]),
Expr(name="step2.final", fn=step2, after=["step1.out"]),
]
p = Pipeline(exprs=exprs)
load = MagicMock(return_value=source)
result = p.run(load)
assert "step1.out" in result
assert "step2.final" in result
# step2 received step1's modified data (n + 10), not the source
assert len(received) == 1
def test_run_all_names_present_in_cache(self, two_exprs) -> None:
df = pl.DataFrame({"x": [1]})
load = MagicMock(return_value=df)
p = Pipeline(exprs=two_exprs)
result = p.run(load)
for name in p.names():
assert name in result