Skip to content
Merged
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
37 changes: 4 additions & 33 deletions .github/workflows/pull_request.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,45 +8,14 @@ on:
branches:
- main
- develop
pull_request_target: # For PR title validation
types:
- opened
- edited
- synchronize

jobs:
check-pr-title:
name: Check PR Title
runs-on: ubuntu-latest
if: github.event_name == 'pull_request_target'
permissions:
pull-requests: read
steps:
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
types: |
feat
fix
docs
style
refactor
perf
test
build
ci
chore
revert
requireScope: false
validateSingleCommit: true # also check lone commit if PR has only one

validate:
name: Validate
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.12", "3.13"]
python-version: ["3.13", "3.14"]
fail-fast: false

steps:
Expand All @@ -59,11 +28,13 @@ jobs:
dependency-groups: all

- name: Install lefthook
shell: bash
run: |
source .venv/bin/activate
lefthook install

- name: Lint
shell: bash
run: |
source .venv/bin/activate
lefthook run pre-commit --all-files
Expand Down Expand Up @@ -94,7 +65,7 @@ jobs:
dependency-groups: doc

- name: Build docs
shell: bash
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
2 changes: 1 addition & 1 deletion examples/02_advanced/10_shared_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
from typing import Any

import anyio
from hother.cancelable import Cancelable, with_cancelable, current_operation

from hother.cancelable import Cancelable, current_operation, with_cancelable

# Example 1: Shared context with current_operation()
# ====================================================
Expand Down Expand Up @@ -118,7 +118,7 @@
print("\n=== Example 2: Shared Context with Injection ===\n")

async with pipeline_cancel:
try:

Check failure on line 121 in examples/02_advanced/10_shared_context.py

View workflow job for this annotation

GitHub Actions / Validate (3.12)

Argument missing for parameter "cancelable" (reportCallIssue)

Check failure on line 121 in examples/02_advanced/10_shared_context.py

View workflow job for this annotation

GitHub Actions / Validate (3.12)

Type of "data" is unknown (reportUnknownVariableType)

Check failure on line 121 in examples/02_advanced/10_shared_context.py

View workflow job for this annotation

GitHub Actions / Validate (3.13)

Argument missing for parameter "cancelable" (reportCallIssue)

Check failure on line 121 in examples/02_advanced/10_shared_context.py

View workflow job for this annotation

GitHub Actions / Validate (3.13)

Type of "data" is unknown (reportUnknownVariableType)

Check failure on line 121 in examples/02_advanced/10_shared_context.py

View workflow job for this annotation

GitHub Actions / Validate (3.14)

Argument missing for parameter "cancelable" (reportCallIssue)

Check failure on line 121 in examples/02_advanced/10_shared_context.py

View workflow job for this annotation

GitHub Actions / Validate (3.14)

Type of "data" is unknown (reportUnknownVariableType)

Check failure on line 121 in examples/02_advanced/10_shared_context.py

View workflow job for this annotation

GitHub Actions / Validate (3.13)

Argument missing for parameter "cancelable" (reportCallIssue)

Check failure on line 121 in examples/02_advanced/10_shared_context.py

View workflow job for this annotation

GitHub Actions / Validate (3.13)

Type of "data" is unknown (reportUnknownVariableType)
data = await fetch_with_injection("Database")
print(f"✓ Fetched {data['records']} records with injection")

Expand Down
2 changes: 1 addition & 1 deletion examples/03_integrations/02_database_simple.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@

import asyncio
import random
from datetime import UTC, datetime, timedelta

Check failure on line 14 in examples/03_integrations/02_database_simple.py

View workflow job for this annotation

GitHub Actions / Validate (3.14)

Type of "String" is unknown (reportUnknownVariableType)

Check failure on line 14 in examples/03_integrations/02_database_simple.py

View workflow job for this annotation

GitHub Actions / Validate (3.14)

Type of "Integer" is unknown (reportUnknownVariableType)

Check failure on line 14 in examples/03_integrations/02_database_simple.py

View workflow job for this annotation

GitHub Actions / Validate (3.14)

Type of "Float" is unknown (reportUnknownVariableType)

Check failure on line 14 in examples/03_integrations/02_database_simple.py

View workflow job for this annotation

GitHub Actions / Validate (3.14)

Type of "DateTime" is unknown (reportUnknownVariableType)

Check failure on line 14 in examples/03_integrations/02_database_simple.py

View workflow job for this annotation

GitHub Actions / Validate (3.14)

Type of "Column" is unknown (reportUnknownVariableType)

Check failure on line 14 in examples/03_integrations/02_database_simple.py

View workflow job for this annotation

GitHub Actions / Validate (3.14)

Type of "Boolean" is unknown (reportUnknownVariableType)

Check failure on line 14 in examples/03_integrations/02_database_simple.py

View workflow job for this annotation

GitHub Actions / Validate (3.14)

Import "sqlalchemy" could not be resolved (reportMissingImports)

Check failure on line 14 in examples/03_integrations/02_database_simple.py

View workflow job for this annotation

GitHub Actions / Validate (3.13)

Type of "String" is unknown (reportUnknownVariableType)

Check failure on line 14 in examples/03_integrations/02_database_simple.py

View workflow job for this annotation

GitHub Actions / Validate (3.13)

Type of "Integer" is unknown (reportUnknownVariableType)

Check failure on line 14 in examples/03_integrations/02_database_simple.py

View workflow job for this annotation

GitHub Actions / Validate (3.13)

Type of "Float" is unknown (reportUnknownVariableType)

Check failure on line 14 in examples/03_integrations/02_database_simple.py

View workflow job for this annotation

GitHub Actions / Validate (3.13)

Type of "DateTime" is unknown (reportUnknownVariableType)

Check failure on line 14 in examples/03_integrations/02_database_simple.py

View workflow job for this annotation

GitHub Actions / Validate (3.13)

Type of "Column" is unknown (reportUnknownVariableType)

Check failure on line 14 in examples/03_integrations/02_database_simple.py

View workflow job for this annotation

GitHub Actions / Validate (3.13)

Type of "Boolean" is unknown (reportUnknownVariableType)

Check failure on line 14 in examples/03_integrations/02_database_simple.py

View workflow job for this annotation

GitHub Actions / Validate (3.13)

Import "sqlalchemy" could not be resolved (reportMissingImports)

from sqlalchemy import Boolean, Column, DateTime, Float, Integer, String, func, select

Check failure on line 16 in examples/03_integrations/02_database_simple.py

View workflow job for this annotation

GitHub Actions / Validate (3.12)

Type of "String" is unknown (reportUnknownVariableType)

Check failure on line 16 in examples/03_integrations/02_database_simple.py

View workflow job for this annotation

GitHub Actions / Validate (3.12)

Type of "Integer" is unknown (reportUnknownVariableType)

Check failure on line 16 in examples/03_integrations/02_database_simple.py

View workflow job for this annotation

GitHub Actions / Validate (3.12)

Type of "Float" is unknown (reportUnknownVariableType)

Check failure on line 16 in examples/03_integrations/02_database_simple.py

View workflow job for this annotation

GitHub Actions / Validate (3.12)

Type of "DateTime" is unknown (reportUnknownVariableType)

Check failure on line 16 in examples/03_integrations/02_database_simple.py

View workflow job for this annotation

GitHub Actions / Validate (3.12)

Type of "Column" is unknown (reportUnknownVariableType)

Check failure on line 16 in examples/03_integrations/02_database_simple.py

View workflow job for this annotation

GitHub Actions / Validate (3.12)

Type of "Boolean" is unknown (reportUnknownVariableType)

Check failure on line 16 in examples/03_integrations/02_database_simple.py

View workflow job for this annotation

GitHub Actions / Validate (3.12)

Import "sqlalchemy" could not be resolved (reportMissingImports)

Check failure on line 16 in examples/03_integrations/02_database_simple.py

View workflow job for this annotation

GitHub Actions / Validate (3.13)

Type of "String" is unknown (reportUnknownVariableType)

Check failure on line 16 in examples/03_integrations/02_database_simple.py

View workflow job for this annotation

GitHub Actions / Validate (3.13)

Type of "Integer" is unknown (reportUnknownVariableType)

Check failure on line 16 in examples/03_integrations/02_database_simple.py

View workflow job for this annotation

GitHub Actions / Validate (3.13)

Type of "Float" is unknown (reportUnknownVariableType)

Check failure on line 16 in examples/03_integrations/02_database_simple.py

View workflow job for this annotation

GitHub Actions / Validate (3.13)

Type of "DateTime" is unknown (reportUnknownVariableType)

Check failure on line 16 in examples/03_integrations/02_database_simple.py

View workflow job for this annotation

GitHub Actions / Validate (3.13)

Type of "Column" is unknown (reportUnknownVariableType)

Check failure on line 16 in examples/03_integrations/02_database_simple.py

View workflow job for this annotation

GitHub Actions / Validate (3.13)

Type of "Boolean" is unknown (reportUnknownVariableType)

Check failure on line 16 in examples/03_integrations/02_database_simple.py

View workflow job for this annotation

GitHub Actions / Validate (3.13)

Import "sqlalchemy" could not be resolved (reportMissingImports)
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import declarative_base

Expand Down Expand Up @@ -97,7 +97,7 @@
await create_test_data(engine, 200)

@cancelable(timeout=10.0, name="batch_processor")
async def process_batch(batch_size: int = 50, cancelable: Cancelable):
async def process_batch(batch_size: int = 50, *, cancelable: Cancelable):
async with cancelable_session(engine, cancelable) as session:
# Process unprocessed records
processed_total = 0
Expand Down
1 change: 1 addition & 0 deletions examples/05_monitoring/01_monitoring_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ async def simulated_api_call(
endpoint: str,
duration: float,
fail_rate: float = 0.1,
*,
cancelable: Cancelable,
):
"""Simulate an API call."""
Expand Down
5 changes: 4 additions & 1 deletion lefthook.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ pre-commit:
end-of-file:
run: |
for file in {staged_files}; do
if [[ "$file" != *.py ]] && [[ -f "$file" ]] && [[ -s "$file" ]]; then
case "$file" in
*.py) continue ;;
esac
if [ -f "$file" ] && [ -s "$file" ]; then
if [ -n "$(tail -c 1 "$file")" ]; then
echo >> "$file"
fi
Expand Down
9 changes: 4 additions & 5 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 @@ -154,7 +153,7 @@ combine-as-imports = true
convention = "google"

[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = ["D"]
"tests/**/*.py" = ["D", "PLR2004"] # Ignore docstrings and magic values in tests
"examples/**/*.py" = ["D101", "D103"]

# Format settings
Expand All @@ -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 Down
77 changes: 73 additions & 4 deletions tests/unit/test_cancelable.py
Original file line number Diff line number Diff line change
Expand Up @@ -1651,8 +1651,8 @@ async def test_destructor_with_parent_cleanup(self):

Targets lines 417-421: parent cleanup in __del__.
"""
import weakref
import gc
import weakref

parent = Cancelable(name="parent")
child = Cancelable(name="child")
Expand Down Expand Up @@ -1834,6 +1834,7 @@ async def test_parent_token_linking(self):
Targets lines 590-591: parent token linking.
"""
import weakref

from hother.cancelable import CancelationToken

parent_token = CancelationToken()
Expand Down Expand Up @@ -1996,7 +1997,7 @@ async def test_cancelation_from_triggered_source(self):

Targets lines 471-479: source checking when cancel_reason not set.
"""
from unittest.mock import Mock, AsyncMock
from unittest.mock import AsyncMock, Mock

cancel = Cancelable(name="triggered_source")

Expand Down Expand Up @@ -2069,6 +2070,7 @@ async def test_token_linking_exception(self):
Targets lines 609-612: exception in _safe_link_tokens.
"""
from unittest.mock import patch

from hother.cancelable.core.token import LinkedCancelationToken

parent = Cancelable(name="parent")
Expand Down Expand Up @@ -2248,7 +2250,7 @@ async def test_source_check_without_deadline(self):

Targets lines 476-479: source checking in else branch (no deadline).
"""
from unittest.mock import Mock, AsyncMock
from unittest.mock import AsyncMock, Mock

cancel = Cancelable(name="no_deadline_source")

Expand Down Expand Up @@ -2279,7 +2281,7 @@ async def test_source_check_with_deadline_and_source(self):

Targets branch 471->470: check sources when deadline exists but not expired.
"""
from unittest.mock import Mock, AsyncMock
from unittest.mock import AsyncMock, Mock

cancel = Cancelable.with_timeout(10.0, name="deadline_and_source")

Expand Down Expand Up @@ -2531,4 +2533,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"
Loading
Loading