Spaces:
Sleeping
Sleeping
| """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) | |