import pytest import time from unittest.mock import MagicMock, patch from semantica.pipeline import ( PipelineBuilder, ExecutionEngine, FailureHandler, ParallelismManager, RetryPolicy, RetryStrategy, PipelineStatus, StepStatus, Task, ErrorSeverity, PipelineTemplateManager, PipelineTemplate, PipelineValidator, ResourceScheduler, ResourceType ) from semantica.pipeline.pipeline_builder import Pipeline, PipelineSerializer from semantica.pipeline.execution_engine import ExecutionResult pytestmark = pytest.mark.integration # --- Fixtures --- @pytest.fixture def pipeline_serializer(): return PipelineSerializer() @pytest.fixture def pipeline_builder(): return PipelineBuilder() @pytest.fixture def execution_engine(): return ExecutionEngine() @pytest.fixture def failure_handler(): return FailureHandler() @pytest.fixture def parallelism_manager(): return ParallelismManager(max_workers=2) @pytest.fixture def template_manager(): return PipelineTemplateManager() @pytest.fixture def validator(): return PipelineValidator() @pytest.fixture def resource_scheduler(): return ResourceScheduler() # --- Test PipelineBuilder --- def test_pipeline_serializer(pipeline_serializer, pipeline_builder): # Create a pipeline first pipeline = pipeline_builder.add_step("s1", "t1").build("test_pipe") # Test serialization serialized_json = pipeline_serializer.serialize_pipeline(pipeline, format="json") assert isinstance(serialized_json, str) assert "s1" in serialized_json serialized_dict = pipeline_serializer.serialize_pipeline(pipeline, format="dict") assert isinstance(serialized_dict, dict) assert serialized_dict["name"] == "test_pipe" # Test deserialization deserialized = pipeline_serializer.deserialize_pipeline(serialized_dict) assert deserialized.name == "test_pipe" assert len(deserialized.steps) == 1 assert deserialized.steps[0].name == "s1" # Test versioning versioned = pipeline_serializer.version_pipeline(pipeline, {"version": "2.0"}) assert versioned.metadata["version"] == "2.0" def test_pipeline_builder_add_step(pipeline_builder): pipeline_builder.add_step("step1", "type1", foo="bar") assert len(pipeline_builder.steps) == 1 step = pipeline_builder.steps[0] assert step.name == "step1" assert step.step_type == "type1" assert step.config["foo"] == "bar" def test_pipeline_builder_connect_steps(pipeline_builder): pipeline_builder.add_step("step1", "type1") pipeline_builder.add_step("step2", "type2") pipeline_builder.connect_steps("step1", "step2") step2 = pipeline_builder.get_step("step2") assert "step1" in step2.dependencies def test_pipeline_builder_build(pipeline_builder): pipeline_builder.add_step("step1", "type1") pipeline = pipeline_builder.build("test_pipeline") assert isinstance(pipeline, Pipeline) assert pipeline.name == "test_pipeline" assert len(pipeline.steps) == 1 def test_pipeline_builder_from_config(pipeline_builder): config = { "name": "config_pipeline", "steps": [ {"name": "s1", "type": "t1", "config": {"a": 1}}, {"name": "s2", "type": "t2", "config": {"dependencies": ["s1"]}} ] } pipeline = pipeline_builder.build_pipeline(config) assert pipeline.name == "config_pipeline" assert len(pipeline.steps) == 2 assert pipeline.steps[1].dependencies == ["s1"] # --- Test ExecutionEngine --- def test_execution_engine_execute_simple_pipeline(execution_engine, pipeline_builder): # Define a simple handler def step_handler(data, **config): return {**data, "processed": True} pipeline = ( pipeline_builder .add_step("step1", "type1", handler=step_handler) .build() ) input_data = {"raw": "data"} result = execution_engine.execute_pipeline(pipeline, input_data) assert isinstance(result, ExecutionResult) assert result.success is True assert result.output["processed"] is True assert result.metrics["steps_executed"] == 1 assert result.metrics["steps_failed"] == 0 def test_execution_engine_execute_pipeline_with_dependencies(execution_engine, pipeline_builder): def step1_handler(data, **config): return {**data, "step1": True} def step2_handler(data, **config): return {**data, "step2": True} pipeline = ( pipeline_builder .add_step("step1", "type1", handler=step1_handler) .add_step("step2", "type2", dependencies=["step1"], handler=step2_handler) .build() ) result = execution_engine.execute_pipeline(pipeline, {}) assert result.success is True assert result.output["step1"] is True assert result.output["step2"] is True def test_execution_engine_failure(execution_engine, pipeline_builder): def failing_handler(data, **config): raise ValueError("Oops") pipeline = ( pipeline_builder .add_step("step1", "type1", handler=failing_handler) .build() ) result = execution_engine.execute_pipeline(pipeline, {}) assert result.success is False assert result.metrics["steps_failed"] == 1 assert "Oops" in str(result.errors) # --- Test FailureHandler --- def test_failure_handler_classify_error(failure_handler): error = ValueError("Something wrong") classification = failure_handler.classify_error(error) assert classification["error_type"] == "ValueError" # ValueError maps to MEDIUM by default else block logic? No, check code: # default severity is MEDIUM. assert classification["severity"] == ErrorSeverity.MEDIUM timeout_error = RuntimeError("Connection timeout") classification = failure_handler.classify_error(timeout_error) assert classification["severity"] == ErrorSeverity.MEDIUM # Based on code analysis def test_failure_handler_retry_policy(failure_handler): policy = RetryPolicy(max_retries=2, strategy=RetryStrategy.FIXED, initial_delay=0.1) failure_handler.set_retry_policy("test_type", policy) retrieved_policy = failure_handler.get_retry_policy("test_type") assert retrieved_policy.max_retries == 2 assert retrieved_policy.strategy == RetryStrategy.FIXED def test_failure_handler_handle_step_failure(failure_handler, pipeline_builder): step = pipeline_builder.add_step("step1", "test_type").steps[0] error = ValueError("fail") # Mock retry policy to ensure it says "retry" policy = RetryPolicy(max_retries=1, strategy=RetryStrategy.FIXED, initial_delay=0.0) failure_handler.set_retry_policy("test_type", policy) # We need to mock _should_retry or ensure logic allows it. # _should_retry defaults to True if no retryable_errors list or if error is in list. # And we need to make sure we don't actually sleep long. result = failure_handler.handle_step_failure(step, error) assert result["retry"] is True assert result["retry_delay"] == 0.0 # --- Test ParallelismManager --- def test_parallelism_manager_execute_parallel(parallelism_manager): def task_func(x): return x * 2 tasks = [ Task("t1", task_func, args=(1,)), Task("t2", task_func, args=(2,)) ] results = parallelism_manager.execute_parallel(tasks) assert len(results) == 2 r1 = next(r for r in results if r.task_id == "t1") r2 = next(r for r in results if r.task_id == "t2") assert r1.success is True assert r1.result == 2 assert r2.success is True assert r2.result == 4 def test_parallelism_manager_identify_parallelizable_steps(parallelism_manager, pipeline_builder): # s1 -> s2 # s1 -> s3 # s2, s3 can be parallel pipeline = ( pipeline_builder .add_step("s1", "t1") .add_step("s2", "t2", dependencies=["s1"]) .add_step("s3", "t3", dependencies=["s1"]) .build() ) groups = parallelism_manager.identify_parallelizable_steps(pipeline) # Expected groups: [ [s1], [s2, s3] ] (or similar structure depending on level calculation) # Level 0: s1 # Level 1: s2, s3 assert len(groups) == 2 assert len(groups[0]) == 1 assert groups[0][0].name == "s1" assert len(groups[1]) == 2 names = {s.name for s in groups[1]} assert "s2" in names assert "s3" in names # --- End-to-End Pipeline Orchestration Test --- def test_end_to_end_pipeline_orchestration(pipeline_builder, execution_engine): # This simulates a complete pipeline orchestration workflow # Mocks for actual components to avoid file I/O and heavy processing file_ingestor_mock = MagicMock() file_ingestor_mock.ingest_file.return_value = MagicMock(path="dummy.pdf") document_parser_mock = MagicMock() document_parser_mock.parse_document.return_value = {"text": "Alice works at Tech Corp."} ner_extractor_mock = MagicMock() ner_entity = MagicMock() ner_entity.text = "Alice" ner_entity.label = "PERSON" ner_extractor_mock.extract_entities.return_value = [ner_entity] graph_builder_mock = MagicMock() graph_builder_mock.build.return_value = {"nodes": [{"id": "e0"}], "edges": []} # Handlers def ingest_handler(data, **config): files = data.get("files", []) if files: file_obj = file_ingestor_mock.ingest_file(files[0], read_content=True) return {**data, "file": file_obj} return data def parse_handler(data, **config): file_obj = data.get("file") if file_obj: parsed = document_parser_mock.parse_document(file_obj.path) text = parsed.get("text") return {**data, "text": text} return data def extract_handler(data, **config): text = data.get("text", "") entities = ner_extractor_mock.extract_entities(text) entity_dicts = [ {"id": f"e{i}", "name": e.text, "type": e.label} for i, e in enumerate(entities) ] return {**data, "entities": entity_dicts} def build_graph_handler(data, **config): entities = data.get("entities", []) graph = graph_builder_mock.build({"entities": entities}) return {**data, "graph": graph} # Build Pipeline pipeline = ( pipeline_builder .add_step("ingest", "ingest", handler=ingest_handler) .add_step("parse", "parse", dependencies=["ingest"], handler=parse_handler) .add_step("extract", "extract", dependencies=["parse"], handler=extract_handler) .add_step("build_graph", "build_graph", dependencies=["extract"], handler=build_graph_handler) .build() ) input_data = { "files": ["test.pdf"] } # Execute result = execution_engine.execute_pipeline(pipeline, input_data) assert result.success is True assert "graph" in result.output assert result.output["graph"]["nodes"][0]["id"] == "e0" # Verify failure handling configuration execution_engine.failure_handler.set_retry_policy( "extract", RetryPolicy(max_retries=3, backoff_factor=2.0, strategy=RetryStrategy.EXPONENTIAL) ) policy = execution_engine.failure_handler.get_retry_policy("extract") assert policy.max_retries == 3 # Verify parallelism identification parallelism = ParallelismManager(max_workers=4) groups = parallelism.identify_parallelizable_steps(pipeline) # This pipeline is sequential, so each group should have 1 step assert len(groups) == 4 assert len(groups[0]) == 1 # --- Test PipelineTemplateManager --- def test_template_manager_defaults(template_manager): templates = template_manager.list_templates() assert "document_processing" in templates assert "rag_pipeline" in templates assert "kg_construction" in templates def test_template_manager_get_template(template_manager): template = template_manager.get_template("document_processing") assert isinstance(template, PipelineTemplate) assert template.name == "document_processing" assert len(template.steps) > 0 def test_template_manager_create_pipeline(template_manager): builder = template_manager.create_pipeline_from_template( "document_processing", pipeline_config={"parallelism": 5}, ingest={"source": "custom_source"} ) pipeline = builder.build() assert pipeline.config["parallelism"] == 5 # Check overrides ingest_step = next(s for s in pipeline.steps if s.name == "ingest") assert ingest_step.config["source"] == "custom_source" def test_template_manager_register_template(template_manager): new_template = PipelineTemplate( name="custom_template", description="Custom Description", steps=[{"name": "step1", "type": "test"}] ) template_manager.register_template(new_template) assert "custom_template" in template_manager.list_templates() info = template_manager.get_template_info("custom_template") assert info["name"] == "custom_template" assert info["step_count"] == 1 # --- Test PipelineValidator --- def test_pipeline_validator_valid_structure(validator, pipeline_builder): pipeline = ( pipeline_builder .add_step("step1", "type1") .add_step("step2", "type2", dependencies=["step1"]) .build() ) result = validator.validate_pipeline(pipeline) assert result.valid is True assert len(result.errors) == 0 def test_pipeline_validator_missing_dependency(validator, pipeline_builder): pipeline = ( pipeline_builder .add_step("step1", "type1", dependencies=["missing_step"]) .build() ) result = validator.validate_pipeline(pipeline) assert result.valid is False assert any("missing step" in e for e in result.errors) def test_pipeline_validator_circular_dependency(validator, pipeline_builder): pipeline = ( pipeline_builder .add_step("step1", "type1", dependencies=["step2"]) .add_step("step2", "type2", dependencies=["step1"]) .build() ) result = validator.validate_pipeline(pipeline) # The validator might catch this in check_dependencies assert result.valid is False assert any("Circular dependency" in e for e in result.errors) def test_pipeline_validator_performance(validator, pipeline_builder): pipeline = pipeline_builder.add_step("s1", "t1").build() perf_result = validator.validate_performance(pipeline) assert perf_result["step_count"] == 1 # Should be no warnings for simple pipeline assert len(perf_result["warnings"]) == 0 # --- Test ResourceScheduler --- def test_resource_scheduler_initialization(resource_scheduler): usage = resource_scheduler.get_resource_usage() assert "cpu" in usage assert "memory" in usage assert usage["cpu"]["capacity"] > 0 def test_resource_scheduler_allocation(resource_scheduler, pipeline_builder): pipeline = pipeline_builder.add_step("s1", "t1").build("test_pipe") allocations = resource_scheduler.allocate_resources( pipeline, cpu_cores=1, memory_gb=0.1 ) assert "cpu" in allocations assert "memory" in allocations assert allocations["cpu"].amount == 1 assert allocations["memory"].amount == 0.1 # Check usage update usage = resource_scheduler.get_resource_usage() assert usage["cpu"]["allocated"] >= 1 def test_resource_scheduler_release(resource_scheduler, pipeline_builder): pipeline = pipeline_builder.add_step("s1", "t1").build("test_pipe") allocations = resource_scheduler.allocate_resources( pipeline, cpu_cores=1 ) assert allocations["cpu"].amount == 1 resource_scheduler.release_resources(allocations) usage = resource_scheduler.get_resource_usage() # It might not be exactly 0 if other things are running, but should be less than before release if isolated. # Since we are in a fresh test fixture, allocated should be 0. assert usage["cpu"]["allocated"] == 0 def test_resource_scheduler_optimization(resource_scheduler, pipeline_builder): pipeline = ( pipeline_builder .add_step("s1", "t1") .add_step("s2", "t2") .build("opt_pipe") ) optimization = resource_scheduler.optimize_resource_allocation(pipeline) recs = optimization["recommendations"] assert recs["parallel_execution"] is True # s1 and s2 are independent assert recs["cpu_cores"] >= 1