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
465 changes: 465 additions & 0 deletions .clickup-docs-updates.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion .github/actions/setup-python-uv/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ inputs:
python-version:
description: 'Python version to use'
required: false
default: '3.13'
default: '3.14'
uv-version:
description: 'uv version to use'
required: false
Expand Down
22 changes: 11 additions & 11 deletions docs/.hooks/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ def render_example_files(markdown: str) -> str:
Markdown with #! directives replaced by code blocks
"""
# Pattern: #! followed by file path (at start of line)
pattern = r'^#!\s*(.+\.py)\s*$'
pattern = r"^#!\s*(.+\.py)\s*$"

def replace_directive(match: re.Match) -> str:
"""Replace a single #! directive with rendered code."""
Expand Down Expand Up @@ -128,37 +128,37 @@ def create_package_manager_tabs(markdown: str) -> str:
Markdown with tabbed package manager commands
"""
# Pattern: bash code blocks containing pip install or uv add
pattern = r'```bash\n((?:pip install|uv (?:add|pip install))[^\n]+)\n```'
pattern = r"```bash\n((?:pip install|uv (?:add|pip install))[^\n]+)\n```"

def create_tabs(match: re.Match) -> str:
"""Create tabbed alternatives for a package manager command."""
command = match.group(1).strip()

# Extract package name
if 'pip install' in command:
package = command.replace('pip install', '').strip()
elif 'uv add' in command:
package = command.replace('uv add', '').strip()
elif 'uv pip install' in command:
package = command.replace('uv pip install', '').strip()
if "pip install" in command:
package = command.replace("pip install", "").strip()
elif "uv add" in command:
package = command.replace("uv add", "").strip()
elif "uv pip install" in command:
package = command.replace("uv pip install", "").strip()
else:
# Don't modify if we can't parse it
return match.group(0)

# Skip if it's a complex command (contains && or other operators)
if any(op in command for op in ['&&', '||', ';', '|']):
if any(op in command for op in ["&&", "||", ";", "|"]):
return match.group(0)

# Generate tabbed interface (uv first as default)
return f'''=== "uv"
return f"""=== "uv"
```bash
uv add {package}
```

=== "pip"
```bash
pip install {package}
```'''
```"""

# Replace all package manager commands
return re.sub(pattern, create_tabs, markdown, flags=re.MULTILINE)
Expand Down
6 changes: 3 additions & 3 deletions docs/.hooks/snippets.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,10 @@ async def main():
"""

# Pattern for section start: ### [section_name]
START_PATTERN = re.compile(r'^\s*###\s*\[(\w+)\]\s*$')
START_PATTERN = re.compile(r"^\s*###\s*\[(\w+)\]\s*$")

# Pattern for section end: ### [/section_name]
END_PATTERN = re.compile(r'^\s*###\s*\[/(\w+)\]\s*$')
END_PATTERN = re.compile(r"^\s*###\s*\[/(\w+)\]\s*$")

def __init__(self, file_path: Path):
"""Initialize extractor for a specific file.
Expand Down Expand Up @@ -290,7 +290,7 @@ def replace_snippet(match: re.Match) -> str:
# Parse sections list if provided
sections_list = None
if sections_str:
sections_list = [s.strip() for s in sections_str.split(',')]
sections_list = [s.strip() for s in sections_str.split(",")]

return process_snippet_directive(file_path, section, sections_list)

Expand Down
6 changes: 3 additions & 3 deletions docs/.hooks/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def strip_leading_docstring(code: str) -> str:
"""
# Match triple-quoted strings at the start (with optional shebang/encoding)
pattern = r'^(#!.*?\n)?(# -\*- coding:.*?\n)?(\s*"""[\s\S]*?"""\s*\n|\s*\'\'\'[\s\S]*?\'\'\'\s*\n)?'
return re.sub(pattern, r'\1\2', code, count=1)
return re.sub(pattern, r"\1\2", code, count=1)


def format_code_block(
Expand Down Expand Up @@ -73,9 +73,9 @@ def format_code_block(

# Add source link
if start_line and end_line:
result += f'_[View source on GitHub (lines {start_line}-{end_line})]({github_link})_'
result += f"_[View source on GitHub (lines {start_line}-{end_line})]({github_link})_"
else:
result += f'_[View source on GitHub]({github_link})_'
result += f"_[View source on GitHub]({github_link})_"

return result

Expand Down
6 changes: 3 additions & 3 deletions examples/01_basics/02_timeout_cancelation.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,11 @@ async def main() -> None:

except anyio.get_cancelled_exc_class():
if cancel:
print(f" Operation timed out after {(cancel.context.duration.total_seconds() if cancel.context.duration else 0.0):.2f}s")
print(f" Final status: {cancel.context.status.value}")
print(
f" Cancel reason: {cancel.context.cancel_reason.value if cancel.context.cancel_reason else 'unknown'}"
f" Operation timed out after {(cancel.context.duration.total_seconds() if cancel.context.duration else 0.0):.2f}s"
)
print(f" Final status: {cancel.context.status.value}")
print(f" Cancel reason: {cancel.context.cancel_reason.value if cancel.context.cancel_reason else 'unknown'}")
# --8<-- [end:example]


Expand Down
4 changes: 3 additions & 1 deletion examples/02_advanced/01_combined_cancelation.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,9 @@ async def main():
print("Token cancel call completed")
except asyncio.CancelledError:
print(" Operation was cancelled")
print(f" Reason: {final_cancellable.context.cancel_reason.value if final_cancellable.context.cancel_reason else 'unknown'}")
print(
f" Reason: {final_cancellable.context.cancel_reason.value if final_cancellable.context.cancel_reason else 'unknown'}"
)
print(f" Message: {final_cancellable.context.cancel_message or 'no message'}")
# --8<-- [end:example]

Expand Down
16 changes: 4 additions & 12 deletions examples/02_advanced/07_condition_cancelation.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,7 @@ async def create_stop_file_after_delay():
async with asyncio.TaskGroup() as tg:
tg.create_task(create_stop_file_after_delay())

async with Cancelable.with_condition(
check_stop_file, condition_name="file_exists", name="file_watcher"
) as cancel:
async with Cancelable.with_condition(check_stop_file, condition_name="file_exists", name="file_watcher") as cancel:
print(f" Started file watcher: {cancel.context.id}")
print(" Waiting for stop file to appear...")

Expand Down Expand Up @@ -83,9 +81,7 @@ def check_db_error():
return bool(db_state.get("error", False))

# Create condition-based cancelables
complete_cancel = Cancelable.with_condition(
check_db_complete, condition_name="db_complete", name="complete_monitor"
)
complete_cancel = Cancelable.with_condition(check_db_complete, condition_name="db_complete", name="complete_monitor")
error_cancel = Cancelable.with_condition(check_db_error, condition_name="db_error", name="error_monitor")

# Combine conditions (cancel if either complete or error)
Expand Down Expand Up @@ -140,9 +136,7 @@ def check_quiet_period():
return system_state["active_connections"] < 10 and system_state["cpu_usage"] < 20.0

# Create condition-based cancelables
health_cancel = Cancelable.with_condition(
check_system_health, condition_name="system_health", name="health_monitor"
)
health_cancel = Cancelable.with_condition(check_system_health, condition_name="system_health", name="health_monitor")
quiet_cancel = Cancelable.with_condition(check_quiet_period, condition_name="quiet_period", name="quiet_monitor")

# Cancel if system becomes unhealthy OR enters quiet period
Expand Down Expand Up @@ -193,9 +187,7 @@ def check_target_reached():
return bool(progress_state["target_reached"])

# Create sources
condition_cancel = Cancelable.with_condition(
check_target_reached, condition_name="target_check", name="target_monitor"
)
condition_cancel = Cancelable.with_condition(check_target_reached, condition_name="target_check", name="target_monitor")
timeout_cancel = Cancelable.with_timeout(5.0, name="processing_timeout")

# Combine condition and timeout
Expand Down
2 changes: 1 addition & 1 deletion examples/02_advanced/08_signal_handling.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,4 +204,4 @@ def custom_sigusr1_handler(signum: int, frame: Any) -> None:
print("Run with: python examples/02_advanced/08_signal_handling.py")
print()

asyncio.run(main())
asyncio.run(main())
22 changes: 5 additions & 17 deletions examples/02_advanced/09_all_of_combining.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,7 @@ async def batch_processor_with_requirements():
elapsed = time.time() - start_time
print("\n✅ Batch processing completed!")
print(f"Final: {items_processed} items processed in {elapsed:.1f}s")
print(
f"Requirements met: Time={elapsed >= 60}, Items={items_processed >= 100}"
)
print(f"Requirements met: Time={elapsed >= 60}, Items={items_processed >= 100}")


async def demonstration_fast_items_slow_time():
Expand All @@ -100,9 +98,7 @@ async def demonstration_fast_items_slow_time():
print("Should continue for full 30 seconds\n")

min_time = TimeoutSource(timeout=30.0)
min_items = ConditionSource(
condition=lambda: items_processed >= 100, check_interval=0.5
)
min_items = ConditionSource(condition=lambda: items_processed >= 100, check_interval=0.5)

all_of = AllOfSource([min_time, min_items])
cancelable = Cancelable(name="fast_items")
Expand All @@ -124,10 +120,7 @@ async def demonstration_fast_items_slow_time():

except anyio.get_cancelled_exc_class():
elapsed = time.time() - start_time
print(
f"\n✅ Completed: {items_processed} items in {elapsed:.1f}s "
f"(waited for time requirement)"
)
print(f"\n✅ Completed: {items_processed} items in {elapsed:.1f}s " f"(waited for time requirement)")


async def demonstration_slow_items_fast_time():
Expand All @@ -147,9 +140,7 @@ async def demonstration_slow_items_fast_time():
print("Should continue until 50 items processed\n")

min_time = TimeoutSource(timeout=10.0)
min_items = ConditionSource(
condition=lambda: items_processed >= 50, check_interval=0.5
)
min_items = ConditionSource(condition=lambda: items_processed >= 50, check_interval=0.5)

all_of = AllOfSource([min_time, min_items])
cancelable = Cancelable(name="slow_items")
Expand All @@ -171,10 +162,7 @@ async def demonstration_slow_items_fast_time():

except anyio.get_cancelled_exc_class():
elapsed = time.time() - start_time
print(
f"\n✅ Completed: {items_processed} items in {elapsed:.1f}s "
f"(waited for item requirement)"
)
print(f"\n✅ Completed: {items_processed} items in {elapsed:.1f}s " f"(waited for item requirement)")


async def main():
Expand Down
5 changes: 2 additions & 3 deletions examples/02_advanced/09_resource_monitoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
# Check if psutil is available
try:
import psutil

_psutil = psutil
_has_psutil = True
except ImportError:
Expand Down Expand Up @@ -264,9 +265,7 @@ async def main():
print("Starting data export with conservative resource monitoring")
print("Thresholds: Memory 75%, CPU 85%, Disk 90%")
resources = get_current_resources()
print(
f"Current: Memory {resources['memory']:.1f}%, CPU {resources['cpu']:.1f}%, Disk {resources['disk']:.1f}%\n"
)
print(f"Current: Memory {resources['memory']:.1f}%, CPU {resources['cpu']:.1f}%, Disk {resources['disk']:.1f}%\n")

try:
async with cancelable:
Expand Down
8 changes: 2 additions & 6 deletions examples/03_integrations/06_retry_tenacity.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,7 @@ async def example_wrap_with_tenacity():
wrapped_op = cancel.wrap(unreliable_operation)

# Use Tenacity for retry logic
async for attempt in AsyncRetrying(
stop=stop_after_attempt(5), wait=wait_exponential(multiplier=1, min=1, max=10)
):
async for attempt in AsyncRetrying(stop=stop_after_attempt(5), wait=wait_exponential(multiplier=1, min=1, max=10)):
with attempt:
# Report progress
attempt_num = attempt.retry_state.attempt_number
Expand Down Expand Up @@ -275,9 +273,7 @@ async def example_progress_tracking():
async for attempt in AsyncRetrying(stop=stop_after_attempt(5), wait=wait_exponential(multiplier=1)):
with attempt:
attempt_num = attempt.retry_state.attempt_number
await cancel.report_progress(
f"📊 Attempt {attempt_num}", {"attempt": attempt_num, "stage": "before_execution"}
)
await cancel.report_progress(f"📊 Attempt {attempt_num}", {"attempt": attempt_num, "stage": "before_execution"})

result = await wrapped_op(400)

Expand Down
4 changes: 1 addition & 3 deletions examples/04_streams/01_stream_processing.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,9 +172,7 @@ async def transform_pipeline(stream: AsyncIterator[dict]) -> AsyncIterator[dict]
yield enriched

# Process with pipeline
cancelable = Cancelable.with_timeout(5.0, name="transform_pipeline").on_progress(
lambda op_id, msg, meta: print(f" {msg}")
)
cancelable = Cancelable.with_timeout(5.0, name="transform_pipeline").on_progress(lambda op_id, msg, meta: print(f" {msg}"))

async with cancelable:
comfort_stats = {"optimal": 0, "good": 0, "poor": 0}
Expand Down
8 changes: 6 additions & 2 deletions examples/05_monitoring/01_monitoring_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,9 @@ async def simulated_data_processing(
processed += batch

if cancelable:
await cancelable.report_progress(f"Processed {processed}/{record_count} records", {"progress_percent": (processed / record_count) * 100})
await cancelable.report_progress(
f"Processed {processed}/{record_count} records", {"progress_percent": (processed / record_count) * 100}
)

return {"dataset_id": dataset_id, "records_processed": processed}

Expand All @@ -261,7 +263,9 @@ async def simulated_file_download(

progress = (downloaded / size_mb) * 100
if cancelable:
await cancelable.report_progress(f"Downloading: {progress:.1f}%", {"downloaded_mb": downloaded, "total_mb": size_mb})
await cancelable.report_progress(
f"Downloading: {progress:.1f}%", {"downloaded_mb": downloaded, "total_mb": size_mb}
)

return {"file_id": file_id, "size_mb": size_mb}

Expand Down
4 changes: 1 addition & 3 deletions examples/07_llm/01_llm_streaming.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,7 @@ async def stream_with_cancelation(prompt: str, token: CancelationToken, conversa
print("STREAMING OUTPUT:")
print(f"{'='*70}\n")

async for chunk in await client.aio.models.generate_content_stream(
model="gemini-2.0-flash-exp", contents=contents
):
async for chunk in await client.aio.models.generate_content_stream(model="gemini-2.0-flash-exp", contents=contents):
# Get chunk text
if not chunk.text:
continue
Expand Down
25 changes: 21 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ dev = [
"coverage[toml]>=7.10.7",
"pretty_errors>=1.2.25",
"ruff>=0.8.6",
"basedpyright>=1.23.0",
"basedpyright>=1.36.0",
"twine>=6.0.0",
"lefthook>=1.13.0",
"pyyaml>=6.0.2",
Expand Down Expand Up @@ -140,6 +140,7 @@ ignore = [
"D104", # Missing docstring in public package
"D105", # Missing docstring in magic method
"D107", # Missing docstring in __init__
"ISC001", # Conflicts with formatter
]

mccabe = { max-complexity = 15 }
Expand All @@ -153,8 +154,18 @@ combine-as-imports = true
convention = "google"

[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = ["D", "PLR2004"] # Ignore docstrings and magic values in tests
"examples/**/*.py" = ["D101", "D103"]
"tests/**/*.py" = ["D", "PLR2004", "S101", "S110", "E722", "F401", "F811", "B023", "SIM117", "PLR0124"] # Comprehensive test ignores
"examples/**/*.py" = ["ALL"] # Examples are demonstration code, ignore all linting rules
"docs/.hooks/**/*.py" = ["ALL"] # Ignore all rules in docs hooks
"src/**/*.py" = ["D200", "D212", "D415", "D301"] # Ignore minor docstring formatting in src
"src/hother/cancelable/sources/condition.py" = ["S101"] # Allow assert for defensive programming
"src/hother/cancelable/streaming/**/*.py" = ["S311"] # Allow random in streaming simulators
"src/hother/cancelable/utils/decorators.py" = ["S101"] # Allow assert False for unreachable code markers
"src/hother/cancelable/core/exceptions.py" = ["N818"] # Exception naming convention (Cancelation vs CancelationError) is intentional
"src/hother/cancelable/core/cancelable.py" = ["PLR0912"] # Allow _determine_final_status to have multiple branches for exception handling
"src/hother/cancelable/integrations/fastapi.py" = ["B904"] # HTTPException raising patterns are intentional
"src/hother/cancelable/utils/testing.py" = ["B904"] # AssertionError raising in test utilities is intentional
"tools/**/*.py" = ["S602", "S110", "D"] # Allow subprocess shell=True, try-except-pass, and skip docstrings in internal tools

# Format settings
[tool.ruff.format]
Expand All @@ -175,6 +186,7 @@ include = [
exclude = [
"tests",
"examples",
"src/hother/cancelable/integrations/fastapi.py", # Optional dependency
]
venvPath = "."
venv = ".venv"
Expand All @@ -197,7 +209,12 @@ filterwarnings = [

[tool.coverage.run]
source = ["src/hother/cancelable"]
omit = ["*/tests/*", "*/__init__.py", "*/_version.py"]
omit = [
"*/tests/*",
"*/__init__.py",
"*/_version.py",
"*/integrations/fastapi.py", # Optional dependency
]
branch = true
concurrency = ["multiprocessing", "thread"]
# Use subdirectory for coverage data to keep root clean
Expand Down
Loading
Loading