logic-engine / tests /test_pipeline_hooks.py
ghostdrive1's picture
Upload folder using huggingface_hub
116524e verified
Raw
History Blame Contribute Delete
12.9 kB
"""Tests for PipelineHook, CancellationToken, and cancel_token_var."""
from __future__ import annotations
import threading
import time
import pytest
from pipeline import (
CancellationToken,
Pipeline,
PipelineCancelled,
PipelineHook,
SampleResult,
StepContext,
cancel_token_var,
)
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
class PassthroughStep:
requires = frozenset()
provides = frozenset()
def __call__(self, ctx: StepContext) -> StepContext:
return ctx
class SlowStep:
"""Step that sleeps briefly — useful for cancel-during-run tests."""
requires = frozenset()
provides = frozenset()
def __init__(self, delay: float = 0.1):
self._delay = delay
def __call__(self, ctx: StepContext) -> StepContext:
time.sleep(self._delay)
return ctx
class FailingStep:
requires = frozenset()
provides = frozenset()
def __call__(self, ctx: StepContext) -> StepContext:
raise RuntimeError("boom")
class RecordingHook:
"""Collects (event, step_name) tuples for assertion."""
def __init__(self) -> None:
self.events: list[tuple[str, str]] = []
def before_step(self, step_name: str, ctx: StepContext) -> None:
self.events.append(("before", step_name))
def after_step(self, step_name: str, ctx: StepContext) -> None:
self.events.append(("after", step_name))
class BrokenHook:
"""Hook that raises on every call."""
def before_step(self, step_name: str, ctx: StepContext) -> None:
raise ValueError("hook broken before")
def after_step(self, step_name: str, ctx: StepContext) -> None:
raise ValueError("hook broken after")
# ==================================================================
# PipelineHook tests
# ==================================================================
class TestPipelineHooks:
def test_hooks_fire_before_and_after_each_step(self):
hook = RecordingHook()
pipe = Pipeline([PassthroughStep(), PassthroughStep()], hooks=[hook])
pipe.run([StepContext(sample=1)])
assert hook.events == [
("before", "PassthroughStep"),
("after", "PassthroughStep"),
("before", "PassthroughStep"),
("after", "PassthroughStep"),
]
def test_hooks_fire_per_sample(self):
hook = RecordingHook()
pipe = Pipeline([PassthroughStep()], hooks=[hook])
pipe.run([StepContext(sample=1), StepContext(sample=2)])
assert len(hook.events) == 4 # 2 samples × (before + after)
def test_multiple_hooks(self):
hook1 = RecordingHook()
hook2 = RecordingHook()
pipe = Pipeline([PassthroughStep()], hooks=[hook1, hook2])
pipe.run([StepContext(sample=1)])
assert hook1.events == [
("before", "PassthroughStep"),
("after", "PassthroughStep"),
]
assert hook2.events == [
("before", "PassthroughStep"),
("after", "PassthroughStep"),
]
def test_broken_hook_does_not_kill_pipeline(self):
broken = BrokenHook()
recorder = RecordingHook()
pipe = Pipeline([PassthroughStep()], hooks=[broken, recorder])
results = pipe.run([StepContext(sample=1)])
# Pipeline still succeeds
assert len(results) == 1
assert results[0].error is None
assert results[0].output is not None
# Second hook still fired
assert len(recorder.events) == 2
def test_hooks_receive_correct_step_name(self):
hook = RecordingHook()
pipe = Pipeline([SlowStep(delay=0)], hooks=[hook])
pipe.run([StepContext(sample=1)])
assert hook.events[0] == ("before", "SlowStep")
assert hook.events[1] == ("after", "SlowStep")
def test_after_hook_not_called_on_step_error(self):
hook = RecordingHook()
pipe = Pipeline([FailingStep()], hooks=[hook])
results = pipe.run([StepContext(sample=1)])
# before fires, step raises, after does NOT fire for that step
assert hook.events == [("before", "FailingStep")]
assert isinstance(results[0].error, RuntimeError)
def test_no_hooks_is_backward_compatible(self):
pipe = Pipeline([PassthroughStep()])
results = pipe.run([StepContext(sample=1)])
assert len(results) == 1
assert results[0].error is None
def test_hook_satisfies_protocol(self):
hook = RecordingHook()
assert isinstance(hook, PipelineHook)
# ==================================================================
# CancellationToken tests
# ==================================================================
class TestCancellationToken:
def test_not_cancelled_initially(self):
token = CancellationToken()
assert not token.is_cancelled
def test_cancel_sets_flag(self):
token = CancellationToken()
token.cancel()
assert token.is_cancelled
def test_cancel_is_idempotent(self):
token = CancellationToken()
token.cancel()
token.cancel()
assert token.is_cancelled
def test_cancel_is_thread_safe(self):
token = CancellationToken()
def cancel_from_thread():
time.sleep(0.01)
token.cancel()
t = threading.Thread(target=cancel_from_thread)
t.start()
t.join()
assert token.is_cancelled
# ==================================================================
# Pipeline cancellation tests
# ==================================================================
class TestPipelineCancellation:
def test_pre_cancelled_token_cancels_immediately(self):
token = CancellationToken()
token.cancel()
pipe = Pipeline([PassthroughStep(), PassthroughStep()])
results = pipe.run([StepContext(sample=1)], cancel_token=token)
assert len(results) == 1
assert isinstance(results[0].error, PipelineCancelled)
assert results[0].failed_at == "PassthroughStep"
assert results[0].output is None
def test_cancel_between_steps(self):
"""Cancel after the first step; second step should not run."""
call_count = 0
class CountingStep:
requires = frozenset()
provides = frozenset()
def __call__(self, ctx: StepContext) -> StepContext:
nonlocal call_count
call_count += 1
return ctx
token = CancellationToken()
class CancelAfterFirstHook:
def before_step(self, step_name, ctx):
pass
def after_step(self, step_name, ctx):
# Cancel after the first step completes
token.cancel()
pipe = Pipeline(
[CountingStep(), CountingStep()],
hooks=[CancelAfterFirstHook()],
)
results = pipe.run([StepContext(sample=1)], cancel_token=token)
assert call_count == 1 # Only first step ran
assert isinstance(results[0].error, PipelineCancelled)
def test_cancel_stops_remaining_samples(self):
token = CancellationToken()
samples_started = []
class TrackingStep:
requires = frozenset()
provides = frozenset()
def __call__(self, ctx: StepContext) -> StepContext:
samples_started.append(ctx.sample)
if ctx.sample == 0:
token.cancel()
return ctx
pipe = Pipeline([TrackingStep()])
results = pipe.run(
[StepContext(sample=i) for i in range(5)],
cancel_token=token,
)
# First sample ran (triggered cancel), rest should be cancelled
assert 0 in samples_started
cancelled = [r for r in results if isinstance(r.error, PipelineCancelled)]
assert len(cancelled) >= 1 # At least some were cancelled
def test_no_token_runs_normally(self):
pipe = Pipeline([PassthroughStep()])
results = pipe.run([StepContext(sample=1)], cancel_token=None)
assert len(results) == 1
assert results[0].error is None
def test_on_sample_done_fires_on_cancellation(self):
token = CancellationToken()
token.cancel()
cb_results = []
pipe = Pipeline([PassthroughStep()])
pipe.run(
[StepContext(sample=1)],
cancel_token=token,
on_sample_done=lambda r: cb_results.append(r),
)
assert len(cb_results) == 1
assert isinstance(cb_results[0].error, PipelineCancelled)
def test_cancelled_result_has_correct_shape(self):
token = CancellationToken()
token.cancel()
pipe = Pipeline([PassthroughStep()])
results = pipe.run([StepContext(sample="x")], cancel_token=token)
r = results[0]
assert r.sample == "x"
assert r.output is None
assert isinstance(r.error, PipelineCancelled)
assert r.failed_at is not None
# ==================================================================
# cancel_token_var contextvar tests
# ==================================================================
class TestCancelTokenVar:
def test_contextvar_is_none_by_default(self):
assert cancel_token_var.get(None) is None
def test_contextvar_set_during_pipeline_run(self):
"""Steps can read the cancel_token_var set by the pipeline."""
observed_tokens = []
class TokenReadingStep:
requires = frozenset()
provides = frozenset()
def __call__(self, ctx: StepContext) -> StepContext:
observed_tokens.append(cancel_token_var.get(None))
return ctx
token = CancellationToken()
pipe = Pipeline([TokenReadingStep()])
pipe.run([StepContext(sample=1)], cancel_token=token)
assert len(observed_tokens) == 1
assert observed_tokens[0] is token
def test_contextvar_is_none_without_cancel_token(self):
"""When no cancel_token is passed, the contextvar is None inside steps."""
observed_tokens = []
class TokenReadingStep:
requires = frozenset()
provides = frozenset()
def __call__(self, ctx: StepContext) -> StepContext:
observed_tokens.append(cancel_token_var.get(None))
return ctx
pipe = Pipeline([TokenReadingStep()])
pipe.run([StepContext(sample=1)])
assert len(observed_tokens) == 1
assert observed_tokens[0] is None
def test_contextvar_reset_after_run(self):
"""The contextvar is reset after run() completes."""
token = CancellationToken()
pipe = Pipeline([PassthroughStep()])
pipe.run([StepContext(sample=1)], cancel_token=token)
# After run, the contextvar should be back to default
assert cancel_token_var.get(None) is None
def test_contextvar_visible_across_multiple_steps(self):
"""All steps in the same pipeline run see the same token."""
observed_tokens = []
class TokenReadingStep:
requires = frozenset()
provides = frozenset()
def __call__(self, ctx: StepContext) -> StepContext:
observed_tokens.append(cancel_token_var.get(None))
return ctx
token = CancellationToken()
pipe = Pipeline([TokenReadingStep(), TokenReadingStep(), TokenReadingStep()])
pipe.run([StepContext(sample=1)], cancel_token=token)
assert len(observed_tokens) == 3
assert all(t is token for t in observed_tokens)
def test_contextvar_per_sample(self):
"""Each sample in the same run sees the same token."""
observed_tokens = []
class TokenReadingStep:
requires = frozenset()
provides = frozenset()
def __call__(self, ctx: StepContext) -> StepContext:
observed_tokens.append(cancel_token_var.get(None))
return ctx
token = CancellationToken()
pipe = Pipeline([TokenReadingStep()])
pipe.run(
[StepContext(sample=i) for i in range(3)],
cancel_token=token,
)
assert len(observed_tokens) == 3
assert all(t is token for t in observed_tokens)