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 [![PyPI version](https://img.shields.io/pypi/v/hother-cancelable?color=brightgreen)](https://pypi.org/project/hother-cancelable/) -[![Python Versions](https://img.shields.io/badge/python-3.12%20%7C%203.13%20%7C%203.14-blue)](https://pypi.org/project/hother-cancelable/) +[![Python Versions](https://img.shields.io/badge/python-3.13%20%7C%203.14-blue)](https://pypi.org/project/hother-cancelable/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Tests](https://github.com/hotherio/cancelable/actions/workflows/test.yaml/badge.svg?branch=main)](https://github.com/hotherio/cancelable/actions/workflows/test.yaml) [![Coverage](https://codecov.io/gh/hotherio/cancelable/branch/main/graph/badge.svg)](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()