Files
AI/참고/neo4j-graphrag-python-main/tests/unit/experimental/pipeline/test_pipeline.py

608 lines
21 KiB
Python
Raw Normal View History

2026-05-12 19:40:31 +09:00
# Copyright (c) "Neo4j"
# Neo4j Sweden AB [https://neo4j.com]
# #
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# #
# https://www.apache.org/licenses/LICENSE-2.0
# #
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import asyncio
import datetime
import tempfile
from typing import Sized
from unittest import mock
from unittest.mock import AsyncMock, call, patch
import pytest
from neo4j_graphrag.experimental.pipeline import Component, Pipeline
from neo4j_graphrag.experimental.pipeline.exceptions import PipelineDefinitionError
from neo4j_graphrag.experimental.pipeline.notification import (
EventCallbackProtocol,
EventType,
PipelineEvent,
TaskEvent,
Event,
)
from neo4j_graphrag.experimental.pipeline.types.orchestration import RunResult
from .components import (
ComponentAdd,
ComponentMultiply,
ComponentNoParam,
ComponentPassThrough,
StringResultModel,
SlowComponentMultiply,
)
@pytest.mark.asyncio
async def test_simple_pipeline_two_components() -> None:
pipe = Pipeline()
component_a = ComponentNoParam()
component_b = ComponentNoParam()
pipe.add_component(
component_a,
"a",
)
pipe.add_component(
component_b,
"b",
)
pipe.connect("a", "b", {})
with mock.patch(
"tests.unit.experimental.pipeline.test_pipeline.ComponentNoParam.run"
) as mock_run:
mock_run.side_effect = [
StringResultModel(result="1"),
StringResultModel(result="2"),
]
res = await pipe.run({})
mock_run.assert_awaited_with(**{})
mock_run.assert_awaited_with(**{})
assert "b" in res.result
assert res.result["b"] == {"result": "2"}
@pytest.mark.asyncio
async def test_pipeline_parameter_propagation() -> None:
pipe = Pipeline()
component_a = ComponentPassThrough()
component_b = ComponentPassThrough()
pipe.add_component(component_a, "a")
pipe.add_component(component_b, "b")
# first component output product goes to second component input number1
pipe.connect("a", "b", {"value": "a.result"})
with mock.patch(
"tests.unit.experimental.pipeline.test_pipeline.ComponentPassThrough.run"
) as mock_run:
mock_run.side_effect = [
StringResultModel(result="1"),
StringResultModel(result="2"),
]
res = await pipe.run({"a": {"value": "text"}})
mock_run.assert_has_awaits([call(**{"value": "text"}), call(**{"value": "1"})])
assert res.result == {"b": {"result": "2"}}
def test_pipeline_parameter_validation_no_expected_params() -> None:
pipe = Pipeline()
component_a = ComponentNoParam()
pipe.add_component(component_a, "a")
is_valid = pipe.validate_parameter_mapping_for_task(pipe.get_node_by_name("a"))
assert is_valid is True
def test_pipeline_parameter_validation_one_component_all_good() -> None:
pipe = Pipeline()
component_a = ComponentPassThrough()
pipe.add_component(component_a, "a")
is_valid = pipe.validate_parameter_mapping_for_task(pipe.get_node_by_name("a"))
assert is_valid is True
def test_pipeline_invalidate() -> None:
pipe = Pipeline()
pipe.is_validated = True
pipe.param_mapping = {"a": {"key": {"component": "component", "param": "param"}}}
pipe.missing_inputs = {"a": ["other_key"]}
pipe.invalidate()
assert pipe.is_validated is False
assert len(pipe.param_mapping) == 0
assert len(pipe.missing_inputs) == 0
def test_pipeline_parameter_validation_called_twice() -> None:
pipe = Pipeline()
component_a = ComponentPassThrough()
component_b = ComponentPassThrough()
pipe.add_component(component_a, "a")
pipe.add_component(component_b, "b")
pipe.connect("a", "b", {"value": "a.result"})
is_valid = pipe.validate_parameter_mapping_for_task(pipe.get_node_by_name("b"))
assert is_valid is True
with pytest.raises(PipelineDefinitionError):
pipe.validate_parameter_mapping_for_task(pipe.get_node_by_name("b"))
pipe.invalidate()
is_valid = pipe.validate_parameter_mapping_for_task(pipe.get_node_by_name("b"))
assert is_valid is True
def test_pipeline_parameter_validation_one_component_input_param_missing() -> None:
pipe = Pipeline()
component_a = ComponentPassThrough()
pipe.add_component(component_a, "a")
pipe.validate_parameter_mapping_for_task(pipe.get_node_by_name("a"))
assert pipe.missing_inputs["a"] == ["value"]
def test_pipeline_parameter_validation_param_mapped_twice() -> None:
pipe = Pipeline()
component_a = ComponentPassThrough()
component_b = ComponentPassThrough()
component_c = ComponentPassThrough()
pipe.add_component(component_a, "a")
pipe.add_component(component_b, "b")
pipe.add_component(component_c, "c")
pipe.connect("a", "c", {"value": "a.result"})
pipe.connect("b", "c", {"value": "b.result"})
with pytest.raises(PipelineDefinitionError) as excinfo:
pipe.validate_parameter_mapping_for_task(pipe.get_node_by_name("c"))
assert (
"Parameter 'value' already mapped to {'component': 'a', 'param': 'result'}"
in str(excinfo)
)
def test_pipeline_parameter_validation_unexpected_input() -> None:
pipe = Pipeline()
component_a = ComponentPassThrough()
component_b = ComponentPassThrough()
pipe.add_component(component_a, "a")
pipe.add_component(component_b, "b")
pipe.connect("a", "b", {"unexpected_input_name": "a.result"})
with pytest.raises(PipelineDefinitionError) as excinfo:
pipe.validate_parameter_mapping_for_task(pipe.get_node_by_name("b"))
assert (
"Parameter 'unexpected_input_name' is not a valid input for component 'b' of type 'ComponentPassThrough'"
in str(excinfo)
)
def test_pipeline_parameter_validation_connected_components_input() -> None:
"""Parameter for component 'b' comes from the pipeline inputs"""
pipe = Pipeline()
component_a = ComponentNoParam()
component_b = ComponentPassThrough()
pipe.add_component(component_a, "a")
pipe.add_component(component_b, "b")
pipe.connect("a", "b", {})
is_valid = pipe.validate_parameter_mapping_for_task(pipe.get_node_by_name("b"))
assert is_valid is True
assert dict(pipe.missing_inputs) == {"b": ["value"]}
def test_pipeline_parameter_validation_connected_components_result() -> None:
"""Parameter for component 'b' comes from the result of component 'a'"""
pipe = Pipeline()
component_a = ComponentNoParam()
component_b = ComponentPassThrough()
pipe.add_component(component_a, "a")
pipe.add_component(component_b, "b")
pipe.connect("a", "b", {"value": "b.result"})
is_valid = pipe.validate_parameter_mapping_for_task(pipe.get_node_by_name("b"))
assert is_valid is True
assert pipe.missing_inputs == {"b": []}
def test_pipeline_parameter_validation_connected_components_missing_input() -> None:
"""Parameter for component 'b' is missing"""
pipe = Pipeline()
component_a = ComponentNoParam()
component_b = ComponentPassThrough()
pipe.add_component(component_a, "a")
pipe.add_component(component_b, "b")
pipe.connect("a", "b", {})
is_valid = pipe.validate_parameter_mapping_for_task(pipe.get_node_by_name("b"))
assert is_valid is True
assert pipe.missing_inputs["b"] == ["value"]
def test_pipeline_parameter_validation_full_missing_inputs_in_user_data() -> None:
pipe = Pipeline()
component_a = ComponentNoParam()
component_b = ComponentPassThrough()
pipe.add_component(component_a, "a")
pipe.add_component(component_b, "b")
pipe.connect("a", "b", {})
is_valid = pipe.validate_input_data(data={"b": {"value": "input for b"}})
assert is_valid is True
def test_pipeline_parameter_validation_full_missing_inputs_in_component_name() -> None:
pipe = Pipeline()
component_a = ComponentNoParam()
component_b = ComponentPassThrough()
pipe.add_component(component_a, "a")
pipe.add_component(component_b, "b")
pipe.connect("a", "b", {})
with pytest.raises(PipelineDefinitionError):
pipe.validate_input_data(data={"b": {}})
def test_pipeline_parameter_validation_full_missing_inputs() -> None:
pipe = Pipeline()
component_a = ComponentNoParam()
component_b = ComponentPassThrough()
pipe.add_component(component_a, "a")
pipe.add_component(component_b, "b")
pipe.connect("a", "b", {})
with pytest.raises(PipelineDefinitionError):
pipe.validate_input_data(data={})
@pytest.mark.asyncio
async def test_pipeline_branches() -> None:
pipe = Pipeline()
component_a = AsyncMock(spec=Component)
component_a.run_with_context = AsyncMock(return_value={})
component_b = AsyncMock(spec=Component)
component_b.run_with_context = AsyncMock(return_value={})
component_c = AsyncMock(spec=Component)
component_c.run_with_context = AsyncMock(return_value={})
pipe.add_component(component_a, "a")
pipe.add_component(component_b, "b")
pipe.add_component(component_c, "c")
pipe.connect("a", "b")
pipe.connect("a", "c")
pipeline_result = await pipe.run({})
res = pipeline_result.result
assert "b" in res
assert "c" in res
@pytest.mark.asyncio
async def test_pipeline_aggregation() -> None:
pipe = Pipeline()
component_a = AsyncMock(spec=Component)
component_a.run_with_context = AsyncMock(return_value={})
component_b = AsyncMock(spec=Component)
component_b.run_with_context = AsyncMock(return_value={})
component_c = AsyncMock(spec=Component)
component_c.run_with_context = AsyncMock(return_value={})
pipe.add_component(
component_a,
"a",
)
pipe.add_component(
component_b,
"b",
)
pipe.add_component(component_c, "c")
pipe.connect("a", "c")
pipe.connect("b", "c")
pipeline_result = await pipe.run({})
res = pipeline_result.result
assert "c" in res
@pytest.mark.asyncio
async def test_pipeline_missing_param_on_init() -> None:
pipe = Pipeline()
component_a = ComponentAdd()
component_b = ComponentAdd()
pipe.add_component(component_a, "a")
pipe.add_component(component_b, "b")
pipe.connect("a", "b", {"number1": "a.result"})
with pytest.raises(PipelineDefinitionError) as excinfo:
await pipe.run({"a": {"number1": 1}})
assert (
"Missing input parameters for a: Expected parameters: ['number1', 'number2']. Got: ['number1']"
in str(excinfo.value)
)
@pytest.mark.asyncio
async def test_pipeline_missing_param_on_connect() -> None:
pipe = Pipeline()
component_a = ComponentAdd()
component_b = ComponentAdd()
pipe.add_component(component_a, "a")
pipe.add_component(component_b, "b")
pipe.connect("a", "b", {"number1": "a.result"})
with pytest.raises(PipelineDefinitionError) as excinfo:
await pipe.run({"a": {"number1": 1, "number2": 2}})
assert (
"Missing input parameters for b: Expected parameters: ['number1', 'number2']. Got: ['number1']"
in str(excinfo.value)
)
@pytest.mark.asyncio
async def test_pipeline_with_default_params() -> None:
pipe = Pipeline()
component_a = ComponentAdd()
component_b = ComponentMultiply()
pipe.add_component(component_a, "a")
pipe.add_component(component_b, "b")
pipe.connect("a", "b", {"number1": "a.result"})
pipeline_result = await pipe.run({"a": {"number1": 1, "number2": 2}})
res = pipeline_result.result
assert res == {"b": {"result": 6}} # (1+2)*2
@pytest.mark.asyncio
async def test_pipeline_cycle() -> None:
pipe = Pipeline()
component_a = ComponentNoParam()
component_b = ComponentNoParam()
pipe.add_component(component_a, "a")
pipe.add_component(component_b, "b")
pipe.connect("a", "b", {})
with pytest.raises(PipelineDefinitionError) as excinfo:
pipe.connect("b", "a", {})
assert "Cycles are not allowed" in str(excinfo.value)
@pytest.mark.asyncio
async def test_pipeline_wrong_component_name() -> None:
pipe = Pipeline()
component_a = ComponentNoParam()
component_b = ComponentNoParam()
pipe.add_component(component_a, "a")
pipe.add_component(component_b, "b")
with pytest.raises(PipelineDefinitionError) as excinfo:
pipe.connect("a", "c", {})
assert "a or c not in the Pipeline" in str(excinfo.value)
@pytest.mark.asyncio
async def test_pipeline_async() -> None:
pipe = Pipeline()
pipe.add_component(ComponentAdd(), "add")
run_params = [[1, 20], [10, 2]]
runs = []
for a, b in run_params:
runs.append(pipe.run({"add": {"number1": a, "number2": b}}))
pipeline_result = await asyncio.gather(*runs)
assert len(pipeline_result) == 2
assert pipeline_result[0].run_id != pipeline_result[1].run_id
assert pipeline_result[0].result == {"add": {"result": 21}}
assert pipeline_result[1].result == {"add": {"result": 12}}
def test_pipeline_to_viz() -> None:
pipe = Pipeline()
component_a = ComponentAdd()
component_b = ComponentMultiply()
pipe.add_component(component_a, "a")
pipe.add_component(component_b, "b")
pipe.connect("a", "b", {"number1": "a.result"})
g = pipe._get_neo4j_viz_graph()
# 3 nodes:
# - 2 components 'a' and 'b'
# - 1 output 'a.result'
assert len(g.nodes) == 3
g = pipe._get_neo4j_viz_graph(hide_unused_outputs=False)
# 4 nodes:
# - 2 components 'a' and 'b'
# - 2 output 'a.result' and 'b.result'
assert len(g.nodes) == 4
def test_pipeline_draw() -> None:
pipe = Pipeline()
pipe.add_component(ComponentAdd(), "add")
t = tempfile.NamedTemporaryFile(suffix=".html")
pipe.draw(t.name)
content = t.file.read()
assert len(content) > 0
@patch("neo4j_graphrag.experimental.pipeline.pipeline.neo4j_viz_available", False)
def test_pipeline_draw_missing_neo4j_viz_dep() -> None:
pipe = Pipeline()
pipe.add_component(ComponentAdd(), "add")
t = tempfile.NamedTemporaryFile(suffix=".html")
with pytest.raises(ImportError):
pipe.draw(t.name)
def test_run_result_no_warning(recwarn: Sized) -> None:
RunResult()
assert len(recwarn) == 0
@pytest.mark.asyncio
async def test_pipeline_event_notification() -> None:
callback = AsyncMock(spec=EventCallbackProtocol)
pipe = Pipeline(callback=callback)
component_a = ComponentMultiply()
pipe.add_component(
component_a,
"a",
)
a_input_data = {"number1": 2, "number2": 3}
pipeline_result = await pipe.run({"a": a_input_data})
await_calls = callback.await_args_list
expected_event_list = [
PipelineEvent(
event_type=EventType.PIPELINE_STARTED,
run_id=pipeline_result.run_id,
timestamp=datetime.datetime.now(),
message=None,
payload={"a": a_input_data},
),
TaskEvent(
event_type=EventType.TASK_STARTED,
run_id=pipeline_result.run_id,
task_name="a",
timestamp=datetime.datetime.now(),
message=None,
payload=a_input_data,
),
TaskEvent(
event_type=EventType.TASK_FINISHED,
run_id=pipeline_result.run_id,
task_name="a",
timestamp=datetime.datetime.now(),
message=None,
payload={"result": 6},
),
PipelineEvent(
event_type=EventType.PIPELINE_FINISHED,
run_id=pipeline_result.run_id,
timestamp=datetime.datetime.now(),
message=None,
payload={"a": {"result": 6}},
),
]
assert len(await_calls) == len(expected_event_list)
previous_ts = None
for await_call, expected_event in zip(await_calls, expected_event_list):
actual_event = await_call[0][0]
assert isinstance(actual_event, type(expected_event))
assert actual_event.event_type == expected_event.event_type
assert actual_event.run_id == expected_event.run_id
assert actual_event.message == expected_event.message
assert actual_event.payload == expected_event.payload
if previous_ts:
assert actual_event.timestamp > previous_ts
previous_ts = actual_event.timestamp
@pytest.mark.asyncio
async def test_pipeline_event_notification_error_in_pipeline_run() -> None:
callback = AsyncMock(spec=EventCallbackProtocol)
pipe = Pipeline(callback=callback)
component_a = ComponentAdd()
component_b = ComponentAdd()
pipe.add_component(component_a, "a")
pipe.add_component(component_b, "b")
pipe.connect("a", "b", {"number1": "a.result"})
with pytest.raises(PipelineDefinitionError):
await pipe.run({"a": {"number1": 1, "number2": 2}})
assert len(callback.await_args_list) == 2
assert callback.await_args_list[0][0][0].event_type == EventType.PIPELINE_STARTED
assert callback.await_args_list[1][0][0].event_type == EventType.PIPELINE_FAILED
def test_event_model_no_warning(recwarn: Sized) -> None:
event = Event(
event_type=EventType.PIPELINE_STARTED,
run_id="run_id",
message=None,
payload=None,
)
assert event.timestamp is not None
assert len(recwarn) == 0
@pytest.mark.asyncio
async def test_pipeline_streaming_no_user_callback_happy_path() -> None:
pipe = Pipeline()
events = []
async for e in pipe.stream({}):
events.append(e)
assert len(events) == 2
assert events[0].event_type == EventType.PIPELINE_STARTED
assert events[1].event_type == EventType.PIPELINE_FINISHED
assert len(pipe.event_notifier.callbacks) == 0
@pytest.mark.asyncio
async def test_pipeline_streaming_with_user_callback_happy_path() -> None:
callback = AsyncMock()
pipe = Pipeline(callback=callback)
events = []
async for e in pipe.stream({}):
events.append(e)
assert len(events) == 2
assert len(callback.call_args_list) == 2
assert len(pipe.event_notifier.callbacks) == 1
@pytest.mark.asyncio
async def test_pipeline_streaming_very_long_running_user_callback() -> None:
async def callback(event: Event) -> None:
await asyncio.sleep(2)
pipe = Pipeline(callback=callback)
events = []
async for e in pipe.stream({}):
events.append(e)
assert len(events) == 2
assert len(pipe.event_notifier.callbacks) == 1
@pytest.mark.asyncio
async def test_pipeline_streaming_very_long_running_pipeline() -> None:
slow_component = SlowComponentMultiply()
pipe = Pipeline()
pipe.add_component(slow_component, "slow_component")
events = []
async for e in pipe.stream({"slow_component": {"number1": 1, "number2": 2}}):
events.append(e)
assert len(events) == 4
last_event = events[-1]
assert last_event.event_type == EventType.PIPELINE_FINISHED
assert last_event.payload == {"slow_component": {"result": 2}}
@pytest.mark.asyncio
async def test_pipeline_streaming_error_in_pipeline_definition() -> None:
pipe = Pipeline()
component_a = ComponentAdd()
component_b = ComponentAdd()
pipe.add_component(component_a, "a")
pipe.add_component(component_b, "b")
pipe.connect("a", "b", {"number1": "a.result"})
events = []
with pytest.raises(PipelineDefinitionError):
async for e in pipe.stream({"a": {"number1": 1, "number2": 2}}):
events.append(e)
assert len(events) == 2
assert events[0].event_type == EventType.PIPELINE_STARTED
assert events[1].event_type == EventType.PIPELINE_FAILED
@pytest.mark.asyncio
async def test_pipeline_streaming_error_in_component() -> None:
component = ComponentMultiply()
pipe = Pipeline()
pipe.add_component(component, "component")
events = []
with pytest.raises(TypeError):
async for e in pipe.stream({"component": {"number1": None, "number2": 2}}):
events.append(e)
assert len(events) == 3
assert events[0].event_type == EventType.PIPELINE_STARTED
assert events[1].event_type == EventType.TASK_STARTED
assert events[2].event_type == EventType.PIPELINE_FAILED
@pytest.mark.asyncio
async def test_pipeline_streaming_error_in_user_callback() -> None:
async def callback(event: Event) -> None:
raise Exception("error in callback")
pipe = Pipeline(callback=callback)
events = []
async for e in pipe.stream({}):
events.append(e)
assert len(events) == 2
assert len(pipe.event_notifier.callbacks) == 1