참고소스 수정본

This commit is contained in:
LASTA_DEV01\lasta
2026-05-12 19:40:31 +09:00
parent 0f34a451fc
commit 2e9204243d
8708 changed files with 3259488 additions and 869 deletions

View File

@@ -0,0 +1,65 @@
# 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.
import asyncio
from neo4j_graphrag.experimental.pipeline import Component, DataModel
from neo4j_graphrag.experimental.pipeline.types.context import RunContext
class StringResultModel(DataModel):
result: str
class IntResultModel(DataModel):
result: int
class ComponentNoParam(Component):
async def run(self) -> StringResultModel:
return StringResultModel(result="")
class ComponentPassThrough(Component):
async def run(self, value: str) -> StringResultModel:
return StringResultModel(result=f"value is: {value}")
class ComponentAdd(Component):
async def run(self, number1: int, number2: int) -> IntResultModel:
return IntResultModel(result=number1 + number2)
class ComponentMultiply(Component):
async def run(self, number1: int, number2: int = 2) -> IntResultModel:
return IntResultModel(result=number1 * number2)
class ComponentMultiplyWithContext(Component):
async def run_with_context(
self, context_: RunContext, number1: int, number2: int = 2
) -> IntResultModel:
await context_.notify(
message="my message", data={"number1": number1, "number2": number2}
)
return IntResultModel(result=number1 * number2)
class SlowComponentMultiply(Component):
def __init__(self, sleep: float = 1.0) -> None:
self.sleep = sleep
async def run(self, number1: int, number2: int = 2) -> IntResultModel:
await asyncio.sleep(self.sleep)
return IntResultModel(result=number1 * number2)

View File

@@ -0,0 +1,594 @@
# 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.
import re
from unittest.mock import Mock, patch
import neo4j
import pytest
from neo4j_graphrag.embeddings import Embedder
from neo4j_graphrag.experimental.components.embedder import TextChunkEmbedder
from neo4j_graphrag.experimental.components.entity_relation_extractor import (
LLMEntityRelationExtractor,
OnError,
)
from neo4j_graphrag.experimental.components.kg_writer import Neo4jWriter
from neo4j_graphrag.experimental.components.data_loader import MarkdownLoader, PdfLoader
from neo4j_graphrag.experimental.components.schema import (
SchemaBuilder,
SchemaFromTextExtractor,
GraphSchema,
)
from neo4j_graphrag.experimental.components.text_splitters.fixed_size_splitter import (
FixedSizeSplitter,
)
from neo4j_graphrag.experimental.pipeline.config.object_config import (
ComponentConfig,
ComponentType,
)
from neo4j_graphrag.experimental.pipeline.config.template_pipeline import (
SimpleKGPipelineConfig,
)
from neo4j_graphrag.experimental.pipeline.exceptions import PipelineDefinitionError
from neo4j_graphrag.experimental.pipeline.types.schema import (
EntityInputType,
RelationInputType,
)
from neo4j_graphrag.experimental.components.types import DocumentType
from neo4j_graphrag.generation.prompts import ERExtractionTemplate
from neo4j_graphrag.llm import LLMInterface
def test_simple_kg_pipeline_config_file_loader_from_file_is_false() -> None:
config = SimpleKGPipelineConfig(from_file=False)
assert config._get_file_loader() is None
def test_simple_kg_pipeline_config_file_loader_from_file_is_true() -> None:
config = SimpleKGPipelineConfig(from_file=True)
assert config._get_file_loader() is not None
@pytest.mark.asyncio
async def test_simple_kg_pipeline_config_default_file_loader_supports_markdown() -> (
None
):
config = SimpleKGPipelineConfig(from_file=True)
loader = config._get_file_loader()
assert loader is not None
doc = await loader.run(
filepath="tests/unit/experimental/components/sample_data/hello.md"
)
assert doc.document_info.document_type == DocumentType.MARKDOWN
def test_simple_kg_pipeline_config_from_pdf_deprecated_maps_to_from_file() -> None:
with pytest.warns(DeprecationWarning, match="from_pdf"):
config = SimpleKGPipelineConfig(from_pdf=True)
assert config.from_file is True
with pytest.warns(DeprecationWarning, match="from_pdf"):
config_false = SimpleKGPipelineConfig(from_pdf=False)
assert config_false.from_file is False
def test_simple_kg_pipeline_config_pdf_loader_deprecated_maps_to_file_loader() -> None:
my_loader = PdfLoader()
with pytest.warns(DeprecationWarning, match="pdf_loader"):
config = SimpleKGPipelineConfig(
from_file=True,
pdf_loader=ComponentType(my_loader),
)
assert config._get_file_loader() == my_loader
def test_simple_kg_pipeline_config_pdf_loader_and_file_loader_conflict() -> None:
with pytest.raises(ValueError, match="pdf_loader"):
SimpleKGPipelineConfig(
from_file=True,
file_loader=ComponentType(PdfLoader()),
pdf_loader=ComponentType(PdfLoader()),
)
def test_simple_kg_pipeline_config_file_loader_from_file_is_true_class_overwrite() -> (
None
):
my_file_loader = MarkdownLoader()
config = SimpleKGPipelineConfig(
from_file=True, file_loader=ComponentType(my_file_loader)
)
assert config._get_file_loader() == my_file_loader
def test_simple_kg_pipeline_config_file_loader_class_overwrite_but_from_file_is_false() -> (
None
):
my_file_loader = PdfLoader()
config = SimpleKGPipelineConfig(
from_file=False, file_loader=ComponentType(my_file_loader)
)
assert config._get_file_loader() is None
@patch("neo4j_graphrag.experimental.pipeline.config.object_config.ComponentType.parse")
def test_simple_kg_pipeline_config_file_loader_from_file_is_true_class_overwrite_from_config(
mock_component_parse: Mock,
) -> None:
my_file_loader_config = ComponentConfig(
class_="",
)
my_file_loader = PdfLoader()
mock_component_parse.return_value = my_file_loader
config = SimpleKGPipelineConfig(
from_file=True,
file_loader=ComponentType(my_file_loader_config),
)
assert config._get_file_loader() == my_file_loader
def test_simple_kg_pipeline_config_text_splitter() -> None:
config = SimpleKGPipelineConfig()
assert isinstance(config._get_splitter(), FixedSizeSplitter)
@patch("neo4j_graphrag.experimental.pipeline.config.object_config.ComponentType.parse")
def test_simple_kg_pipeline_config_text_splitter_overwrite(
mock_component_parse: Mock,
) -> None:
my_text_splitter_config = ComponentConfig(
class_="",
)
my_text_splitter = FixedSizeSplitter()
mock_component_parse.return_value = my_text_splitter
config = SimpleKGPipelineConfig(
text_splitter=my_text_splitter_config, # type: ignore
)
assert config._get_splitter() == my_text_splitter
@patch(
"neo4j_graphrag.experimental.pipeline.config.template_pipeline.simple_kg_builder.SimpleKGPipelineConfig.get_default_embedder"
)
def test_simple_kg_pipeline_config_chunk_embedder(
mock_embedder: Mock, embedder: Embedder
) -> None:
mock_embedder.return_value = embedder
config = SimpleKGPipelineConfig()
chunk_embedder = config._get_chunk_embedder()
assert isinstance(chunk_embedder, TextChunkEmbedder)
assert chunk_embedder._embedder == embedder
@patch(
"neo4j_graphrag.experimental.pipeline.config.template_pipeline.simple_kg_builder.SimpleKGPipelineConfig.get_default_llm"
)
def test_simple_kg_pipeline_config_automatic_schema(
mock_llm: Mock, llm: LLMInterface
) -> None:
mock_llm.return_value = llm
config = SimpleKGPipelineConfig()
schema = config._get_schema()
assert isinstance(schema, SchemaFromTextExtractor)
assert schema._llm == llm
assert schema.use_structured_output is False
@patch(
"neo4j_graphrag.experimental.pipeline.config.template_pipeline.simple_kg_builder.SimpleKGPipelineConfig.get_default_llm"
)
def test_simple_kg_pipeline_config_automatic_schema_structured_output(
mock_llm: Mock, llm: LLMInterface
) -> None:
llm.supports_structured_output = True
mock_llm.return_value = llm
config = SimpleKGPipelineConfig()
schema = config._get_schema()
assert isinstance(schema, SchemaFromTextExtractor)
assert schema.use_structured_output is True
def test_simple_kg_pipeline_config_manual_schema() -> None:
config = SimpleKGPipelineConfig(entities=["Person"])
assert isinstance(config._get_schema(), SchemaBuilder)
def test_simple_kg_pipeline_config_literal_schema_validation() -> None:
config = SimpleKGPipelineConfig(schema="FREE") # type: ignore
assert config.schema_ == GraphSchema.create_empty()
config = SimpleKGPipelineConfig(schema="EXTRACTED") # type: ignore
assert config.schema_ is None
def test_simple_kg_pipeline_config_schema_run_params() -> None:
config = SimpleKGPipelineConfig(
entities=["Person"],
relations=["KNOWS"],
potential_schema=[("Person", "KNOWS", "Person")],
)
assert config._get_run_params_for_schema() == {
"node_types": ["Person"],
"relationship_types": ["KNOWS"],
"patterns": [
("Person", "KNOWS", "Person"),
],
}
@patch(
"neo4j_graphrag.experimental.pipeline.config.template_pipeline.simple_kg_builder.SimpleKGPipelineConfig.get_default_llm"
)
def test_simple_kg_pipeline_config_extractor(mock_llm: Mock, llm: LLMInterface) -> None:
mock_llm.return_value = llm
config = SimpleKGPipelineConfig(
on_error="IGNORE", # type: ignore
prompt_template=ERExtractionTemplate(template="my template {text}"),
)
extractor = config._get_extractor()
assert isinstance(extractor, LLMEntityRelationExtractor)
assert extractor.llm == llm
assert extractor.on_error == OnError.IGNORE
assert extractor.prompt_template.template == "my template {text}"
assert extractor.use_structured_output is False
@patch(
"neo4j_graphrag.experimental.pipeline.config.template_pipeline.simple_kg_builder.SimpleKGPipelineConfig.get_default_llm"
)
def test_simple_kg_pipeline_config_extractor_structured_output(
mock_llm: Mock, llm: LLMInterface
) -> None:
llm.supports_structured_output = True
mock_llm.return_value = llm
config = SimpleKGPipelineConfig()
extractor = config._get_extractor()
assert isinstance(extractor, LLMEntityRelationExtractor)
assert extractor.use_structured_output is True
@patch(
"neo4j_graphrag.experimental.components.kg_writer.get_version",
return_value=((5, 23, 0), False, False),
)
@patch(
"neo4j_graphrag.experimental.pipeline.config.template_pipeline.simple_kg_builder.SimpleKGPipelineConfig.get_default_neo4j_driver"
)
def test_simple_kg_pipeline_config_writer(
mock_driver: Mock,
_: Mock,
driver: neo4j.Driver,
) -> None:
mock_driver.return_value = driver
config = SimpleKGPipelineConfig(
neo4j_database="my_db",
)
writer = config._get_writer()
assert isinstance(writer, Neo4jWriter)
assert writer.driver == driver
assert writer.neo4j_database == "my_db"
@patch(
"neo4j_graphrag.experimental.components.kg_writer.get_version",
return_value=((5, 23, 0), False, False),
)
@patch("neo4j_graphrag.experimental.pipeline.config.object_config.ComponentType.parse")
def test_simple_kg_pipeline_config_writer_overwrite(
mock_component_parse: Mock,
_: Mock,
driver: neo4j.Driver,
) -> None:
my_writer_config = ComponentConfig(
class_="",
)
my_writer = Neo4jWriter(driver, neo4j_database="my_db")
mock_component_parse.return_value = my_writer
config = SimpleKGPipelineConfig(
kg_writer=my_writer_config, # type: ignore
neo4j_database="my_other_db",
)
writer: Neo4jWriter = config._get_writer() # type: ignore
assert writer == my_writer
# database not changed:
assert writer.neo4j_database == "my_db"
def test_simple_kg_pipeline_config_connections_from_file() -> None:
config = SimpleKGPipelineConfig(
from_file=True,
perform_entity_resolution=False,
)
connections = config._get_connections()
assert len(connections) == 7
expected_connections = [
("file_loader", "splitter"),
("file_loader", "schema"),
("schema", "extractor"),
("splitter", "chunk_embedder"),
("chunk_embedder", "extractor"),
("extractor", "pruner"),
("pruner", "writer"),
]
for actual, expected in zip(connections, expected_connections):
assert (actual.start, actual.end) == expected
def test_simple_kg_pipeline_config_connections_from_text() -> None:
config = SimpleKGPipelineConfig(
from_file=False,
perform_entity_resolution=False,
)
connections = config._get_connections()
assert len(connections) == 5
expected_connections = [
("schema", "extractor"),
("splitter", "chunk_embedder"),
("chunk_embedder", "extractor"),
("extractor", "pruner"),
("pruner", "writer"),
]
for actual, expected in zip(connections, expected_connections):
assert (actual.start, actual.end) == expected
def test_simple_kg_pipeline_config_connections_with_er() -> None:
config = SimpleKGPipelineConfig(
from_file=True,
perform_entity_resolution=True,
)
connections = config._get_connections()
assert len(connections) == 8
expected_connections = [
("file_loader", "splitter"),
("file_loader", "schema"),
("schema", "extractor"),
("splitter", "chunk_embedder"),
("chunk_embedder", "extractor"),
("extractor", "pruner"),
("pruner", "writer"),
("writer", "resolver"),
]
for actual, expected in zip(connections, expected_connections):
assert (actual.start, actual.end) == expected
def test_simple_kg_pipeline_config_run_params_from_file_file_path() -> None:
config = SimpleKGPipelineConfig(from_file=True)
assert config.get_run_params({"file_path": "my_file"}) == {
"file_loader": {"filepath": "my_file", "metadata": None}
}
def test_simple_kg_pipeline_config_run_params_from_text_text() -> None:
config = SimpleKGPipelineConfig(from_file=False)
run_params = config.get_run_params({"text": "my text"})
assert run_params["splitter"] == {"text": "my text"}
assert run_params["schema"] == {"text": "my text"}
assert run_params["extractor"]["document_info"]["path"] == "document.txt"
def test_simple_kg_pipeline_config_run_params_from_file_text() -> None:
config = SimpleKGPipelineConfig(from_file=True)
with pytest.raises(PipelineDefinitionError) as excinfo:
config.get_run_params({"text": "my text"})
assert (
"Expected 'file_path' to a PDF or Markdown file when 'from_file' is True"
in str(excinfo)
)
def test_simple_kg_pipeline_config_run_params_from_text_file_path() -> None:
config = SimpleKGPipelineConfig(from_file=False)
with pytest.raises(PipelineDefinitionError) as excinfo:
config.get_run_params({"file_path": "my file"})
assert "Expected 'text' argument when 'from_file' is False" in str(excinfo)
def test_simple_kg_pipeline_config_run_params_no_file_no_text() -> None:
config = SimpleKGPipelineConfig(from_file=False)
with pytest.raises(
PipelineDefinitionError,
match=re.escape(
"At least one of `text` (when from_file=False) or `file_path` (when from_file=True) argument must be provided."
),
):
config.get_run_params({})
def test_simple_kg_pipeline_config_process_schema_with_precedence_legacy() -> None:
entities: list[EntityInputType] = [
"Person",
{
"label": "Organization",
"description": "A group of persons",
"properties": [
{
"name": "name",
"type": "STRING",
}
],
},
]
relations: list[RelationInputType] = [
"WORKS_FOR",
{
"label": "CREATED",
"description": "A person created an organization",
"properties": [
{
"name": "date",
"description": "The date the organization was created",
"type": "DATE",
},
{"name": "isActive", "type": "BOOLEAN"},
],
},
]
potential_schema = [
("Person", "WORKS_FOR", "Organization"),
("Person", "CREATED", "Organization"),
]
config = SimpleKGPipelineConfig(
entities=entities,
relations=relations,
potential_schema=potential_schema,
)
schema_dict = config._process_schema_with_precedence()
node_types = schema_dict["node_types"]
relationship_types = schema_dict["relationship_types"]
patterns = schema_dict["patterns"]
assert len(node_types) == 2
assert node_types[0] == "Person"
assert node_types[1]["label"] == "Organization"
assert len(node_types[1]["properties"]) == 1
assert relationship_types is not None
assert len(relationship_types) == 2
assert relationship_types[0] == "WORKS_FOR"
assert relationship_types[1]["label"] == "CREATED"
assert len(relationship_types[1]["properties"]) == 2
assert patterns is not None
assert len(patterns) == 2
assert "additional_node_types" not in schema_dict
def test_simple_kg_pipeline_config_process_schema_with_precedence_schema_dict() -> None:
entities = [
"Person",
{
"label": "Organization",
"description": "A group of persons",
"properties": [
{
"name": "name",
"type": "STRING",
}
],
},
]
relations = [
"WORKS_FOR",
{
"label": "CREATED",
"description": "A person created an organization",
"properties": [
{
"name": "date",
"description": "The date the organization was created",
"type": "DATE",
},
{"name": "isActive", "type": "BOOLEAN"},
],
},
]
potential_schema = [
("Person", "WORKS_FOR", "Organization"),
("Person", "CREATED", "Organization"),
]
config = SimpleKGPipelineConfig(
schema={ # type: ignore
"node_types": entities,
"relationship_types": relations,
"patterns": potential_schema,
"additional_node_types": False,
}
)
schema_dict = config._process_schema_with_precedence()
node_types = schema_dict["node_types"]
relationship_types = schema_dict["relationship_types"]
patterns = schema_dict["patterns"]
assert len(node_types) == 2
assert node_types[0]["label"] == "Person"
# String input gets default "name" property and additional_properties=True
assert len(node_types[0]["properties"]) == 1
assert node_types[0]["properties"][0]["name"] == "name"
assert node_types[0]["additional_properties"] is True
assert node_types[1]["label"] == "Organization"
assert len(node_types[1]["properties"]) == 1
assert relationship_types is not None
assert len(relationship_types) == 2
assert relationship_types[0]["label"] == "WORKS_FOR"
assert len(relationship_types[0]["properties"]) == 0
assert relationship_types[1]["label"] == "CREATED"
assert len(relationship_types[1]["properties"]) == 2
assert patterns is not None
assert len(patterns) == 2
assert schema_dict["additional_node_types"] is False
def test_simple_kg_pipeline_config_process_schema_with_precedence_schema_object() -> (
None
):
entities = [
"Person",
{
"label": "Organization",
"description": "A group of persons",
"properties": [
{
"name": "name",
"type": "STRING",
}
],
},
]
relations = [
"WORKS_FOR",
{
"label": "CREATED",
"description": "A person created an organization",
"properties": [
{
"name": "date",
"description": "The date the organization was created",
"type": "DATE",
},
{"name": "isActive", "type": "BOOLEAN"},
],
},
]
potential_schema = [
("Person", "WORKS_FOR", "Organization"),
("Person", "CREATED", "Organization"),
]
config = SimpleKGPipelineConfig(
schema=GraphSchema.model_validate(
{
"node_types": entities,
"relationship_types": relations,
"patterns": potential_schema,
"additional_node_types": False,
}
)
)
schema_dict = config._process_schema_with_precedence()
node_types = schema_dict["node_types"]
relationship_types = schema_dict["relationship_types"]
patterns = schema_dict["patterns"]
assert len(node_types) == 2
assert node_types[0]["label"] == "Person"
# String input gets default "name" property and additional_properties=True
assert len(node_types[0]["properties"]) == 1
assert node_types[0]["properties"][0]["name"] == "name"
assert node_types[0]["additional_properties"] is True
assert node_types[1]["label"] == "Organization"
assert len(node_types[1]["properties"]) == 1
assert relationship_types is not None
assert len(relationship_types) == 2
assert relationship_types[0]["label"] == "WORKS_FOR"
assert len(relationship_types[0]["properties"]) == 0
assert relationship_types[1]["label"] == "CREATED"
assert len(relationship_types[1]["properties"]) == 2
assert patterns is not None
assert len(patterns) == 2
assert schema_dict["additional_node_types"] is False

View File

@@ -0,0 +1,37 @@
# 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 unittest.mock import patch
from neo4j_graphrag.experimental.pipeline.config.base import AbstractConfig
from neo4j_graphrag.experimental.pipeline.config.param_resolver import (
ParamToResolveConfig,
)
def test_resolve_param_with_param_to_resolve_object() -> None:
c = AbstractConfig()
with patch(
"neo4j_graphrag.experimental.pipeline.config.param_resolver.ParamToResolveConfig",
spec=ParamToResolveConfig,
) as mock_param_class:
mock_param = mock_param_class.return_value
mock_param.resolve.return_value = 1
assert c.resolve_param(mock_param) == 1
mock_param.resolve.assert_called_once_with({})
def test_resolve_param_with_other_object() -> None:
c = AbstractConfig()
assert c.resolve_param("value") == "value"

View File

@@ -0,0 +1,192 @@
# 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.
import importlib
import sys
from abc import ABC
from typing import ClassVar
from unittest.mock import patch
import neo4j
import pytest
from neo4j_graphrag.embeddings import Embedder, OpenAIEmbeddings
from neo4j_graphrag.experimental.pipeline import Pipeline
from neo4j_graphrag.experimental.pipeline.config.object_config import (
EmbedderConfig,
EmbedderType,
LLMConfig,
LLMType,
Neo4jDriverConfig,
Neo4jDriverType,
ObjectConfig,
)
from neo4j_graphrag.llm import LLMInterface, OpenAILLM
def test_get_class_no_optional_module() -> None:
c: ObjectConfig[object] = ObjectConfig()
klass = c._get_class("neo4j_graphrag.experimental.pipeline.Pipeline")
assert klass == Pipeline
def test_get_class_optional_module() -> None:
c: ObjectConfig[object] = ObjectConfig()
klass = c._get_class(
"Pipeline", optional_module="neo4j_graphrag.experimental.pipeline"
)
assert klass == Pipeline
def test_get_class_path_and_optional_module() -> None:
c: ObjectConfig[object] = ObjectConfig()
klass = c._get_class(
"pipeline.Pipeline", optional_module="neo4j_graphrag.experimental"
)
assert klass == Pipeline
def test_get_class_wrong_path() -> None:
c: ObjectConfig[object] = ObjectConfig()
with pytest.raises(ValueError):
c._get_class("MyClass")
class _MyClass:
def __init__(self, param: str) -> None:
self.param = param
class _MyInterface(ABC): ...
def test_parse_after_module_reload() -> None:
class MyClassConfig(ObjectConfig[_MyClass]):
DEFAULT_MODULE: ClassVar[str] = __name__
INTERFACE: ClassVar[type] = _MyClass
param_value = "value"
config = MyClassConfig.model_validate(
{"class_": f"{__name__}.{_MyClass.__name__}", "params_": {"param": param_value}}
)
importlib.reload(sys.modules[__name__])
my_obj = config.parse()
assert isinstance(my_obj, _MyClass)
assert my_obj.param == param_value
def test_neo4j_driver_config() -> None:
config = Neo4jDriverConfig.model_validate(
{
"params_": {
"uri": "bolt://",
"user": "a user",
"password": "a password",
}
}
)
assert config.class_ == "not used"
assert config.params_ == {
"uri": "bolt://",
"user": "a user",
"password": "a password",
}
with patch(
"neo4j_graphrag.experimental.pipeline.config.object_config.neo4j.GraphDatabase.driver"
) as driver_mock:
driver_mock.return_value = "a driver"
d = config.parse()
driver_mock.assert_called_once_with("bolt://", auth=("a user", "a password"))
assert d == "a driver" # type: ignore
def test_neo4j_driver_type_with_driver(driver: neo4j.Driver) -> None:
driver_type = Neo4jDriverType(driver)
assert driver_type.parse() == driver
def test_neo4j_driver_type_with_config() -> None:
driver_type = Neo4jDriverType(
Neo4jDriverConfig(
params_={
"uri": "bolt://",
"user": "",
"password": "",
}
)
)
driver = driver_type.parse()
assert isinstance(driver, neo4j.Driver)
def test_llm_config() -> None:
config = LLMConfig.model_validate(
{
"class_": "OpenAILLM",
"params_": {"model_name": "gpt-5", "api_key": "my-api-key"},
}
)
assert config.class_ == "OpenAILLM"
assert config.get_module() == "neo4j_graphrag.llm"
assert config.get_interface() == LLMInterface
assert config.params_ == {"model_name": "gpt-5", "api_key": "my-api-key"}
d = config.parse()
assert isinstance(d, OpenAILLM)
def test_llm_type_with_driver(llm: LLMInterface) -> None:
llm_type = LLMType(llm)
assert llm_type.parse() == llm
def test_llm_type_with_config() -> None:
llm_type = LLMType(
LLMConfig(
class_="OpenAILLM",
params_={"model_name": "gpt-5", "api_key": "my-api-key"},
)
)
llm = llm_type.parse()
assert isinstance(llm, OpenAILLM)
def test_embedder_config() -> None:
config = EmbedderConfig.model_validate(
{
"class_": "OpenAIEmbeddings",
"params_": {"api_key": "my-api-key"},
}
)
assert config.class_ == "OpenAIEmbeddings"
assert config.get_module() == "neo4j_graphrag.embeddings"
assert config.get_interface() == Embedder
assert config.params_ == {"api_key": "my-api-key"}
d = config.parse()
assert isinstance(d, OpenAIEmbeddings)
def test_embedder_type_with_embedder(embedder: Embedder) -> None:
embedder_type = EmbedderType(embedder)
assert embedder_type.parse() == embedder
def test_embedder_type_with_config() -> None:
embedder_type = EmbedderType(
EmbedderConfig(
class_="OpenAIEmbeddings",
params_={"api_key": "my-api-key"},
)
)
embedder = embedder_type.parse()
assert isinstance(embedder, OpenAIEmbeddings)

View File

@@ -0,0 +1,56 @@
# 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.
import os
from unittest.mock import patch
import pytest
from neo4j_graphrag.experimental.pipeline.config.param_resolver import (
ParamFromEnvConfig,
ParamFromKeyConfig,
)
@patch.dict(os.environ, {"MY_KEY": "my_value"}, clear=True)
def test_env_param_config_happy_path() -> None:
resolver = ParamFromEnvConfig(var_="MY_KEY")
assert resolver.resolve({}) == "my_value"
@patch.dict(os.environ, {}, clear=True)
def test_env_param_config_missing_env_var() -> None:
resolver = ParamFromEnvConfig(var_="MY_KEY")
assert resolver.resolve({}) is None
def test_config_key_param_simple_key() -> None:
resolver = ParamFromKeyConfig(key_="my_key")
assert resolver.resolve({"my_key": "my_value"}) == "my_value"
def test_config_key_param_missing_key() -> None:
resolver = ParamFromKeyConfig(key_="my_key")
with pytest.raises(KeyError):
resolver.resolve({})
def test_config_complex_key_param() -> None:
resolver = ParamFromKeyConfig(key_="my_key.my_sub_key")
assert resolver.resolve({"my_key": {"my_sub_key": "value"}}) == "value"
def test_config_complex_key_param_missing_subkey() -> None:
resolver = ParamFromKeyConfig(key_="my_key.my_sub_key")
with pytest.raises(KeyError):
resolver.resolve({"my_key": {}})

View File

@@ -0,0 +1,378 @@
# 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 unittest.mock import Mock, patch
import neo4j
from neo4j_graphrag.embeddings import Embedder
from neo4j_graphrag.experimental.pipeline import Component
from neo4j_graphrag.experimental.pipeline.config.object_config import (
ComponentConfig,
ComponentType,
Neo4jDriverConfig,
Neo4jDriverType,
)
from neo4j_graphrag.experimental.pipeline.config.param_resolver import (
ParamFromEnvConfig,
ParamFromKeyConfig,
)
from neo4j_graphrag.experimental.pipeline.config.pipeline_config import (
AbstractPipelineConfig,
)
from neo4j_graphrag.experimental.pipeline.types.definitions import ComponentDefinition
from neo4j_graphrag.llm import LLMInterface
@patch(
"neo4j_graphrag.experimental.pipeline.config.object_config.Neo4jDriverConfig.parse"
)
def test_abstract_pipeline_config_neo4j_config_is_a_dict_with_params_(
mock_neo4j_config: Mock,
) -> None:
mock_neo4j_config.return_value = "text"
config = AbstractPipelineConfig.model_validate(
{
"neo4j_config": {
"params_": {
"uri": "bolt://",
"user": "",
"password": "",
}
}
}
)
assert isinstance(config.neo4j_config, dict)
assert "default" in config.neo4j_config
config.parse()
mock_neo4j_config.assert_called_once()
assert config._global_data["neo4j_config"]["default"] == "text"
@patch(
"neo4j_graphrag.experimental.pipeline.config.object_config.Neo4jDriverConfig.parse"
)
def test_abstract_pipeline_config_neo4j_config_is_a_dict_with_names(
mock_neo4j_config: Mock,
) -> None:
mock_neo4j_config.return_value = "text"
config = AbstractPipelineConfig.model_validate(
{
"neo4j_config": {
"my_driver": {
"params_": {
"uri": "bolt://",
"user": "",
"password": "",
}
}
}
}
)
assert isinstance(config.neo4j_config, dict)
assert "my_driver" in config.neo4j_config
config.parse()
mock_neo4j_config.assert_called_once()
assert config._global_data["neo4j_config"]["my_driver"] == "text"
@patch(
"neo4j_graphrag.experimental.pipeline.config.object_config.Neo4jDriverConfig.parse"
)
def test_abstract_pipeline_config_neo4j_config_is_a_dict_with_driver(
mock_neo4j_config: Mock, driver: neo4j.Driver
) -> None:
config = AbstractPipelineConfig.model_validate(
{
"neo4j_config": {
"my_driver": driver,
}
}
)
assert isinstance(config.neo4j_config, dict)
assert "my_driver" in config.neo4j_config
config.parse()
assert not mock_neo4j_config.called
assert config._global_data["neo4j_config"]["my_driver"] == driver
@patch(
"neo4j_graphrag.experimental.pipeline.config.object_config.Neo4jDriverConfig.parse"
)
def test_abstract_pipeline_config_neo4j_config_is_a_driver(
mock_neo4j_config: Mock, driver: neo4j.Driver
) -> None:
config = AbstractPipelineConfig.model_validate(
{
"neo4j_config": driver,
}
)
assert isinstance(config.neo4j_config, dict)
assert "default" in config.neo4j_config
config.parse()
assert not mock_neo4j_config.called
assert config._global_data["neo4j_config"]["default"] == driver
@patch("neo4j_graphrag.experimental.pipeline.config.object_config.LLMConfig.parse")
def test_abstract_pipeline_config_llm_config_is_a_dict_with_params_(
mock_llm_config: Mock,
) -> None:
mock_llm_config.return_value = "text"
config = AbstractPipelineConfig.model_validate(
{"llm_config": {"class_": "OpenAILLM", "params_": {"model_name": "gpt-5"}}}
)
assert isinstance(config.llm_config, dict)
assert "default" in config.llm_config
config.parse()
mock_llm_config.assert_called_once()
assert config._global_data["llm_config"]["default"] == "text"
@patch("neo4j_graphrag.experimental.pipeline.config.object_config.LLMConfig.parse")
def test_abstract_pipeline_config_llm_config_is_a_dict_with_names(
mock_llm_config: Mock,
) -> None:
mock_llm_config.return_value = "text"
config = AbstractPipelineConfig.model_validate(
{
"llm_config": {
"my_llm": {"class_": "OpenAILLM", "params_": {"model_name": "gpt-5"}}
}
}
)
assert isinstance(config.llm_config, dict)
assert "my_llm" in config.llm_config
config.parse()
mock_llm_config.assert_called_once()
assert config._global_data["llm_config"]["my_llm"] == "text"
@patch("neo4j_graphrag.experimental.pipeline.config.object_config.LLMConfig.parse")
def test_abstract_pipeline_config_llm_config_is_a_dict_with_llm(
mock_llm_config: Mock, llm: LLMInterface
) -> None:
config = AbstractPipelineConfig.model_validate(
{
"llm_config": {
"my_llm": llm,
}
}
)
assert isinstance(config.llm_config, dict)
assert "my_llm" in config.llm_config
config.parse()
assert not mock_llm_config.called
assert config._global_data["llm_config"]["my_llm"] == llm
@patch("neo4j_graphrag.experimental.pipeline.config.object_config.LLMConfig.parse")
def test_abstract_pipeline_config_llm_config_is_a_llm(
mock_llm_config: Mock, llm: LLMInterface
) -> None:
config = AbstractPipelineConfig.model_validate(
{
"llm_config": llm,
}
)
assert isinstance(config.llm_config, dict)
assert "default" in config.llm_config
config.parse()
assert not mock_llm_config.called
assert config._global_data["llm_config"]["default"] == llm
@patch("neo4j_graphrag.experimental.pipeline.config.object_config.EmbedderConfig.parse")
def test_abstract_pipeline_config_embedder_config_is_a_dict_with_params_(
mock_embedder_config: Mock,
) -> None:
mock_embedder_config.return_value = "text"
config = AbstractPipelineConfig.model_validate(
{"embedder_config": {"class_": "OpenAIEmbeddings", "params_": {}}}
)
assert isinstance(config.embedder_config, dict)
assert "default" in config.embedder_config
config.parse()
mock_embedder_config.assert_called_once()
assert config._global_data["embedder_config"]["default"] == "text"
@patch("neo4j_graphrag.experimental.pipeline.config.object_config.EmbedderConfig.parse")
def test_abstract_pipeline_config_embedder_config_is_a_dict_with_names(
mock_embedder_config: Mock,
) -> None:
mock_embedder_config.return_value = "text"
config = AbstractPipelineConfig.model_validate(
{
"embedder_config": {
"my_embedder": {"class_": "OpenAIEmbeddings", "params_": {}}
}
}
)
assert isinstance(config.embedder_config, dict)
assert "my_embedder" in config.embedder_config
config.parse()
mock_embedder_config.assert_called_once()
assert config._global_data["embedder_config"]["my_embedder"] == "text"
@patch("neo4j_graphrag.experimental.pipeline.config.object_config.EmbedderConfig.parse")
def test_abstract_pipeline_config_embedder_config_is_a_dict_with_llm(
mock_embedder_config: Mock, embedder: Embedder
) -> None:
config = AbstractPipelineConfig.model_validate(
{
"embedder_config": {
"my_embedder": embedder,
}
}
)
assert isinstance(config.embedder_config, dict)
assert "my_embedder" in config.embedder_config
config.parse()
assert not mock_embedder_config.called
assert config._global_data["embedder_config"]["my_embedder"] == embedder
@patch("neo4j_graphrag.experimental.pipeline.config.object_config.EmbedderConfig.parse")
def test_abstract_pipeline_config_embedder_config_is_an_embedder(
mock_embedder_config: Mock, embedder: Embedder
) -> None:
config = AbstractPipelineConfig.model_validate(
{
"embedder_config": embedder,
}
)
assert isinstance(config.embedder_config, dict)
assert "default" in config.embedder_config
config.parse()
assert not mock_embedder_config.called
assert config._global_data["embedder_config"]["default"] == embedder
def test_abstract_pipeline_config_parse_global_data_no_extras(driver: Mock) -> None:
config = AbstractPipelineConfig(
neo4j_config={"my_driver": Neo4jDriverType(driver)},
)
gd = config._parse_global_data()
assert gd == {
"extras": {},
"neo4j_config": {
"my_driver": driver,
},
"llm_config": {},
"embedder_config": {},
}
@patch(
"neo4j_graphrag.experimental.pipeline.config.param_resolver.ParamFromEnvConfig.resolve"
)
def test_abstract_pipeline_config_parse_global_data_extras(
mock_param_resolver: Mock,
) -> None:
mock_param_resolver.return_value = "my value"
config = AbstractPipelineConfig(
extras={"my_extra_var": ParamFromEnvConfig(var_="some key")},
)
gd = config._parse_global_data()
assert gd == {
"extras": {"my_extra_var": "my value"},
"neo4j_config": {},
"llm_config": {},
"embedder_config": {},
}
@patch(
"neo4j_graphrag.experimental.pipeline.config.param_resolver.ParamFromEnvConfig.resolve"
)
@patch(
"neo4j_graphrag.experimental.pipeline.config.object_config.Neo4jDriverType.parse"
)
def test_abstract_pipeline_config_parse_global_data_use_extras_in_other_config(
mock_neo4j_parser: Mock,
mock_param_resolver: Mock,
) -> None:
"""Parser is able to read variables in the 'extras' section of config
to instantiate another object (neo4j.Driver in this test case)
"""
mock_param_resolver.side_effect = ["bolt://myhost", "myuser", "mypwd"]
mock_neo4j_parser.return_value = "my driver"
config = AbstractPipelineConfig(
extras={
"my_extra_uri": ParamFromEnvConfig(var_="some key"),
"my_extra_user": ParamFromEnvConfig(var_="some key"),
"my_extra_pwd": ParamFromEnvConfig(var_="some key"),
},
neo4j_config={
"my_driver": Neo4jDriverType(
Neo4jDriverConfig(
params_=dict(
uri=ParamFromKeyConfig(key_="extras.my_extra_uri"),
user=ParamFromKeyConfig(key_="extras.my_extra_user"),
password=ParamFromKeyConfig(key_="extras.my_extra_pwd"),
)
)
)
},
)
gd = config._parse_global_data()
expected_extras = {
"my_extra_uri": "bolt://myhost",
"my_extra_user": "myuser",
"my_extra_pwd": "mypwd",
}
assert gd["extras"] == expected_extras
assert gd["neo4j_config"] == {"my_driver": "my driver"}
mock_neo4j_parser.assert_called_once_with({"extras": expected_extras})
@patch("neo4j_graphrag.experimental.pipeline.config.object_config.ComponentType.parse")
def test_abstract_pipeline_config_resolve_component_definition_no_run_params(
mock_component_parse: Mock,
component: Component,
) -> None:
mock_component_parse.return_value = component
config = AbstractPipelineConfig()
component_type = ComponentType(component)
component_definition = config._resolve_component_definition("name", component_type)
assert isinstance(component_definition, ComponentDefinition)
mock_component_parse.assert_called_once_with({})
assert component_definition.name == "name"
assert component_definition.component == component
assert component_definition.run_params == {}
@patch(
"neo4j_graphrag.experimental.pipeline.config.pipeline_config.AbstractPipelineConfig.resolve_params"
)
@patch("neo4j_graphrag.experimental.pipeline.config.object_config.ComponentType.parse")
def test_abstract_pipeline_config_resolve_component_definition_with_run_params(
mock_component_parse: Mock,
mock_resolve_params: Mock,
component: Component,
) -> None:
mock_component_parse.return_value = component
mock_resolve_params.return_value = {"param": "resolver param result"}
config = AbstractPipelineConfig()
component_type = ComponentType(
ComponentConfig(class_="", params_={}, run_params_={"param1": "value1"})
)
component_definition = config._resolve_component_definition("name", component_type)
assert isinstance(component_definition, ComponentDefinition)
mock_component_parse.assert_called_once_with({})
assert component_definition.name == "name"
assert component_definition.component == component
assert component_definition.run_params == {"param": "resolver param result"}
mock_resolve_params.assert_called_once_with({"param1": "value1"})

View File

@@ -0,0 +1,56 @@
# 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 unittest.mock import Mock, patch
from neo4j_graphrag.experimental.pipeline import Pipeline
from neo4j_graphrag.experimental.pipeline.config.pipeline_config import PipelineConfig
from neo4j_graphrag.experimental.pipeline.config.runner import PipelineRunner
from neo4j_graphrag.experimental.pipeline.types.definitions import PipelineDefinition
@patch("neo4j_graphrag.experimental.pipeline.pipeline.Pipeline.from_definition")
def test_pipeline_runner_from_def_empty(mock_from_definition: Mock) -> None:
mock_from_definition.return_value = Pipeline()
runner = PipelineRunner(
pipeline_definition=PipelineDefinition(components=[], connections=[])
)
assert runner.config is None
assert runner.pipeline is not None
assert runner.pipeline._nodes == {}
assert runner.pipeline._edges == []
assert runner.run_params == {}
mock_from_definition.assert_called_once()
def test_pipeline_runner_from_config() -> None:
config = PipelineConfig(component_config={}, connection_config=[])
runner = PipelineRunner.from_config(config)
assert runner.config is not None
assert runner.pipeline is not None
assert runner.pipeline._nodes == {}
assert runner.pipeline._edges == []
assert runner.run_params == {}
@patch("neo4j_graphrag.experimental.pipeline.config.runner.PipelineRunner.from_config")
@patch("neo4j_graphrag.utils.file_handler.FileHandler.read")
def test_pipeline_runner_from_config_file(
mock_read: Mock, mock_from_config: Mock
) -> None:
mock_read.return_value = {"dict": "with data"}
PipelineRunner.from_config_file("file.yaml")
mock_read.assert_called_once_with("file.yaml")
mock_from_config.assert_called_once_with({"dict": "with data"}, do_cleaning=True)

View File

@@ -0,0 +1,89 @@
# 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 unittest.mock import AsyncMock
import pytest
from neo4j_graphrag.experimental.pipeline import Component
from neo4j_graphrag.experimental.pipeline.types.context import RunContext
from .components import ComponentMultiply, ComponentMultiplyWithContext, IntResultModel
def test_component_inputs() -> None:
inputs = ComponentMultiply.component_inputs
assert "number1" in inputs
assert inputs["number1"]["has_default"] is False
assert "number2" in inputs
assert inputs["number2"]["has_default"] is True
def test_component_outputs() -> None:
outputs = ComponentMultiply.component_outputs
assert "result" in outputs
assert outputs["result"]["has_default"] is True
assert outputs["result"]["annotation"] == int
@pytest.mark.asyncio
async def test_component_run() -> None:
c = ComponentMultiply()
result = await c.run(number1=1, number2=2)
assert isinstance(result, IntResultModel)
assert isinstance(
result.result,
# we know this is a type and not a bool or str:
ComponentMultiply.component_outputs["result"]["annotation"], # type: ignore
)
@pytest.mark.asyncio
async def test_component_run_with_context_default_implementation() -> None:
c = ComponentMultiply()
result = await c.run_with_context(
# context can not be null in the function signature,
# but it's ignored in this case
None, # type: ignore
number1=1,
number2=2,
)
# the type checker doesn't know about the type
# because the method is not re-declared
assert result.result == 2 # type: ignore
@pytest.mark.asyncio
async def test_component_run_with_context() -> None:
c = ComponentMultiplyWithContext()
notifier_mock = AsyncMock()
result = await c.run_with_context(
RunContext(run_id="run_id", task_name="task_name", notifier=notifier_mock),
number1=1,
number2=2,
)
assert result.result == 2
notifier_mock.assert_awaited_once()
def test_component_missing_method() -> None:
with pytest.raises(RuntimeError) as e:
class WrongComponent(Component):
# we must have either run or run_with_context
pass
assert (
"You must implement either `run` or `run_with_context` in Component 'WrongComponent'"
in str(e)
)

View File

@@ -0,0 +1,272 @@
# 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 unittest import mock
from unittest.mock import MagicMock, Mock, patch
import neo4j
import pytest
from neo4j_graphrag.embeddings import Embedder
from neo4j_graphrag.experimental.components.data_loader import PdfLoader
from neo4j_graphrag.experimental.components.types import (
LexicalGraphConfig,
)
from neo4j_graphrag.experimental.pipeline.exceptions import PipelineDefinitionError
from neo4j_graphrag.experimental.pipeline.kg_builder import SimpleKGPipeline
from neo4j_graphrag.experimental.pipeline.pipeline import PipelineResult
from neo4j_graphrag.llm.base import LLMInterface
@mock.patch(
"neo4j_graphrag.experimental.components.kg_writer.get_version",
return_value=((5, 23, 0), False, False),
)
@pytest.mark.asyncio
async def test_knowledge_graph_builder_from_pdf_deprecated_kwarg(_: Mock) -> None:
llm = MagicMock(spec=LLMInterface)
driver = MagicMock(spec=neo4j.Driver)
embedder = MagicMock(spec=Embedder)
with pytest.warns(DeprecationWarning, match="from_pdf"):
kg_builder = SimpleKGPipeline(
llm=llm,
driver=driver,
embedder=embedder,
from_pdf=True,
)
file_path = "path/to/test.pdf"
with patch.object(
kg_builder.runner.pipeline,
"run",
return_value=PipelineResult(run_id="test_run", result=None),
) as mock_run:
await kg_builder.run_async(file_path=file_path)
pipe_inputs = mock_run.call_args[1]["data"]
assert "file_loader" in pipe_inputs
@mock.patch(
"neo4j_graphrag.experimental.components.kg_writer.get_version",
return_value=((5, 23, 0), False, False),
)
@pytest.mark.asyncio
async def test_knowledge_graph_builder_pdf_loader_deprecated_kwarg(_: Mock) -> None:
llm = MagicMock(spec=LLMInterface)
driver = MagicMock(spec=neo4j.Driver)
embedder = MagicMock(spec=Embedder)
loader = PdfLoader()
with pytest.warns(DeprecationWarning, match="pdf_loader"):
kg_builder = SimpleKGPipeline(
llm=llm,
driver=driver,
embedder=embedder,
pdf_loader=loader,
)
file_path = "path/to/test.pdf"
with patch.object(
kg_builder.runner.pipeline,
"run",
return_value=PipelineResult(run_id="test_run", result=None),
) as mock_run:
await kg_builder.run_async(file_path=file_path)
pipe_inputs = mock_run.call_args[1]["data"]
assert "file_loader" in pipe_inputs
@mock.patch(
"neo4j_graphrag.experimental.components.kg_writer.get_version",
return_value=((5, 23, 0), False, False),
)
@pytest.mark.asyncio
async def test_knowledge_graph_builder_document_info_with_file(_: Mock) -> None:
llm = MagicMock(spec=LLMInterface)
driver = MagicMock(spec=neo4j.Driver)
embedder = MagicMock(spec=Embedder)
kg_builder = SimpleKGPipeline(
llm=llm,
driver=driver,
embedder=embedder,
from_file=True,
)
file_path = "path/to/test.pdf"
with patch.object(
kg_builder.runner.pipeline,
"run",
return_value=PipelineResult(run_id="test_run", result=None),
) as mock_run:
await kg_builder.run_async(
file_path=file_path, document_metadata={"source": "google drive"}
)
pipe_inputs = mock_run.call_args[1]["data"]
assert "file_loader" in pipe_inputs
assert pipe_inputs["file_loader"] == {
"filepath": file_path,
"metadata": {"source": "google drive"},
}
assert "extractor" not in pipe_inputs
@mock.patch(
"neo4j_graphrag.experimental.components.kg_writer.get_version",
return_value=((5, 23, 0), False, False),
)
@pytest.mark.asyncio
async def test_knowledge_graph_builder_document_info_with_text(_: Mock) -> None:
llm = MagicMock(spec=LLMInterface)
driver = MagicMock(spec=neo4j.Driver)
embedder = MagicMock(spec=Embedder)
kg_builder = SimpleKGPipeline(
llm=llm,
driver=driver,
embedder=embedder,
from_file=False,
)
text_input = "May thy knife chip and shatter."
with patch.object(
kg_builder.runner.pipeline,
"run",
return_value=PipelineResult(run_id="test_run", result=None),
) as mock_run:
await kg_builder.run_async(
text=text_input,
file_path="my_document.txt",
document_metadata={"source": "google drive"},
)
pipe_inputs = mock_run.call_args[1]["data"]
assert "splitter" in pipe_inputs
assert pipe_inputs["splitter"] == {"text": text_input}
assert pipe_inputs["extractor"]["document_info"]["path"] == "my_document.txt"
assert pipe_inputs["extractor"]["document_info"]["metadata"] == {
"source": "google drive"
}
@mock.patch(
"neo4j_graphrag.experimental.components.kg_writer.get_version",
return_value=((5, 23, 0), False, False),
)
@pytest.mark.asyncio
async def test_knowledge_graph_builder_with_entities_and_file(_: Mock) -> None:
llm = MagicMock(spec=LLMInterface)
driver = MagicMock(spec=neo4j.Driver)
embedder = MagicMock(spec=Embedder)
entities = ["Document", "Section"]
relations = ["CONTAINS"]
potential_schema = [("Document", "CONTAINS", "Section")]
kg_builder = SimpleKGPipeline(
llm=llm,
driver=driver,
embedder=embedder,
entities=entities,
relations=relations,
potential_schema=potential_schema,
from_file=True,
)
file_path = "path/to/test.pdf"
with patch.object(
kg_builder.runner.pipeline,
"run",
return_value=PipelineResult(run_id="test_run", result=None),
) as mock_run:
await kg_builder.run_async(file_path=file_path)
pipe_inputs = mock_run.call_args[1]["data"]
assert pipe_inputs["schema"]["node_types"] == entities
assert pipe_inputs["schema"]["relationship_types"] == relations
assert pipe_inputs["schema"]["patterns"] == potential_schema
def test_simple_kg_pipeline_on_error_invalid_value() -> None:
llm = MagicMock(spec=LLMInterface)
driver = MagicMock(spec=neo4j.Driver)
embedder = MagicMock(spec=Embedder)
with pytest.raises(PipelineDefinitionError):
SimpleKGPipeline(
llm=llm,
driver=driver,
embedder=embedder,
on_error="INVALID_VALUE",
)
def test_knowledge_graph_builder_pdf_loader_and_file_loader_conflict() -> None:
llm = MagicMock(spec=LLMInterface)
driver = MagicMock(spec=neo4j.Driver)
embedder = MagicMock(spec=Embedder)
with pytest.raises(ValueError, match="pdf_loader"):
SimpleKGPipeline(
llm=llm,
driver=driver,
embedder=embedder,
file_loader=PdfLoader(),
pdf_loader=PdfLoader(),
)
@mock.patch(
"neo4j_graphrag.experimental.components.kg_writer.get_version",
return_value=((5, 23, 0), False, False),
)
@pytest.mark.asyncio
async def test_knowledge_graph_builder_with_lexical_graph_config(_: Mock) -> None:
llm = MagicMock(spec=LLMInterface)
driver = MagicMock(spec=neo4j.Driver)
embedder = MagicMock(spec=Embedder)
chunk_node_label = "TestChunk"
document_nodel_label = "TestDocument"
lexical_graph_config = LexicalGraphConfig(
chunk_node_label=chunk_node_label, document_node_label=document_nodel_label
)
kg_builder = SimpleKGPipeline(
llm=llm,
driver=driver,
embedder=embedder,
from_file=False,
lexical_graph_config=lexical_graph_config,
)
text_input = "May thy knife chip and shatter."
with patch.object(
kg_builder.runner.pipeline,
"run",
return_value=PipelineResult(run_id="test_run", result=None),
) as mock_run:
await kg_builder.run_async(text=text_input)
pipe_inputs = mock_run.call_args[1]["data"]
assert "extractor" in pipe_inputs
assert pipe_inputs["extractor"]["lexical_graph_config"] == lexical_graph_config
assert pipe_inputs["extractor"]["document_info"] is not None
assert pipe_inputs["extractor"]["document_info"]["path"] == "document.txt"

View File

@@ -0,0 +1,384 @@
# 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 unittest.mock import Mock, patch
import pytest
from neo4j_graphrag.experimental.pipeline import (
Component,
Pipeline,
)
from neo4j_graphrag.experimental.pipeline.exceptions import (
PipelineDefinitionError,
PipelineMissingDependencyError,
PipelineStatusUpdateError,
)
from neo4j_graphrag.experimental.pipeline.orchestrator import Orchestrator
from neo4j_graphrag.experimental.pipeline.types.orchestration import RunStatus
from tests.unit.experimental.pipeline.components import (
ComponentNoParam,
ComponentPassThrough,
)
def test_orchestrator_get_input_config_for_task_pipeline_not_validated() -> None:
pipe = Pipeline()
pipe.add_component(ComponentPassThrough(), "a")
pipe.add_component(ComponentPassThrough(), "b")
orchestrator = Orchestrator(pipe)
with pytest.raises(PipelineDefinitionError) as exc:
orchestrator.get_input_config_for_task(pipe.get_node_by_name("a"))
assert "You must validate the pipeline input config first" in str(exc.value)
@pytest.mark.asyncio
async def test_orchestrator_get_component_inputs_from_user_only() -> None:
"""Components take all their inputs from user input."""
pipe = Pipeline()
pipe.add_component(ComponentPassThrough(), "a")
pipe.add_component(ComponentPassThrough(), "b")
orchestrator = Orchestrator(pipe)
input_data = {
"a": {"value": "user input for component a"},
"b": {"value": "user input for component b"},
}
data = await orchestrator.get_component_inputs("a", {}, input_data)
assert data == {"value": "user input for component a"}
data = await orchestrator.get_component_inputs("b", {}, input_data)
assert data == {"value": "user input for component b"}
@patch(
"neo4j_graphrag.experimental.pipeline.pipeline.Orchestrator.get_results_for_component"
)
@pytest.mark.asyncio
async def test_orchestrator_get_component_inputs_from_parent_specific(
mock_result: Mock,
) -> None:
"""Propagate one specific output field from parent to a child component."""
pipe = Pipeline()
pipe.add_component(ComponentPassThrough(), "a")
pipe.add_component(ComponentPassThrough(), "b")
pipe.connect("a", "b", input_config={"value": "a.result"})
# component "a" already run and results stored:
mock_result.return_value = {"result": "output from component a"}
orchestrator = Orchestrator(pipe)
data = await orchestrator.get_component_inputs(
"b", {"value": {"component": "a", "param": "result"}}, {}
)
assert data == {"value": "output from component a"}
@patch(
"neo4j_graphrag.experimental.pipeline.pipeline.Orchestrator.get_results_for_component"
)
@pytest.mark.asyncio
async def test_orchestrator_get_component_inputs_from_parent_all(
mock_result: Mock,
) -> None:
"""Use the component name to get the full output
(without extracting a specific field).
"""
pipe = Pipeline()
pipe.add_component(ComponentNoParam(), "a")
pipe.add_component(ComponentPassThrough(), "b")
pipe.connect("a", "b", input_config={"value": "a"})
# component "a" already run and results stored:
mock_result.return_value = {"result": "output from component a"}
orchestrator = Orchestrator(pipe)
data = await orchestrator.get_component_inputs(
"b", {"value": {"component": "a"}}, {}
)
assert data == {"value": {"result": "output from component a"}}
@patch(
"neo4j_graphrag.experimental.pipeline.pipeline.Orchestrator.get_results_for_component"
)
@pytest.mark.asyncio
async def test_orchestrator_get_component_inputs_from_parent_and_input(
mock_result: Mock,
) -> None:
"""Some parameters from user input, some other parameter from previous component."""
pipe = Pipeline()
pipe.add_component(ComponentNoParam(), "a")
pipe.add_component(ComponentPassThrough(), "b")
pipe.connect("a", "b", input_config={"value": "a"})
# component "a" already run and results stored:
mock_result.return_value = {"result": "output from component a"}
orchestrator = Orchestrator(pipe)
data = await orchestrator.get_component_inputs(
"b",
{"value": {"component": "a"}},
{"b": {"other_value": "user input for component b 'other_value' param"}},
)
assert data == {
"value": {"result": "output from component a"},
"other_value": "user input for component b 'other_value' param",
}
@patch(
"neo4j_graphrag.experimental.pipeline.pipeline.Orchestrator.get_results_for_component"
)
@pytest.mark.asyncio
async def test_orchestrator_get_component_inputs_ignore_user_input_if_input_def_provided(
mock_result: Mock,
) -> None:
"""If a parameter is defined both in the user input and in an input definition
(ie propagated from a previous component), the user input is ignored and a
warning is raised.
"""
pipe = Pipeline()
pipe.add_component(ComponentNoParam(), "a")
pipe.add_component(ComponentPassThrough(), "b")
pipe.connect("a", "b", input_config={"value": "a"})
# component "a" already run and results stored:
mock_result.return_value = {"result": "output from component a"}
orchestrator = Orchestrator(pipe)
with pytest.warns(Warning) as w:
data = await orchestrator.get_component_inputs(
"b",
{"value": {"component": "a"}},
{"b": {"value": "user input for component a"}},
)
assert data == {"value": {"result": "output from component a"}}
assert (
w[0].message.args[0] # type: ignore[union-attr]
== "In component 'b', parameter 'value' from user input will be ignored and replaced by 'a.value'"
)
@pytest.mark.asyncio
@patch(
"neo4j_graphrag.experimental.pipeline.pipeline.Orchestrator.get_status_for_component"
)
@pytest.mark.parametrize(
"old_status, new_status, result",
[
# Normal path: from UNKNOWN to RUNNING to DONE
(RunStatus.UNKNOWN, RunStatus.RUNNING, "ok"),
(RunStatus.RUNNING, RunStatus.DONE, "ok"),
# Error: status is already set to this value
(RunStatus.RUNNING, RunStatus.RUNNING, "Status is already RunStatus.RUNNING"),
(RunStatus.DONE, RunStatus.DONE, "Status is already RunStatus.DONE"),
# Error: can't go back in time
(
RunStatus.DONE,
RunStatus.RUNNING,
"Can't go from RunStatus.DONE to RunStatus.RUNNING",
),
(
RunStatus.RUNNING,
RunStatus.UNKNOWN,
"Can't go from RunStatus.RUNNING to RunStatus.UNKNOWN",
),
(
RunStatus.DONE,
RunStatus.UNKNOWN,
"Can't go from RunStatus.DONE to RunStatus.UNKNOWN",
),
],
)
async def test_orchestrator_set_component_status(
mock_status: Mock,
old_status: RunStatus,
new_status: RunStatus,
result: str,
) -> None:
pipe = Pipeline()
orchestrator = Orchestrator(pipeline=pipe)
mock_status.side_effect = [
old_status,
]
if result == "ok":
await orchestrator.set_task_status("task_name", new_status)
else:
with pytest.raises(PipelineStatusUpdateError) as exc:
await orchestrator.set_task_status("task_name", new_status)
assert result in str(exc)
@pytest.fixture(scope="function")
def pipeline_branch() -> Pipeline:
pipe = Pipeline()
pipe.add_component(Component(), "a") # type: ignore[abstract,unused-ignore]
pipe.add_component(Component(), "b") # type: ignore[abstract,unused-ignore]
pipe.add_component(Component(), "c") # type: ignore[abstract,unused-ignore]
pipe.connect("a", "b")
pipe.connect("a", "c")
return pipe
@pytest.fixture(scope="function")
def pipeline_aggregation() -> Pipeline:
pipe = Pipeline()
pipe.add_component(Component(), "a") # type: ignore[abstract,unused-ignore]
pipe.add_component(Component(), "b") # type: ignore[abstract,unused-ignore]
pipe.add_component(Component(), "c") # type: ignore[abstract,unused-ignore]
pipe.connect("a", "c")
pipe.connect("b", "c")
return pipe
@pytest.mark.asyncio
@patch(
"neo4j_graphrag.experimental.pipeline.pipeline.Orchestrator.get_status_for_component"
)
async def test_orchestrator_check_dependency_complete(
mock_status: Mock, pipeline_branch: Pipeline
) -> None:
"""a -> b, c"""
orchestrator = Orchestrator(pipeline=pipeline_branch)
node_a = pipeline_branch.get_node_by_name("a")
await orchestrator.check_dependencies_complete(node_a)
node_b = pipeline_branch.get_node_by_name("b")
# dependency is DONE:
mock_status.side_effect = [RunStatus.DONE]
await orchestrator.check_dependencies_complete(node_b)
# dependency is not DONE:
mock_status.side_effect = [RunStatus.RUNNING]
with pytest.raises(PipelineMissingDependencyError):
await orchestrator.check_dependencies_complete(node_b)
@pytest.mark.asyncio
@patch(
"neo4j_graphrag.experimental.pipeline.pipeline.Orchestrator.get_status_for_component"
)
@patch(
"neo4j_graphrag.experimental.pipeline.pipeline.Orchestrator.check_dependencies_complete",
)
async def test_orchestrator_next_task_branch_no_missing_dependencies(
mock_dep: Mock, mock_status: Mock, pipeline_branch: Pipeline
) -> None:
"""a -> b, c"""
orchestrator = Orchestrator(pipeline=pipeline_branch)
node_a = pipeline_branch.get_node_by_name("a")
mock_status.side_effect = [
# next "b"
RunStatus.UNKNOWN,
# next "c"
RunStatus.UNKNOWN,
]
mock_dep.side_effect = [
None, # "b" has no missing dependencies
None, # "c" has no missing dependencies
]
next_tasks = [n async for n in orchestrator.next(node_a)]
next_task_names = [n.name for n in next_tasks]
assert next_task_names == ["b", "c"]
@pytest.mark.asyncio
@patch(
"neo4j_graphrag.experimental.pipeline.pipeline.Orchestrator.get_status_for_component"
)
@patch(
"neo4j_graphrag.experimental.pipeline.pipeline.Orchestrator.check_dependencies_complete",
)
async def test_orchestrator_next_task_branch_missing_dependencies(
mock_dep: Mock, mock_status: Mock, pipeline_branch: Pipeline
) -> None:
"""a -> b, c"""
orchestrator = Orchestrator(pipeline=pipeline_branch)
node_a = pipeline_branch.get_node_by_name("a")
mock_status.side_effect = [
# next "b"
RunStatus.UNKNOWN,
# next "c"
RunStatus.UNKNOWN,
]
mock_dep.side_effect = [
PipelineMissingDependencyError, # "b" has missing dependencies
None, # "c" has no missing dependencies
]
next_tasks = [n async for n in orchestrator.next(node_a)]
next_task_names = [n.name for n in next_tasks]
assert next_task_names == ["c"]
@pytest.mark.asyncio
@patch(
"neo4j_graphrag.experimental.pipeline.pipeline.Orchestrator.get_status_for_component"
)
@patch(
"neo4j_graphrag.experimental.pipeline.pipeline.Orchestrator.check_dependencies_complete",
)
async def test_orchestrator_next_task_aggregation_no_missing_dependencies(
mock_dep: Mock, mock_status: Mock, pipeline_aggregation: Pipeline
) -> None:
"""a, b -> c"""
orchestrator = Orchestrator(pipeline=pipeline_aggregation)
node_a = pipeline_aggregation.get_node_by_name("a")
mock_status.side_effect = [
RunStatus.UNKNOWN, # status for "c", not started
]
mock_dep.side_effect = [
None, # no missing deps
]
# then "c" can start
next_tasks = [n async for n in orchestrator.next(node_a)]
next_task_names = [n.name for n in next_tasks]
assert next_task_names == ["c"]
@pytest.mark.asyncio
@patch(
"neo4j_graphrag.experimental.pipeline.pipeline.Orchestrator.get_status_for_component"
)
@patch(
"neo4j_graphrag.experimental.pipeline.pipeline.Orchestrator.check_dependencies_complete",
)
async def test_orchestrator_next_task_aggregation_missing_dependency(
mock_dep: Mock, mock_status: Mock, pipeline_aggregation: Pipeline
) -> None:
"""a, b -> c"""
orchestrator = Orchestrator(pipeline=pipeline_aggregation)
node_a = pipeline_aggregation.get_node_by_name("a")
mock_status.side_effect = [
RunStatus.UNKNOWN, # status for "c" is unknown, it's a possible next
]
mock_dep.side_effect = [
PipelineMissingDependencyError, # some dependencies are not done yet
]
next_task_names = [n.name async for n in orchestrator.next(node_a)]
# "c" dependencies not ready yet
assert next_task_names == []
@pytest.mark.asyncio
@patch(
"neo4j_graphrag.experimental.pipeline.pipeline.Orchestrator.get_status_for_component"
)
async def test_orchestrator_next_task_aggregation_next_already_started(
mock_status: Mock, pipeline_aggregation: Pipeline
) -> None:
"""a, b -> c"""
orchestrator = Orchestrator(pipeline=pipeline_aggregation)
node_a = pipeline_aggregation.get_node_by_name("a")
mock_status.side_effect = [
RunStatus.RUNNING, # status for "c" is already running, do not start it again
]
next_task_names = [n.name async for n in orchestrator.next(node_a)]
assert next_task_names == []

View File

@@ -0,0 +1,607 @@
# 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

View File

@@ -0,0 +1,137 @@
# 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.
import pytest
from neo4j_graphrag.experimental.pipeline.pipeline_graph import (
PipelineEdge,
PipelineGraph,
PipelineNode,
)
def test_node_alone() -> None:
n = PipelineNode(name="node", data={})
assert n.is_root() is True
assert n.is_leaf() is True
def test_node_not_root() -> None:
n = PipelineNode(name="node", data={})
n.parents = ["other_node"]
assert n.is_root() is False
assert n.is_leaf() is True
def test_node_not_leaf() -> None:
n = PipelineNode(name="node", data={})
n.children = ["other_node"]
assert n.is_root() is True
assert n.is_leaf() is False
def test_graph_add_nodes() -> None:
g: PipelineGraph[PipelineNode, PipelineEdge] = PipelineGraph()
n1 = PipelineNode("n1", {})
n2 = PipelineNode("n2", {})
g.add_node(n1)
g.add_node(n2)
assert len(g._nodes) == 2
edge = PipelineEdge(n1.name, n2.name, {"key": "value"})
g.add_edge(edge)
assert len(g._edges) == 1
assert n1.children == [n2.name]
assert n2.parents == [n1.name]
@pytest.fixture(scope="function")
def graph() -> PipelineGraph[PipelineNode, PipelineEdge]:
g: PipelineGraph[PipelineNode, PipelineEdge] = PipelineGraph()
n1 = PipelineNode("n1", {})
n2 = PipelineNode("n2", {})
g.add_node(n1)
g.add_node(n2)
edge = PipelineEdge(n1.name, n2.name, {"key": "value"})
g.add_edge(edge)
return g
def test_graph_roots(graph: PipelineGraph[PipelineNode, PipelineEdge]) -> None:
roots = graph.roots()
assert len(roots) == 1
assert roots[0].name == "n1"
def test_graph_next_edge(graph: PipelineGraph[PipelineNode, PipelineEdge]) -> None:
start = graph._nodes["n1"]
next_edges = graph.next_edges(start.name)
assert len(next_edges) == 1
next_edge = next_edges[0]
assert isinstance(next_edge, PipelineEdge)
assert next_edge.start == "n1"
assert next_edge.end == "n2"
def test_graph_prev_edge(graph: PipelineGraph[PipelineNode, PipelineEdge]) -> None:
start = graph._nodes["n2"]
next_edges = graph.previous_edges(start.name)
assert len(next_edges) == 1
next_edge = next_edges[0]
assert isinstance(next_edge, PipelineEdge)
assert next_edge.start == "n1"
assert next_edge.end == "n2"
def test_graph_contains(graph: PipelineGraph[PipelineNode, PipelineEdge]) -> None:
start = graph._nodes["n2"]
assert start in graph
def test_graph_is_cyclic(graph: PipelineGraph[PipelineNode, PipelineEdge]) -> None:
g: PipelineGraph[PipelineNode, PipelineEdge] = PipelineGraph()
n1 = PipelineNode("n1", {})
n2 = PipelineNode("n2", {})
g.add_node(n1)
g.add_node(n2)
edge = PipelineEdge(n1.name, n2.name, {})
g.add_edge(edge)
assert g.is_cyclic() is False
edge = PipelineEdge(n2.name, n1.name, {})
g.add_edge(edge)
assert g.is_cyclic() is True
def test_graph_set_node(graph: PipelineGraph[PipelineNode, PipelineEdge]) -> None:
new_node = PipelineNode("n1", {})
graph.set_node(new_node)
new_node_from_graph = graph.get_node_by_name("n1")
assert new_node_from_graph.parents == []
assert new_node_from_graph.children == ["n2"]
def test_graph_validate_edge_bad_node_name(
graph: PipelineGraph[PipelineNode, PipelineEdge],
) -> None:
with pytest.raises(KeyError):
graph.add_edge(PipelineEdge("n0", "n1", {}))
with pytest.raises(KeyError):
graph.add_edge(PipelineEdge("n1", "n12", {}))
def test_graph_validate_edge_no_parallel_edges(
graph: PipelineGraph[PipelineNode, PipelineEdge],
) -> None:
with pytest.raises(ValueError):
graph.add_edge(PipelineEdge("n1", "n2", {}))

View File

@@ -0,0 +1,15 @@
import pytest
from neo4j_graphrag.experimental.pipeline.stores import InMemoryStore
@pytest.mark.asyncio
async def test_memory_store() -> None:
store = InMemoryStore()
await store.add("key", "value")
res = await store.get("key")
assert res == "value"
with pytest.raises(KeyError):
await store.add("key", "value", overwrite=False)
assert store.all() == {"key": "value"}