diff --git a/.github/workflow-config.yml b/.github/workflow-config.yml
index 6316221..72b7d7b 100644
--- a/.github/workflow-config.yml
+++ b/.github/workflow-config.yml
@@ -4,7 +4,7 @@
# Python configuration
python:
default-version: "3.13"
- supported-versions: ["3.11", "3.12", "3.13"]
+ supported-versions: ["3.13", "3.14"]
# Tool versions
tools:
diff --git a/.github/workflows/pull_request.yaml b/.github/workflows/pull_request.yaml
index e24cb7a..825894c 100644
--- a/.github/workflows/pull_request.yaml
+++ b/.github/workflows/pull_request.yaml
@@ -44,9 +44,10 @@ jobs:
validate:
name: Validate
runs-on: ubuntu-latest
+ if: github.event_name == 'pull_request'
strategy:
matrix:
- python-version: ["3.12", "3.13"]
+ python-version: ["3.13", "3.14"]
fail-fast: false
steps:
@@ -84,6 +85,7 @@ jobs:
validate-docs:
name: Validate Docs
runs-on: ubuntu-24.04
+ if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
@@ -96,5 +98,4 @@ jobs:
- name: Build docs
run: |
source .venv/bin/activate
- uvx hatch version dev
mkdocs build
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index cac8982..e99bfe5 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
- python-version: ["3.12", "3.13", "3.14"]
+ python-version: ["3.13", "3.14"]
fail-fast: false
steps:
diff --git a/README.md b/README.md
index d70586b..2081f6d 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,12 @@
# Cancelable
[](https://pypi.org/project/hother-cancelable/)
-[](https://pypi.org/project/hother-cancelable/)
+[](https://pypi.org/project/hother-cancelable/)
[](https://opensource.org/licenses/MIT)
[](https://github.com/hotherio/cancelable/actions/workflows/test.yaml)
[](https://codecov.io/gh/hotherio/cancelable)
-A comprehensive, production-ready async cancellation system for Python 3.12+ using anyio.
+A comprehensive, production-ready async cancellation system for Python 3.13+ using anyio.
📚 Documentation
diff --git a/pyproject.toml b/pyproject.toml
index 8f78fea..6221d36 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,6 @@ readme = "README.md"
authors = [{ name = "Alexandre Quemy", email="alexandre@hother.io" }]
classifiers = [
"Programming Language :: Python :: 3",
- "Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"License :: OSI Approved :: MIT License",
@@ -18,7 +17,7 @@ classifiers = [
"Framework :: AsyncIO",
"Framework :: AnyIO",
]
-requires-python = ">=3.12"
+requires-python = ">=3.13"
dependencies = [
"anyio>=4.9.0",
"psutil>=7.1.1",
@@ -108,7 +107,7 @@ publish-url = "https://hotherio.github.io/pypi-registry/"
[tool.ruff]
line-length = 128
-target-version = "py312"
+target-version = "py313"
[tool.ruff.lint]
extend-select = [
@@ -164,7 +163,7 @@ indent-style = "space"
docstring-code-format = false
[tool.basedpyright]
-pythonVersion = "3.12"
+pythonVersion = "3.13"
typeCheckingMode = "strict"
reportMissingTypeStubs = false
reportUnnecessaryIsInstance = false
@@ -173,13 +172,14 @@ reportMissingModuleSource = false
include = [
"src/hother/cancelable",
"tests",
- "examples",
]
venvPath = "."
venv = ".venv"
-# Don't error on unused functions in tests (e.g., fixtures)
+# Use different type checking modes for different parts of the codebase
executionEnvironments = [
- { root = "tests", reportUnusedFunction = false, reportPrivateImportUsage = false },
+ { root = "src/hother/cancelable", typeCheckingMode = "strict" }, # Production code: strict
+ { root = "tests", typeCheckingMode = "standard", reportUnusedFunction = false, reportPrivateImportUsage = false }, # Tests: standard
+ { root = "examples", typeCheckingMode = "basic" }, # Examples: basic (demo code)
]
[tool.pytest.ini_options]
diff --git a/src/hother/cancelable/types.py b/src/hother/cancelable/types.py
index 75d9945..f47abb8 100644
--- a/src/hother/cancelable/types.py
+++ b/src/hother/cancelable/types.py
@@ -11,6 +11,7 @@
from typing import TYPE_CHECKING, Any, ParamSpec, Protocol, TypeVar
if TYPE_CHECKING:
+ from hother.cancelable.core.cancelable import Cancelable
from hother.cancelable.core.models import OperationContext
# Type variables
@@ -99,7 +100,7 @@ def __call__(
)
-def ensure_cancelable(cancelable: "Cancelable | None") -> "Cancelable": # type: ignore[name-defined]
+def ensure_cancelable(cancelable: "Cancelable | None") -> "Cancelable":
"""
Type guard utility for injected cancelable parameters.
diff --git a/tests/unit/test_cancelable.py b/tests/unit/test_cancelable.py
index b54b7df..077240d 100644
--- a/tests/unit/test_cancelable.py
+++ b/tests/unit/test_cancelable.py
@@ -3,11 +3,12 @@
"""
from datetime import timedelta
+from typing import Any
import anyio
import pytest
-from hother.cancelable import Cancelable, CancelationReason, CancelationToken, OperationStatus, current_operation
+from hother.cancelable import Cancelable, CancelationReason, CancelationToken, OperationContext, OperationStatus, current_operation
from tests.conftest import assert_cancelled_within
@@ -263,7 +264,7 @@ async def test_progress_callbacks(self):
"""Test progress reporting and callbacks."""
messages = []
- def capture_progress(op_id, msg, meta):
+ def capture_progress(op_id: str, msg: Any, meta: dict[str, Any] | None) -> None:
messages.append((op_id, msg, meta))
cancelable = Cancelable(name="progress_test")
@@ -283,7 +284,7 @@ async def test_status_callbacks(self):
"""Test status change callbacks."""
events = []
- async def record_event(ctx):
+ async def record_event(ctx: OperationContext) -> None:
events.append((ctx.status.value, anyio.current_time()))
cancelable = Cancelable(name="status_test").on_start(record_event).on_complete(record_event)
@@ -300,7 +301,7 @@ async def test_cancel_callbacks(self):
"""Test cancelation callbacks."""
cancel_info = None
- def on_cancel(ctx):
+ def on_cancel(ctx: OperationContext) -> None:
nonlocal cancel_info
cancel_info = {
"reason": ctx.cancel_reason,
@@ -325,7 +326,7 @@ async def test_error_callbacks(self):
"""Test error callbacks."""
error_info = None
- async def on_error(ctx, error):
+ async def on_error(ctx: OperationContext, error: Exception) -> None:
nonlocal error_info
error_info = {
"type": type(error).__name__,
@@ -2531,4 +2532,71 @@ def condition():
assert cancel.context.cancel_reason == CancelationReason.CONDITION
assert source.triggered
+ @pytest.mark.anyio
+ async def test_parent_token_not_linkable_warning(self, caplog):
+ """Test warning when child has non-LinkedCancelationToken with parent.
+
+ Covers line 810: Warning when parent exists but token isn't LinkedCancelationToken.
+ """
+ import logging
+
+ caplog.set_level(logging.WARNING)
+
+ parent = Cancelable(name="parent")
+ regular_token = CancelationToken()
+ child = Cancelable.with_token(regular_token, name="child", parent=parent)
+
+ async with parent:
+ async with child:
+ pass # Line 810 should log warning
+
+ # Verify warning was logged
+ assert any("Cannot link to parent" in record.message for record in caplog.records)
+
+ @pytest.mark.anyio
+ async def test_combined_cancelables_not_linkable_warning(self, caplog):
+ """Test warning when combined cancelable has non-LinkedCancelationToken.
+
+ Covers line 828: Warning when combined source linking cannot occur.
+ """
+ import logging
+
+ caplog.set_level(logging.WARNING)
+
+ cancelable1 = Cancelable.with_timeout(5.0)
+ cancelable2 = Cancelable.with_timeout(10.0)
+
+ combined = cancelable1.combine(cancelable2)
+ # Manually replace token to trigger warning path
+ combined._token = CancelationToken()
+
+ async with combined:
+ pass # Line 828 should log warning
+
+ # Verify warning was logged
+ assert any("Cannot link to combined sources" in record.message for record in caplog.records)
+
+ @pytest.mark.anyio
+ async def test_base_exception_not_exception_type(self):
+ """Test handling of BaseException that is not Exception subclass.
+
+ Covers branch 723→734: False path where isinstance(exc_val, Exception) is False.
+ """
+ cancel = Cancelable(name="test")
+ error_callback_called = False
+
+ def on_error(ctx, error):
+ nonlocal error_callback_called
+ error_callback_called = True
+
+ cancel.on_error(on_error)
+
+ with pytest.raises(KeyboardInterrupt):
+ async with cancel:
+ raise KeyboardInterrupt() # BaseException but not Exception
+
+ # Verify error callback was NOT called (line 723 condition False)
+ assert not error_callback_called
+ assert cancel.context.status == OperationStatus.FAILED
+
diff --git a/tests/unit/test_decorators.py b/tests/unit/test_decorators.py
index 1b93560..6af6d87 100644
--- a/tests/unit/test_decorators.py
+++ b/tests/unit/test_decorators.py
@@ -999,3 +999,30 @@ async def task():
result = await task()
assert result == "completed"
assert progress_messages == ["step 1", "step 2"]
+
+
+class TestCreateCancelableFromConfig:
+ """Test internal _create_cancelable_from_config function."""
+
+ def test_create_cancelable_from_config_existing_cancelable(self):
+ """Test _create_cancelable_from_config returns existing cancelable.
+
+ Covers line 157: Early return when existing_cancelable is provided.
+ """
+ from hother.cancelable.utils.decorators import (
+ _create_cancelable_from_config,
+ _CancelableConfig,
+ )
+
+ existing = Cancelable(name="existing")
+
+ config = _CancelableConfig(
+ existing_cancelable=existing,
+ no_context=False, # Must be False to call _create_cancelable_from_config
+ )
+
+ result = _create_cancelable_from_config(config, "test_func", None)
+
+ # Should return the exact same instance (line 157)
+ assert result is existing
+ assert result.context.name == "existing"
diff --git a/tests/unit/test_registry.py b/tests/unit/test_registry.py
index 0658cea..6c4d0cf 100644
--- a/tests/unit/test_registry.py
+++ b/tests/unit/test_registry.py
@@ -3,11 +3,12 @@
"""
from datetime import UTC, datetime, timedelta
+from typing import Any
import anyio
import pytest
-from hother.cancelable import Cancelable, CancelationReason, OperationRegistry, OperationStatus
+from hother.cancelable import Cancelable, CancelationReason, OperationContext, OperationRegistry, OperationStatus
class TestOperationRegistry:
@@ -314,10 +315,10 @@ async def test_sync_get_operation(self, clean_registry):
await registry.register(cancelable)
# Call from thread
- result = [None]
- error = [None]
+ result: list[Cancelable | None] = [None]
+ error: list[Exception | None] = [None]
- def thread_func():
+ def thread_func() -> None:
try:
result[0] = registry.get_operation_sync(cancelable.context.id)
except Exception as e:
@@ -345,10 +346,10 @@ async def test_sync_list_operations(self, clean_registry):
ops.append(op)
# Call from thread
- result = [None]
- error = [None]
+ result: list[list[OperationContext] | None] = [None]
+ error: list[Exception | None] = [None]
- def thread_func():
+ def thread_func() -> None:
try:
result[0] = registry.list_operations_sync()
except Exception as e:
@@ -390,9 +391,9 @@ async def test_sync_list_operations_with_filters(self, clean_registry):
other.context.status = OperationStatus.COMPLETED
results = {}
- error = [None]
+ error: list[Exception | None] = [None]
- def thread_func():
+ def thread_func() -> None:
try:
results['status_filter'] = registry.list_operations_sync(status=OperationStatus.RUNNING)
@@ -445,10 +446,10 @@ async def test_sync_statistics_with_successful_operations(self, clean_registry):
await registry.unregister(op.context.id)
- result = [None]
- error = [None]
+ result: list[dict[str, Any] | None] = [None]
+ error: list[Exception | None] = [None]
- def thread_func():
+ def thread_func() -> None:
try:
result[0] = registry.get_statistics_sync()
except Exception as e:
@@ -480,10 +481,10 @@ async def test_sync_get_statistics(self, clean_registry):
await registry.unregister(op.context.id)
# Call from thread
- result = [None]
- error = [None]
+ result: list[dict[str, Any] | None] = [None]
+ error: list[Exception | None] = [None]
- def thread_func():
+ def thread_func() -> None:
try:
result[0] = registry.get_statistics_sync()
except Exception as e:
@@ -513,10 +514,10 @@ async def test_sync_get_history(self, clean_registry):
await registry.unregister(op.context.id)
# Call from thread
- result = [None]
- error = [None]
+ result: list[list[OperationContext] | None] = [None]
+ error: list[Exception | None] = [None]
- def thread_func():
+ def thread_func() -> None:
try:
result[0] = registry.get_history_sync(limit=2)
except Exception as e:
diff --git a/tests/unit/test_sources/test_composite.py b/tests/unit/test_sources/test_composite.py
index fa4beb4..91f4f2b 100644
--- a/tests/unit/test_sources/test_composite.py
+++ b/tests/unit/test_sources/test_composite.py
@@ -186,6 +186,102 @@ async def test_stop_monitoring_without_task_group(self):
# Should complete without error
+ @pytest.mark.anyio
+ async def test_monitored_source_stop_monitoring_exception(self):
+ """Test _MonitoredSource.stop_monitoring() finally block during exception.
+
+ Covers lines 71-75: Finally block that restores original trigger.
+ """
+
+ class FailingSource(CancelationSource):
+ def __init__(self):
+ super().__init__(CancelationReason.MANUAL, "failing")
+ self.stop_called = False
+
+ async def start_monitoring(self, scope):
+ self.scope = scope
+
+ async def stop_monitoring(self):
+ self.stop_called = True
+ raise RuntimeError("Intentional stop failure")
+
+ failing = FailingSource()
+ composite = CompositeSource([failing])
+
+ scope = anyio.CancelScope()
+ await composite.start_monitoring(scope)
+
+ # Stop should catch exception and not propagate it (lines 71-75 finally block executes)
+ await composite.stop_monitoring() # Should not raise
+
+ # Verify stop_monitoring was called (exception was encountered)
+ assert failing.stop_called
+
+ @pytest.mark.anyio
+ async def test_monitored_source_attribute_delegation(self):
+ """Test _MonitoredSource.__getattr__() delegates to wrapped source.
+
+ Covers line 79: Attribute delegation to wrapped source.
+ """
+ timeout_source = TimeoutSource(5.0)
+ timeout_source.custom_attr = "test_value" # Add custom attribute
+
+ # Create monitored wrapper (accessing private class for testing)
+ from hother.cancelable.sources.composite import _MonitoredSource
+
+ parent = CompositeSource([TimeoutSource(1.0)])
+ monitored = _MonitoredSource(timeout_source, parent)
+
+ # Access attribute through wrapper - should delegate via __getattr__ (line 79)
+ assert monitored.custom_attr == "test_value"
+ assert monitored.name == "TimeoutSource"
+ assert monitored.reason == CancelationReason.TIMEOUT
+
+ @pytest.mark.anyio
+ async def test_monitored_source_multiple_triggers(self):
+ """Test _MonitoredSource handles multiple trigger_cancelation calls.
+
+ Covers branch 48→54: Second trigger call should skip if block but still call original.
+ """
+
+ class ManualSource(CancelationSource):
+ def __init__(self):
+ super().__init__(CancelationReason.MANUAL, "manual")
+ self.trigger_count = 0
+
+ async def start_monitoring(self, scope):
+ self.scope = scope
+
+ async def stop_monitoring(self):
+ pass
+
+ async def trigger_cancelation(self, message=None):
+ self.trigger_count += 1
+ if self.scope:
+ self.scope.cancel()
+
+ from hother.cancelable.sources.composite import _MonitoredSource
+
+ manual = ManualSource()
+ composite = CompositeSource([TimeoutSource(1.0)])
+ monitored = _MonitoredSource(manual, composite)
+
+ # Set up scope
+ await monitored.start_monitoring(anyio.CancelScope())
+
+ # First trigger - line 48 condition True
+ await monitored.trigger_cancelation("First")
+ assert monitored._triggered is True
+ assert composite.triggered_source is manual
+ assert manual.trigger_count == 1
+
+ # Second trigger - line 48 condition False, still calls original (line 54)
+ await monitored.trigger_cancelation("Second")
+ assert manual.trigger_count == 2 # Original trigger called both times
+
+ # Clean up
+ await monitored.stop_monitoring()
+
class TestAnyOfSource:
"""Test AnyOfSource (alias for CompositeSource)."""
@@ -351,3 +447,61 @@ async def stop_monitoring(self):
# Stop should not raise, even though one source fails
await all_of.stop_monitoring()
+
+ @pytest.mark.anyio
+ async def test_all_of_monitored_source_stop_monitoring_exception(self):
+ """Test _AllOfMonitoredSource.stop_monitoring() finally block during exception.
+
+ Covers lines 250-254: Finally block restores trigger in AllOfSource wrapper.
+ """
+
+ class FailingSource(CancelationSource):
+ def __init__(self):
+ super().__init__(CancelationReason.MANUAL, "failing")
+ self.stop_called = False
+
+ async def start_monitoring(self, scope):
+ self.scope = scope
+
+ async def stop_monitoring(self):
+ self.stop_called = True
+ raise RuntimeError("Intentional stop failure")
+
+ from hother.cancelable.sources.composite import _AllOfMonitoredSource
+
+ failing = FailingSource()
+ original_trigger = failing.trigger_cancelation
+ all_of = AllOfSource([TimeoutSource(1.0)])
+ monitored = _AllOfMonitoredSource(failing, all_of)
+
+ scope = anyio.CancelScope()
+ await monitored.start_monitoring(scope)
+
+ # Stop raises exception, but finally block still executes (lines 250-254)
+ with pytest.raises(RuntimeError, match="Intentional stop failure"):
+ await monitored.stop_monitoring()
+
+ # Verify stop_monitoring was called (exception was encountered)
+ assert failing.stop_called
+ # Verify trigger was restored in finally block (line 254)
+ assert failing.trigger_cancelation == original_trigger
+
+ @pytest.mark.anyio
+ async def test_all_of_monitored_source_attribute_delegation(self):
+ """Test _AllOfMonitoredSource.__getattr__() delegates to wrapped source.
+
+ Covers line 258: Attribute delegation in AllOfSource wrapper.
+ """
+ timeout_source = TimeoutSource(5.0)
+ timeout_source.custom_attr = "all_of_test"
+
+ # Create AllOfSource and access private wrapper class
+ from hother.cancelable.sources.composite import _AllOfMonitoredSource
+
+ all_of = AllOfSource([timeout_source])
+ monitored = _AllOfMonitoredSource(timeout_source, all_of)
+
+ # Access attributes through wrapper - tests __getattr__ delegation (line 258)
+ assert monitored.custom_attr == "all_of_test"
+ assert monitored.name == "TimeoutSource"
+ assert monitored.reason == CancelationReason.TIMEOUT
diff --git a/tests/unit/test_thread_cancelation.py b/tests/unit/test_thread_cancelation.py
index 3250c99..edc8ccb 100644
--- a/tests/unit/test_thread_cancelation.py
+++ b/tests/unit/test_thread_cancelation.py
@@ -46,16 +46,16 @@ async def bridge():
class TestAnyioBridge:
"""Test cases for AnyioBridge thread-safe communication."""
- async def test_bridge_singleton(self, bridge):
+ async def test_bridge_singleton(self, bridge: AnyioBridge) -> None:
"""Test that AnyioBridge is a singleton."""
bridge2 = AnyioBridge.get_instance()
assert bridge is bridge2
- async def test_bridge_starts_successfully(self, bridge):
+ async def test_bridge_starts_successfully(self, bridge: AnyioBridge) -> None:
"""Test that bridge starts without errors."""
assert bridge.is_started
- async def test_bridge_executes_callback_from_thread(self, bridge):
+ async def test_bridge_executes_callback_from_thread(self, bridge: AnyioBridge) -> None:
"""Test that bridge executes callbacks scheduled from threads."""
result = []
@@ -75,7 +75,7 @@ def run_in_thread():
assert result == ["executed"]
- async def test_bridge_executes_async_callback_from_thread(self, bridge):
+ async def test_bridge_executes_async_callback_from_thread(self, bridge: AnyioBridge) -> None:
"""Test that bridge executes async callbacks scheduled from threads."""
result = []
@@ -96,7 +96,7 @@ def run_in_thread():
assert result == ["async_executed"]
- async def test_bridge_handles_multiple_callbacks(self, bridge):
+ async def test_bridge_handles_multiple_callbacks(self, bridge: AnyioBridge) -> None:
"""Test that bridge handles multiple callbacks correctly."""
results = []
@@ -119,7 +119,7 @@ def run_in_thread():
class TestTokenCancelSync:
"""Test cases for CancelationToken.cancel_sync() method."""
- async def test_cancel_sync_from_thread(self, bridge):
+ async def test_cancel_sync_from_thread(self, bridge: AnyioBridge) -> None:
"""Test that cancel_sync() can be called from a thread."""
token = CancelationToken()
@@ -142,7 +142,7 @@ def cancel_in_thread():
assert token.reason == CancelationReason.MANUAL
assert token.message == "Cancelled from thread"
- async def test_cancel_sync_sets_event(self, bridge):
+ async def test_cancel_sync_sets_event(self, bridge: AnyioBridge) -> None:
"""Test that cancel_sync() sets the anyio event via bridge."""
token = CancelationToken()
@@ -170,7 +170,7 @@ def cancel_in_thread():
tg.cancel_scope.cancel()
- async def test_cancel_sync_triggers_callbacks(self, bridge):
+ async def test_cancel_sync_triggers_callbacks(self, bridge: AnyioBridge) -> None:
"""Test that cancel_sync() triggers registered callbacks."""
token = CancelationToken()
callback_executed = []
@@ -195,7 +195,7 @@ def cancel_in_thread():
assert callback_executed == [True]
- async def test_cancel_sync_idempotent(self, bridge):
+ async def test_cancel_sync_idempotent(self, bridge: AnyioBridge) -> None:
"""Test that cancel_sync() is idempotent."""
token = CancelationToken()
@@ -214,7 +214,7 @@ def cancel_in_thread():
assert token.is_cancelled
- async def test_cancel_sync_thread_safe(self, bridge):
+ async def test_cancel_sync_thread_safe(self, bridge: AnyioBridge) -> None:
"""Test that cancel_sync() is thread-safe with multiple threads."""
token = CancelationToken()
success_count = []
@@ -242,7 +242,7 @@ def cancel_in_thread():
class TestPynputScenario:
"""Test cases simulating pynput-like keyboard listener scenarios."""
- async def test_keyboard_listener_simulation(self, bridge):
+ async def test_keyboard_listener_simulation(self, bridge: AnyioBridge) -> None:
"""Test simulated keyboard listener cancelling operation."""
token = CancelationToken()
@@ -290,7 +290,7 @@ def join(self):
assert token.reason == CancelationReason.MANUAL
assert "SPACE" in token.message
- async def test_streaming_with_keyboard_cancelation(self, bridge):
+ async def test_streaming_with_keyboard_cancelation(self, bridge: AnyioBridge) -> None:
"""Test LLM-like streaming with keyboard cancelation."""
token = CancelationToken()
chunks_processed = []
@@ -330,7 +330,7 @@ async def stream_chunks():
class TestBridgeErrorHandling:
"""Test error handling in bridge and cancel_sync."""
- async def test_bridge_handles_callback_errors(self, bridge):
+ async def test_bridge_handles_callback_errors(self, bridge: AnyioBridge) -> None:
"""Test that bridge handles errors in callbacks gracefully."""
def failing_callback():
@@ -350,7 +350,7 @@ def successful_callback():
# Successful callback should still execute
assert result == ["success"]
- async def test_cancel_sync_with_failing_callback(self, bridge):
+ async def test_cancel_sync_with_failing_callback(self, bridge: AnyioBridge) -> None:
"""Test that cancel_sync continues even if a callback fails."""
token = CancelationToken()
successful_callbacks = []
diff --git a/tests/unit/test_threading_bridge.py b/tests/unit/test_threading_bridge.py
index fb7e2a3..a822c7f 100644
--- a/tests/unit/test_threading_bridge.py
+++ b/tests/unit/test_threading_bridge.py
@@ -3,11 +3,13 @@
"""
import threading
+from typing import Any
import anyio
import pytest
-from hother.cancelable import Cancelable, CancelationReason, OperationRegistry, OperationStatus, ThreadSafeRegistry
+from hother.cancelable import Cancelable, CancelationReason, OperationContext, OperationRegistry, OperationStatus, ThreadSafeRegistry
+from hother.cancelable.utils.anyio_bridge import AnyioBridge
@pytest.fixture
@@ -27,7 +29,7 @@ class TestThreadSafeRegistry:
"""Test ThreadSafeRegistry functionality."""
@pytest.mark.anyio
- async def test_singleton(self):
+ async def test_singleton(self) -> None:
"""Test ThreadSafeRegistry singleton."""
registry1 = ThreadSafeRegistry.get_instance()
registry2 = ThreadSafeRegistry.get_instance()
@@ -35,7 +37,7 @@ async def test_singleton(self):
assert registry1 is registry2
@pytest.mark.anyio
- async def test_singleton_thread_safety(self, reset_singleton):
+ async def test_singleton_thread_safety(self, reset_singleton: None) -> None:
"""Test that singleton works correctly under concurrent access."""
# This test forces the race condition where multiple threads
# pass the outer check but only one creates the instance
@@ -75,7 +77,7 @@ def create_instance(barrier):
assert isinstance(instances[0], ThreadSafeRegistry)
@pytest.mark.anyio
- async def test_singleton_double_check_locking(self, reset_singleton):
+ async def test_singleton_double_check_locking(self, reset_singleton: None) -> None:
"""Test the inner check of double-check locking pattern."""
# where a thread finds the instance already created inside the lock
@@ -134,7 +136,7 @@ def thread2_func():
assert isinstance(instances[0], ThreadSafeRegistry)
@pytest.mark.anyio
- async def test_get_operation_from_thread(self, clean_registry):
+ async def test_get_operation_from_thread(self, clean_registry: OperationRegistry) -> None:
"""Test getting operation from thread."""
# Setup operation in async context
cancelable = Cancelable(name="test_op")
@@ -142,10 +144,10 @@ async def test_get_operation_from_thread(self, clean_registry):
# Access from thread
thread_registry = ThreadSafeRegistry()
- result = [None]
- error = [None]
+ result: list[Cancelable | None] = [None]
+ error: list[Exception | None] = [None]
- def thread_func():
+ def thread_func() -> None:
try:
result[0] = thread_registry.get_operation(cancelable.context.id)
except Exception as e:
@@ -159,7 +161,7 @@ def thread_func():
assert result[0] is cancelable
@pytest.mark.anyio
- async def test_list_operations_from_thread(self, clean_registry):
+ async def test_list_operations_from_thread(self, clean_registry: OperationRegistry) -> None:
"""Test listing operations from thread."""
# Create operations in async context
ops = []
@@ -170,10 +172,10 @@ async def test_list_operations_from_thread(self, clean_registry):
# Access from thread
thread_registry = ThreadSafeRegistry()
- result = [None]
- error = [None]
+ result: list[list[OperationContext] | None] = [None]
+ error: list[Exception | None] = [None]
- def thread_func():
+ def thread_func() -> None:
try:
result[0] = thread_registry.list_operations()
except Exception as e:
@@ -187,7 +189,7 @@ def thread_func():
assert len(result[0]) == 5
@pytest.mark.anyio
- async def test_list_operations_with_filter(self, clean_registry):
+ async def test_list_operations_with_filter(self, clean_registry: OperationRegistry) -> None:
"""Test filtering operations from thread."""
# Create operations with different statuses
for i in range(5):
@@ -202,9 +204,9 @@ async def test_list_operations_with_filter(self, clean_registry):
# Access from thread with filter
thread_registry = ThreadSafeRegistry()
- result = [None]
+ result: list[list[OperationContext] | None] = [None]
- def thread_func():
+ def thread_func() -> None:
result[0] = thread_registry.list_operations(status=OperationStatus.RUNNING)
thread = threading.Thread(target=thread_func)
@@ -216,7 +218,7 @@ def thread_func():
assert all("running" in op.name for op in result[0])
@pytest.mark.anyio
- async def test_get_statistics_from_thread(self, clean_registry):
+ async def test_get_statistics_from_thread(self, clean_registry: OperationRegistry) -> None:
"""Test getting statistics from thread."""
# Create and complete some operations
for i in range(3):
@@ -227,9 +229,9 @@ async def test_get_statistics_from_thread(self, clean_registry):
# Access from thread
thread_registry = ThreadSafeRegistry()
- result = [None]
+ result: list[dict[str, Any] | None] = [None]
- def thread_func():
+ def thread_func() -> None:
result[0] = thread_registry.get_statistics()
thread = threading.Thread(target=thread_func)
@@ -243,7 +245,7 @@ def thread_func():
assert stats["history_size"] == 3
@pytest.mark.anyio
- async def test_get_history_from_thread(self, clean_registry):
+ async def test_get_history_from_thread(self, clean_registry: OperationRegistry) -> None:
"""Test getting history from thread."""
# Create and complete operations
for i in range(5):
@@ -254,9 +256,9 @@ async def test_get_history_from_thread(self, clean_registry):
# Access from thread with limit
thread_registry = ThreadSafeRegistry()
- result = [None]
+ result: list[list[OperationContext] | None] = [None]
- def thread_func():
+ def thread_func() -> None:
result[0] = thread_registry.get_history(limit=3)
thread = threading.Thread(target=thread_func)
@@ -266,7 +268,7 @@ def thread_func():
assert len(result[0]) == 3
@pytest.mark.anyio
- async def test_concurrent_access_from_multiple_threads(self, clean_registry):
+ async def test_concurrent_access_from_multiple_threads(self, clean_registry: OperationRegistry) -> None:
"""Test concurrent access from multiple threads using ThreadSafeRegistry."""
# Create operations
for i in range(10):
@@ -301,9 +303,8 @@ def thread_func(thread_id):
assert len(results) == 500 # 10 threads * 50 iterations
@pytest.mark.anyio
- async def test_cancel_operation_from_thread(self, clean_registry):
+ async def test_cancel_operation_from_thread(self, clean_registry: OperationRegistry) -> None:
"""Test cancelling operation from thread."""
- from hother.cancelable.utils.anyio_bridge import AnyioBridge
# Start the bridge
bridge = AnyioBridge.get_instance()
@@ -345,7 +346,7 @@ def thread_func():
tg.cancel_scope.cancel()
@pytest.mark.anyio
- async def test_wrapper_vs_direct_consistency(self, clean_registry):
+ async def test_wrapper_vs_direct_consistency(self, clean_registry: OperationRegistry) -> None:
"""Test that ThreadSafeRegistry returns same data as direct sync methods."""
# Create some operations
for i in range(5):
@@ -373,9 +374,8 @@ def thread_func():
thread.join(timeout=1.0)
@pytest.mark.anyio
- async def test_cancel_all_from_thread(self, clean_registry):
+ async def test_cancel_all_from_thread(self, clean_registry: OperationRegistry) -> None:
"""Test cancelling all operations from thread."""
- from hother.cancelable.utils.anyio_bridge import AnyioBridge
# Start the bridge
bridge = AnyioBridge.get_instance()