Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflow-config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 3 additions & 2 deletions .github/workflows/pull_request.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -96,5 +98,4 @@ jobs:
- name: Build docs
run: |
source .venv/bin/activate
uvx hatch version dev
mkdocs build
2 changes: 1 addition & 1 deletion .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

<div align="center">
<a href="https://hotherio.github.io/cancelable/">📚 Documentation</a>
Expand Down
14 changes: 7 additions & 7 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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
Expand All @@ -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]
Expand Down
3 changes: 2 additions & 1 deletion src/hother/cancelable/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
78 changes: 73 additions & 5 deletions tests/unit/test_cancelable.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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")
Expand All @@ -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)
Expand All @@ -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,
Expand All @@ -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__,
Expand Down Expand Up @@ -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


27 changes: 27 additions & 0 deletions tests/unit/test_decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
37 changes: 19 additions & 18 deletions tests/unit/test_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
Loading