diff --git a/README.md b/README.md index 821405e..d388938 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ ## Features * **Production-Safe & Low Overhead**: Leverages Python's `sys.audit` hooks for minimal runtime overhead, making it safe for production use +* **Works with asyncio and uvloop**: Compatible with both standard asyncio and uvloop event loops out of the box * **Blocking I/O Detection**: Automatically detects blocking I/O calls (file operations, network calls, subprocess, etc.) in your async code * **Stack Trace Capture**: Captures full stack traces to pinpoint exactly where blocking calls originate * **Severity Scoring**: Assigns severity scores to blocking events to help prioritize fixes @@ -30,7 +31,7 @@ aiocop architecture diagram

-aiocop wraps `asyncio.Handle._run` (the method that executes every task in the event loop) and uses Python's `sys.audit` hooks to detect blocking calls. When your code calls a blocking function like `open()`, the audit event is captured along with the full stack trace—letting you know exactly where the problem is. +aiocop wraps the event loop's scheduling methods (`call_soon`, `call_later`, etc.) and uses Python's `sys.audit` hooks to detect blocking calls. This approach works with both standard asyncio and uvloop. When your code calls a blocking function like `open()`, the audit event is captured along with the full stack trace—letting you know exactly where the problem is. ## Why aiocop? @@ -47,6 +48,7 @@ aiocop was built to solve specific production constraints that existing approach | Production Overhead | Low-Medium | High | Very Low (~13μs/task) | | Stack Traces | Yes | No (timing only) | Yes | | Runtime Control | Varies | Flag at startup | Dynamic on/off | +| uvloop Support | Varies | No | Yes | ## Performance diff --git a/aiocop/core/callbacks.py b/aiocop/core/callbacks.py index 4ecceb6..dfade47 100644 --- a/aiocop/core/callbacks.py +++ b/aiocop/core/callbacks.py @@ -66,7 +66,7 @@ def _capture_context() -> dict[str, Any]: if provider_context is not None: context.update(provider_context) except Exception as e: - logger.warning("Error in context provider %s: %s", provider.__name__, e) + logger.warning("Error in context provider %s: %s", getattr(provider, "__name__", repr(provider)), e) return context @@ -81,4 +81,4 @@ def _invoke_slow_task_callbacks(event: SlowTaskEvent) -> None: try: callback(event) except Exception as e: - logger.warning("Error in slow task callback %s: %s", callback.__name__, e) + logger.warning("Error in slow task callback %s: %s", getattr(callback, "__name__", repr(callback)), e) diff --git a/aiocop/core/slow_tasks.py b/aiocop/core/slow_tasks.py index a808108..4ac03db 100644 --- a/aiocop/core/slow_tasks.py +++ b/aiocop/core/slow_tasks.py @@ -1,10 +1,15 @@ -"""Slow task detection by patching asyncio Handle._run.""" - +"""Slow task detection by patching event loop scheduling methods. +This module patches the event loop's call_soon, call_later, call_at, and +call_soon_threadsafe methods to wrap callbacks with monitoring logic. +This approach works with both standard asyncio and uvloop, since it patches +at the loop level rather than relying on asyncio's Handle._run which uvloop +replaces with its own Cython implementation. +""" +import asyncio import logging -from asyncio.events import Handle from collections.abc import Callable from dataclasses import replace from time import perf_counter_ns @@ -20,6 +25,7 @@ _reset_exception_flag, is_monitoring_active, raise_on_violations, + register_on_activate_hook, ) from aiocop.exceptions import HighSeverityBlockingIoException from aiocop.types.events import BlockingEventInfo, SlowTaskEvent @@ -27,32 +33,26 @@ logger = logging.getLogger(__name__) -_detect_slow_tasks_already_applied = False +_detect_slow_tasks_configured = False _slow_task_threshold_ns: int = 30 * 1_000_000 SlowTaskCallback = Callable[[SlowTaskEvent], None] -def _invoke_callbacks_with_context(event: SlowTaskEvent) -> None: - """ - Capture context and invoke callbacks within the Handle's context. - - This function is called via self._context.run() to ensure that context - providers (like ddtrace span) are captured from the correct contextvars. - """ - captured_context = _capture_context() - event_with_context = replace(event, context=captured_context) - _invoke_slow_task_callbacks(event_with_context) - - def detect_slow_tasks( threshold_ms: int = 30, on_slow_task: SlowTaskCallback | None = None, ) -> None: """ - Patch the asyncio event loop to detect slow tasks. + Configure slow task detection for the asyncio event loop. + + This patches the event loop's scheduling methods (call_soon, call_later, etc.) + to measure execution time and capture blocking IO events. Works with both + standard asyncio and uvloop. + + The loop is patched either immediately (if called from an async context) or + when activate() is called. - This patches Handle._run to measure execution time and capture blocking IO events. Callbacks are invoked for every task that has blocking events detected, with the exceeded_threshold flag indicating if the task exceeded the threshold. @@ -64,9 +64,9 @@ def detect_slow_tasks( Should be called after start_blocking_io_detection(). """ - global _detect_slow_tasks_already_applied, _slow_task_threshold_ns + global _detect_slow_tasks_configured, _slow_task_threshold_ns - if _detect_slow_tasks_already_applied is True: + if _detect_slow_tasks_configured is True: logger.warning("detect_slow_tasks called more than once, ignoring") return @@ -80,19 +80,88 @@ def detect_slow_tasks( if raise_on_violations.get() is True: logger.info("Exceptions raising on high severity IO blocking tasks enabled") - old_run = Handle._run # noqa + _detect_slow_tasks_configured = True + + register_on_activate_hook(_ensure_loop_patched) + + _ensure_loop_patched() + + +def _ensure_loop_patched() -> bool: + """ + Ensure the current event loop is patched for slow task detection. + """ + if not _detect_slow_tasks_configured: + return False + + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return False + + if getattr(loop, "_aiocop_patched", False): + return True + + _patch_loop(loop) + loop._aiocop_patched = True # type: ignore[attr-defined] + logger.info("Event loop patched for slow task detection") + return True + + +def _patch_loop(loop: asyncio.AbstractEventLoop) -> None: + """Patch the event loop's scheduling methods to monitor callback execution.""" + + original_call_soon = loop.call_soon - __class__ = Handle # noqa + def patched_call_soon(callback: Callable[..., Any], *args: Any, context: Any = None) -> Any: + wrapped = _make_monitored_callback(callback, args) + return original_call_soon(wrapped, context=context) - def new_run(self) -> Any: + loop.call_soon = patched_call_soon # type: ignore[method-assign] + + original_call_later = loop.call_later + + def patched_call_later(delay: float, callback: Callable[..., Any], *args: Any, context: Any = None) -> Any: + wrapped = _make_monitored_callback(callback, args) + return original_call_later(delay, wrapped, context=context) + + loop.call_later = patched_call_later # type: ignore[method-assign] + + original_call_at = loop.call_at + + def patched_call_at(when: float, callback: Callable[..., Any], *args: Any, context: Any = None) -> Any: + wrapped = _make_monitored_callback(callback, args) + return original_call_at(when, wrapped, context=context) + + loop.call_at = patched_call_at # type: ignore[method-assign] + + original_call_soon_threadsafe = loop.call_soon_threadsafe + + def patched_call_soon_threadsafe(callback: Callable[..., Any], *args: Any, context: Any = None) -> Any: + wrapped = _make_monitored_callback(callback, args) + return original_call_soon_threadsafe(wrapped, context=context) + + loop.call_soon_threadsafe = patched_call_soon_threadsafe # type: ignore[method-assign] + + +def _make_monitored_callback(callback: Callable[..., Any], args: tuple[Any, ...]) -> Callable[[], Any]: + """ + Create a wrapper that monitors callback execution time and blocking events. + + The wrapper is called with no arguments - the original args are captured + in the closure and passed to the callback. + """ + + def monitored_wrapper() -> Any: if not is_monitoring_active(): - return old_run(self) + if args: + return callback(*args) + return callback() thread_local = _get_thread_local() - captured_events: list = [] + captured_events: list[Any] = [] previous_events = getattr(thread_local, "blocking_events", None) - thread_local.blocking_events = captured_events thread_local.should_raise_for_this_handle = False @@ -100,7 +169,10 @@ def new_run(self) -> Any: t0 = perf_counter_ns() try: - return_value = old_run(self) # noqa + if args: + return_value = callback(*args) + else: + return_value = callback() finally: thread_local.blocking_events = previous_events @@ -129,10 +201,10 @@ def new_run(self) -> Any: blocking_events=formatted_events, ) - self._context.run(_invoke_callbacks_with_context, slow_task_event) + _invoke_callbacks_with_context(slow_task_event) if exceeded_threshold is True: - self._context.run(_check_and_raise_if_needed, elapsed, formatted_events, should_raise) + _check_and_raise_if_needed(elapsed, formatted_events, should_raise) elif exceeded_threshold is True: slow_task_event = SlowTaskEvent( @@ -145,7 +217,7 @@ def new_run(self) -> Any: blocking_events=[], ) - self._context.run(_invoke_callbacks_with_context, slow_task_event) + _invoke_callbacks_with_context(slow_task_event) except HighSeverityBlockingIoException: raise @@ -154,14 +226,22 @@ def new_run(self) -> Any: return return_value - Handle._run = new_run # noqa # type: ignore[method-assign] - _detect_slow_tasks_already_applied = True + return monitored_wrapper + + +def _invoke_callbacks_with_context(event: SlowTaskEvent) -> None: + """ + Capture context and invoke callbacks. + """ + captured_context = _capture_context() + event_with_context = replace(event, context=captured_context) + _invoke_slow_task_callbacks(event_with_context) def _check_and_raise_if_needed( elapsed: int, blocking_events: list[BlockingEventInfo] | None, should_raise: bool ) -> None: - """Check if high severity blocking IO should raise an exception within the Handle's context.""" + """Check if high severity blocking IO should raise an exception.""" if should_raise is True and _has_exception_been_raised() is False: io_severity = calculate_io_severity_score(blocking_events) diff --git a/aiocop/core/state.py b/aiocop/core/state.py index 40327cc..04cf62d 100644 --- a/aiocop/core/state.py +++ b/aiocop/core/state.py @@ -1,6 +1,7 @@ """Shared state management for aiocop monitoring.""" import threading +from collections.abc import Callable from contextvars import ContextVar _thread_local = threading.local() @@ -11,12 +12,23 @@ _exception_raised = False +_on_activate_hooks: list[Callable[[], object]] = [] + + +def register_on_activate_hook(hook: Callable[[], object]) -> None: + """Register a hook to be called when monitoring is activated.""" + if hook not in _on_activate_hooks: + _on_activate_hooks.append(hook) + def activate() -> None: """Activate monitoring. Call this after startup when you want to start detecting blocking IO.""" global _monitoring_active _monitoring_active = True + for hook in _on_activate_hooks: + hook() + def deactivate() -> None: """Deactivate monitoring. Pauses all detection without unregistering hooks.""" diff --git a/aiocop/exceptions.py b/aiocop/exceptions.py index a48b570..bb87f7a 100644 --- a/aiocop/exceptions.py +++ b/aiocop/exceptions.py @@ -1,5 +1,10 @@ """Exceptions for aiocop.""" +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from aiocop.types.events import BlockingEventInfo + class HighSeverityBlockingIoException(Exception): """Exception raised when high-severity blocking I/O is detected in async context.""" @@ -10,7 +15,7 @@ def __init__( severity_level: str, elapsed_ms: float, threshold_ms: float, - events: list[dict[str, str]], + events: "list[BlockingEventInfo]", ) -> None: self.severity_score = severity_score self.severity_level = severity_level diff --git a/benchmarks/run_benchmark.py b/benchmarks/run_benchmark.py index 256d7c5..8ecdd0c 100644 --- a/benchmarks/run_benchmark.py +++ b/benchmarks/run_benchmark.py @@ -3,14 +3,18 @@ aiocop Benchmark Script Measures the overhead of aiocop monitoring on async workloads. +Supports both standard asyncio and uvloop. Usage: python benchmarks/run_benchmark.py + python benchmarks/run_benchmark.py --asyncio-only + python benchmarks/run_benchmark.py --uvloop-only Or with uv: uv run python benchmarks/run_benchmark.py """ +import argparse import asyncio import gc import os @@ -24,55 +28,71 @@ # Add parent directory to path for importing aiocop sys.path.insert(0, str(Path(__file__).parent.parent)) -import aiocop - @dataclass class BenchmarkResult: """Result of a single benchmark run.""" name: str + loop_type: str num_tasks: int without_aiocop_ms: float with_aiocop_ms: float overhead_per_task_us: float # microseconds per task -def format_results(results: list[BenchmarkResult]) -> str: - """Format benchmark results.""" - lines = [] - lines.append("") - lines.append("=" * 70) - lines.append("aiocop Benchmark Results") - lines.append("=" * 70) - lines.append("") - lines.append("Per-Task Overhead (lower is better):") - lines.append("") - lines.append(f" {'Scenario':<40} {'Overhead':<15} {'Impact on 50ms request'}") - lines.append(f" {'-' * 40} {'-' * 15} {'-' * 22}") +def format_results(results: list[BenchmarkResult], loop_type: str) -> str: + """Format benchmark results for a specific loop type.""" + filtered = [r for r in results if r.loop_type == loop_type] + if not filtered: + return "" - for r in results: - # Calculate impact on a 50ms request - impact_percent = (r.overhead_per_task_us / 50000) * 100 # 50ms = 50000us - lines.append( - f" {r.name:<40} {r.overhead_per_task_us:>8.1f} us {impact_percent:.3f}%" - ) + lines = [] + lines.append(f"\n{loop_type.upper()} Results:") + lines.append("-" * 70) + lines.append(f" {'Scenario':<40} {'Overhead':<15} {'Impact on 50ms'}") + lines.append(f" {'-' * 40} {'-' * 15} {'-' * 14}") - lines.append("") + for r in filtered: + impact_percent = (r.overhead_per_task_us / 50000) * 100 + lines.append(f" {r.name:<40} {r.overhead_per_task_us:>8.1f} us {impact_percent:.3f}%") - # Calculate average - avg_overhead = statistics.mean(r.overhead_per_task_us for r in results) + avg_overhead = statistics.mean(r.overhead_per_task_us for r in filtered) avg_impact = (avg_overhead / 50000) * 100 + lines.append(f"\n Average: {avg_overhead:.1f} us per task ({avg_impact:.3f}% on 50ms request)") + + return "\n".join(lines) + + +def format_comparison(results: list[BenchmarkResult]) -> str: + """Format a comparison between asyncio and uvloop results.""" + asyncio_results = {r.name: r for r in results if r.loop_type == "asyncio"} + uvloop_results = {r.name: r for r in results if r.loop_type == "uvloop"} + + if not asyncio_results or not uvloop_results: + return "" + + lines = [] + lines.append("\n" + "=" * 70) + lines.append("COMPARISON: asyncio vs uvloop") + lines.append("=" * 70) + lines.append(f" {'Scenario':<30} {'asyncio':<12} {'uvloop':<12} {'Difference'}") + lines.append(f" {'-' * 30} {'-' * 12} {'-' * 12} {'-' * 12}") + + for name in asyncio_results: + if name in uvloop_results: + asyncio_us = asyncio_results[name].overhead_per_task_us + uvloop_us = uvloop_results[name].overhead_per_task_us + diff = uvloop_us - asyncio_us + diff_str = f"{diff:+.1f} us" if diff != 0 else "same" + lines.append(f" {name:<30} {asyncio_us:>8.1f} us {uvloop_us:>8.1f} us {diff_str}") + + asyncio_avg = statistics.mean(r.overhead_per_task_us for r in asyncio_results.values()) + uvloop_avg = statistics.mean(r.overhead_per_task_us for r in uvloop_results.values()) + diff_avg = uvloop_avg - asyncio_avg - lines.append(f" Average: {avg_overhead:.1f} us per task ({avg_impact:.3f}% on 50ms request)") - lines.append("") - lines.append("-" * 70) - lines.append("") - lines.append("What this means:") - lines.append(f" - Each async task adds ~{avg_overhead:.0f} microseconds of overhead") - lines.append(f" - A typical 50ms HTTP request sees {avg_impact:.2f}% overhead") - lines.append(f" - A typical 100ms database query sees {avg_impact/2:.2f}% overhead") lines.append("") + lines.append(f" {'Average':<30} {asyncio_avg:>8.1f} us {uvloop_avg:>8.1f} us {diff_avg:+.1f} us") return "\n".join(lines) @@ -81,9 +101,11 @@ async def run_scenario( name: str, task_fn: Callable[[], asyncio.Future], num_tasks: int, + loop_type: str, iterations: int = 5, ) -> BenchmarkResult: """Run a benchmark scenario with and without aiocop.""" + import aiocop async def run_tasks(): tasks = [asyncio.create_task(task_fn()) for _ in range(num_tasks)] @@ -118,10 +140,11 @@ async def run_tasks(): with_ms = statistics.median(times_with) overhead_ms = with_ms - without_ms - overhead_per_task_us = (overhead_ms * 1000) / num_tasks # Convert to microseconds + overhead_per_task_us = (overhead_ms * 1000) / num_tasks return BenchmarkResult( name=name, + loop_type=loop_type, num_tasks=num_tasks, without_aiocop_ms=without_ms, with_aiocop_ms=with_ms, @@ -167,25 +190,27 @@ async def realistic_http_handler(): await asyncio.sleep(0.001) # 1ms async work -def noop_callback(event: aiocop.SlowTaskEvent) -> None: +def noop_callback(event) -> None: """No-op callback for benchmarking.""" pass -async def main(): - print("") - print("aiocop Performance Benchmark") - print("=" * 50) - print("") - print("Setting up aiocop...") +def reset_aiocop_state(): + """Reset aiocop state for fresh benchmark run.""" + from aiocop.core import slow_tasks, state - # Setup aiocop with minimal trace depth for better performance - aiocop.patch_audit_functions() - aiocop.start_blocking_io_detection(trace_depth=5) - aiocop.detect_slow_tasks(threshold_ms=1000, on_slow_task=noop_callback) + state._monitoring_active = False + state._on_activate_hooks.clear() + slow_tasks._detect_slow_tasks_configured = False + + import aiocop + + aiocop.clear_slow_task_callbacks() + aiocop.clear_context_providers() - print("Running benchmarks...\n") +async def run_all_scenarios(loop_type: str) -> list[BenchmarkResult]: + """Run all benchmark scenarios.""" results = [] # Scenario 1: Pure async (baseline - no blocking I/O to detect) @@ -193,6 +218,7 @@ async def main(): name="Pure async (no blocking)", task_fn=fast_async_task, num_tasks=10_000, + loop_type=loop_type, ) results.append(result) print(f" [done] {result.name}") @@ -202,6 +228,7 @@ async def main(): name="Trivial blocking (getcwd)", task_fn=task_with_getcwd, num_tasks=5_000, + loop_type=loop_type, ) results.append(result) print(f" [done] {result.name}") @@ -211,6 +238,7 @@ async def main(): name="Light blocking (stat)", task_fn=task_with_stat, num_tasks=5_000, + loop_type=loop_type, ) results.append(result) print(f" [done] {result.name}") @@ -220,6 +248,7 @@ async def main(): name="Moderate blocking (file read)", task_fn=task_with_file_read, num_tasks=2_000, + loop_type=loop_type, ) results.append(result) print(f" [done] {result.name}") @@ -229,20 +258,116 @@ async def main(): name="Realistic HTTP handler", task_fn=realistic_http_handler, num_tasks=500, + loop_type=loop_type, ) results.append(result) print(f" [done] {result.name}") + return results + + +def setup_aiocop(): + """Setup aiocop for benchmarking.""" + import aiocop + + aiocop.patch_audit_functions() + aiocop.start_blocking_io_detection(trace_depth=5) + aiocop.detect_slow_tasks(threshold_ms=1000, on_slow_task=noop_callback) + + +def run_asyncio_benchmark() -> list[BenchmarkResult]: + """Run benchmark with standard asyncio.""" + reset_aiocop_state() + setup_aiocop() + + async def main(): + return await run_all_scenarios("asyncio") + + return asyncio.run(main()) + + +def run_uvloop_benchmark() -> list[BenchmarkResult]: + """Run benchmark with uvloop.""" + try: + import uvloop + except ImportError: + print(" [skip] uvloop not available") + return [] + + reset_aiocop_state() + setup_aiocop() + + async def main(): + # Verify we're on uvloop + loop = asyncio.get_running_loop() + assert type(loop).__name__ == "Loop", f"Not running on uvloop: {type(loop)}" + return await run_all_scenarios("uvloop") + + return uvloop.run(main()) + + +def main(): + parser = argparse.ArgumentParser(description="aiocop Performance Benchmark") + parser.add_argument("--asyncio-only", action="store_true", help="Only run asyncio benchmark") + parser.add_argument("--uvloop-only", action="store_true", help="Only run uvloop benchmark") + args = parser.parse_args() + + import aiocop + + print("") + print("aiocop Performance Benchmark") + print("=" * 50) + print("") + + results: list[BenchmarkResult] = [] + + # Run asyncio benchmark + if not args.uvloop_only: + print("Running asyncio benchmarks...") + results.extend(run_asyncio_benchmark()) + print("") + + # Run uvloop benchmark + if not args.asyncio_only: + print("Running uvloop benchmarks...") + uvloop_results = run_uvloop_benchmark() + results.extend(uvloop_results) + print("") + # Print results - print(format_results(results)) + print("") + print("=" * 70) + print("aiocop Benchmark Results") + print("=" * 70) + + if not args.uvloop_only: + print(format_results(results, "asyncio")) + + if not args.asyncio_only and any(r.loop_type == "uvloop" for r in results): + print(format_results(results, "uvloop")) - # Print system info + # Print comparison if both were run + if not args.asyncio_only and not args.uvloop_only: + print(format_comparison(results)) + + print("") + print("-" * 70) + print("") print("System Info:") print(f" Python: {sys.version.split()[0]}") print(f" Platform: {sys.platform}") print(f" aiocop: {aiocop.__version__}") + + # Check uvloop version if available + try: + import uvloop + + print(f" uvloop: {uvloop.__version__}") + except ImportError: + pass + print("") if __name__ == "__main__": - asyncio.run(main()) + main() diff --git a/docs/api.md b/docs/api.md index 74b6272..103407d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -42,7 +42,7 @@ aiocop.detect_slow_tasks( ) -> None ``` -Patches the asyncio event loop to detect slow tasks. +Patches the event loop to detect slow tasks. Works with both standard asyncio and uvloop. **Parameters:** - `threshold_ms`: Threshold in milliseconds. Tasks exceeding this have `exceeded_threshold=True`. Default: 30. diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 229117f..c4b31da 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -2,8 +2,12 @@ aiocop is designed to be production-safe with minimal overhead. This page documents performance characteristics and how to run benchmarks yourself. +aiocop works with both standard asyncio and uvloop, with similar low overhead on both. + ## Summary +### asyncio + | Scenario | Per-Task Overhead | Impact on 50ms Request | |----------|-------------------|------------------------| | Pure async (no blocking I/O) | ~1 us | 0.002% | @@ -11,7 +15,16 @@ aiocop is designed to be production-safe with minimal overhead. This page docume | Moderate blocking (file read) | ~12 us | 0.02% | | Realistic HTTP handler | ~22 us | 0.04% | -**Bottom line:** aiocop adds ~13 microseconds per task on average. For typical web applications where requests take 10-100ms, this translates to **less than 0.05% overhead**. +### uvloop + +| Scenario | Per-Task Overhead | Impact on 50ms Request | +|----------|-------------------|------------------------| +| Pure async (no blocking I/O) | ~3 us | 0.006% | +| Light blocking (os.stat) | ~29 us | 0.06% | +| Moderate blocking (file read) | ~17 us | 0.03% | +| Realistic HTTP handler | ~56 us | 0.11% | + +**Bottom line:** aiocop adds ~13 microseconds per task on asyncio and ~27 microseconds on uvloop. For typical web applications where requests take 10-100ms, this translates to **less than 0.1% overhead** on either event loop. ## Understanding the Numbers @@ -41,11 +54,14 @@ If your application: Run the included benchmark script: ```bash -# With uv +# Run both asyncio and uvloop benchmarks uv run python benchmarks/run_benchmark.py -# Or directly -python benchmarks/run_benchmark.py +# Run only asyncio benchmarks +uv run python benchmarks/run_benchmark.py --asyncio-only + +# Run only uvloop benchmarks +uv run python benchmarks/run_benchmark.py --uvloop-only ``` ### Sample Output @@ -55,10 +71,10 @@ python benchmarks/run_benchmark.py aiocop Benchmark Results ====================================================================== -Per-Task Overhead (lower is better): - - Scenario Overhead Impact on 50ms request - ---------------------------------------- --------------- ---------------------- +ASYNCIO Results: +---------------------------------------------------------------------- + Scenario Overhead Impact on 50ms + ---------------------------------------- --------------- -------------- Pure async (no blocking) 1.2 us 0.002% Trivial blocking (getcwd) 15.3 us 0.031% Light blocking (stat) 13.5 us 0.027% @@ -67,12 +83,30 @@ Per-Task Overhead (lower is better): Average: 12.8 us per task (0.026% on 50ms request) +UVLOOP Results: ---------------------------------------------------------------------- + Scenario Overhead Impact on 50ms + ---------------------------------------- --------------- -------------- + Pure async (no blocking) 2.7 us 0.005% + Trivial blocking (getcwd) 29.5 us 0.059% + Light blocking (stat) 28.4 us 0.057% + Moderate blocking (file read) 16.8 us 0.034% + Realistic HTTP handler 56.4 us 0.113% -What this means: - - Each async task adds ~13 microseconds of overhead - - A typical 50ms HTTP request sees 0.03% overhead - - A typical 100ms database query sees 0.01% overhead + Average: 26.8 us per task (0.054% on 50ms request) + +====================================================================== +COMPARISON: asyncio vs uvloop +====================================================================== + Scenario asyncio uvloop Difference + ------------------------------ ------------ ------------ ------------ + Pure async (no blocking) 1.2 us 2.7 us +1.5 us + Trivial blocking (getcwd) 15.3 us 29.5 us +14.2 us + Light blocking (stat) 13.5 us 28.4 us +14.9 us + Moderate blocking (file read) 12.3 us 16.8 us +4.5 us + Realistic HTTP handler 21.6 us 56.4 us +34.8 us + + Average 12.8 us 26.8 us +14.0 us ``` ## Tuning for Performance diff --git a/docs/guide.md b/docs/guide.md index 9a31b71..fa2aa5f 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -23,14 +23,14 @@ aiocop uses three mechanisms to detect blocking I/O: 2. **Audit Hook Registration** (`start_blocking_io_detection`): Registers a `sys.audit` hook that listens for blocking I/O events and captures stack traces. -3. **Event Loop Patching** (`detect_slow_tasks`): Patches `asyncio.Handle._run` to measure task execution time and invoke callbacks when blocking is detected. +3. **Event Loop Patching** (`detect_slow_tasks`): Patches the event loop's scheduling methods (`call_soon`, `call_later`, `call_at`) to measure task execution time and invoke callbacks when blocking is detected. This approach works with both standard asyncio and uvloop. ![aiocop architecture diagram](images/explanation_diagram.png) The diagram above shows the complete flow: -1. The **Event Loop** schedules a task via `Handle._run` -2. **aiocop's wrapper** starts a timer and creates an events list +1. The **Event Loop** schedules a callback via `call_soon` (or similar) +2. **aiocop's wrapper** intercepts the callback, starts a timer and creates an events list 3. Your **task code** executes 4. When a blocking function (like `open()`) is called, the **Python VM** (for native functions) or **aiocop's wrapper** (for patched functions) emits a `sys.audit` event 5. The **audit hook** captures the event and stack trace, appending it to the events list diff --git a/docs/images/explanation_diagram.png b/docs/images/explanation_diagram.png index 70a40c7..0e35a43 100644 Binary files a/docs/images/explanation_diagram.png and b/docs/images/explanation_diagram.png differ diff --git a/docs/index.md b/docs/index.md index 2026000..2eb96aa 100644 --- a/docs/index.md +++ b/docs/index.md @@ -45,6 +45,7 @@ aiocop.activate() ## Features - **Production-Safe**: Minimal runtime overhead using Python's audit hooks +- **asyncio & uvloop**: Works with both standard asyncio and uvloop out of the box - **Blocking I/O Detection**: Detects file operations, network calls, subprocess, `time.sleep`, and more - **Stack Trace Capture**: Full stack traces to pinpoint blocking calls - **Severity Scoring**: Prioritize fixes based on impact diff --git a/docs/installation.md b/docs/installation.md index d7fd216..5833c41 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -4,6 +4,7 @@ - **Python 3.10 or higher** - No additional dependencies required +- **uvloop** (optional): aiocop works with both standard asyncio and uvloop out of the box ## Install from PyPI diff --git a/pyproject.toml b/pyproject.toml index 88dd210..0e334cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,9 +4,10 @@ build-backend = "hatchling.build" [project] name = "aiocop" -version = "1.0.1" -description = "Non-intrusive monitoring for Python asyncio. Detects, pinpoints, and logs blocking IO and CPU calls that freeze your event loop." +version = "1.1.0" +description = "Non-intrusive monitoring for Python asyncio and uvloop. Detects, pinpoints, and logs blocking IO and CPU calls that freeze your event loop." readme = "README.md" +keywords = ["asyncio", "uvloop", "monitoring", "blocking-io", "event-loop", "debugging", "performance"] authors = [ {name = "Fever Labs, Inc.", email = "engineering@feverup.com"}, {name = "Adrià Ardila", email = "adria.ardila@feverup.com"} @@ -45,6 +46,7 @@ test = [ "ruff", # linting "ty", # checking types "ipdb", # debugging + "uvloop; sys_platform != 'win32'", # uvloop testing (Unix only) ] docs = [ "mkdocs-material", # documentation diff --git a/tests/test_uvloop.py b/tests/test_uvloop.py new file mode 100644 index 0000000..46fcb4a --- /dev/null +++ b/tests/test_uvloop.py @@ -0,0 +1,274 @@ +"""Tests for uvloop compatibility. + +These tests verify that aiocop works correctly with uvloop. +They are skipped on Windows or if uvloop is not installed. + +Note: pytest-asyncio manages its own event loops, so we use uvloop.run() +for tests that need to verify uvloop-specific behavior. +""" + +import asyncio +import sys +import tempfile +import time +from pathlib import Path +from typing import Any + +import pytest + +# Skip entire module on Windows or if uvloop not available +uvloop = pytest.importorskip("uvloop", reason="uvloop not available") + +if sys.platform == "win32": + pytest.skip("uvloop not supported on Windows", allow_module_level=True) + +import aiocop # noqa: E402 +from aiocop.core import slow_tasks, state # noqa: E402 +from aiocop.types.events import SlowTaskEvent # noqa: E402 + +# ============================================================================= +# Fixtures +# ============================================================================= + + +@pytest.fixture(autouse=True) +def reset_aiocop_state(): + """Reset aiocop state before and after each test. + + This is critical for uvloop tests since we need fresh state + for each test to properly test loop patching. + """ + # Reset before test + state._monitoring_active = False + state._on_activate_hooks.clear() + slow_tasks._detect_slow_tasks_configured = False + aiocop.clear_slow_task_callbacks() + aiocop.clear_context_providers() + + yield + + # Reset after test + state._monitoring_active = False + state._on_activate_hooks.clear() + slow_tasks._detect_slow_tasks_configured = False + aiocop.clear_slow_task_callbacks() + aiocop.clear_context_providers() + + +# ============================================================================= +# Helper to run async code with uvloop +# ============================================================================= + + +def run_with_uvloop(coro): + """Run a coroutine using uvloop.run() to ensure uvloop is used.""" + return uvloop.run(coro) + + +# ============================================================================= +# Tests using uvloop.run() directly +# ============================================================================= + + +class TestUvloopWithRun: + """Tests that use uvloop.run() to ensure uvloop is actually used.""" + + def test_uvloop_run_uses_uvloop(self): + """Verify that uvloop.run() actually uses uvloop.""" + async def check_loop(): + loop = asyncio.get_running_loop() + return type(loop).__name__ + + loop_type = run_with_uvloop(check_loop()) + assert loop_type == "Loop", f"Expected uvloop.Loop, got {loop_type}" + + def test_detects_blocking_io_with_uvloop_run(self, reset_aiocop_state): + """Test blocking IO detection using uvloop.run().""" + captured_events: list[SlowTaskEvent] = [] + + def callback(event: SlowTaskEvent) -> None: + captured_events.append(event) + + async def main(): + aiocop.patch_audit_functions() + aiocop.start_blocking_io_detection(trace_depth=10) + aiocop.detect_slow_tasks(threshold_ms=10) + aiocop.register_slow_task_callback(callback) + aiocop.activate() + + # Verify we're on uvloop + loop = asyncio.get_running_loop() + assert type(loop).__name__ == "Loop", "Not running on uvloop!" + + async def task_with_sleep(): + time.sleep(0.02) + + task = asyncio.create_task(task_with_sleep()) + await task + await asyncio.sleep(0) + + run_with_uvloop(main()) + + assert len(captured_events) >= 1 + event = captured_events[0] + assert event.reason == "io_blocking" + event_names = [e["event"] for e in event.blocking_events] + assert any("time.sleep" in name for name in event_names) + + def test_loop_is_patched_with_uvloop_run(self, reset_aiocop_state): + """Test that uvloop loop gets patched correctly.""" + async def main(): + aiocop.patch_audit_functions() + aiocop.start_blocking_io_detection(trace_depth=10) + aiocop.detect_slow_tasks(threshold_ms=10) + aiocop.activate() + + loop = asyncio.get_running_loop() + assert type(loop).__name__ == "Loop", "Not running on uvloop!" + assert hasattr(loop, "_aiocop_patched") + assert loop._aiocop_patched is True + + run_with_uvloop(main()) + + def test_context_providers_with_uvloop_run(self, reset_aiocop_state): + """Test context providers work with uvloop.""" + captured_events: list[SlowTaskEvent] = [] + + def callback(event: SlowTaskEvent) -> None: + captured_events.append(event) + + def my_context_provider() -> dict[str, Any]: + return {"uvloop_test": True} + + async def main(): + aiocop.patch_audit_functions() + aiocop.start_blocking_io_detection(trace_depth=10) + aiocop.detect_slow_tasks(threshold_ms=10) + aiocop.register_slow_task_callback(callback) + aiocop.register_context_provider(my_context_provider) + aiocop.activate() + + async def task_with_io(): + time.sleep(0.015) + + task = asyncio.create_task(task_with_io()) + await task + await asyncio.sleep(0) + + run_with_uvloop(main()) + + assert len(captured_events) >= 1 + assert captured_events[0].context.get("uvloop_test") is True + + def test_severity_calculation_with_uvloop_run(self, reset_aiocop_state): + """Test severity is calculated correctly with uvloop.""" + captured_events: list[SlowTaskEvent] = [] + + def callback(event: SlowTaskEvent) -> None: + captured_events.append(event) + + async def main(): + aiocop.patch_audit_functions() + aiocop.start_blocking_io_detection(trace_depth=10) + aiocop.detect_slow_tasks(threshold_ms=10) + aiocop.register_slow_task_callback(callback) + aiocop.activate() + + async def task_with_heavy_io(): + time.sleep(0.015) # WEIGHT_HEAVY = 50 + + task = asyncio.create_task(task_with_heavy_io()) + await task + await asyncio.sleep(0) + + run_with_uvloop(main()) + + assert len(captured_events) >= 1 + event = captured_events[0] + assert event.severity_score >= 50 + assert event.severity_level == "high" + + def test_file_io_detection_with_uvloop_run(self, reset_aiocop_state): + """Test file IO is detected with uvloop.""" + captured_events: list[SlowTaskEvent] = [] + + def callback(event: SlowTaskEvent) -> None: + captured_events.append(event) + + async def main(): + aiocop.patch_audit_functions() + aiocop.start_blocking_io_detection(trace_depth=10) + aiocop.detect_slow_tasks(threshold_ms=10) + aiocop.register_slow_task_callback(callback) + aiocop.activate() + + async def task_with_file_io(): + with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: + f.write("test") + temp_path = f.name + Path(temp_path).unlink() + + task = asyncio.create_task(task_with_file_io()) + await task + await asyncio.sleep(0) + + run_with_uvloop(main()) + + assert len(captured_events) >= 1 + event = captured_events[0] + assert event.reason == "io_blocking" + event_names = [e["event"] for e in event.blocking_events] + assert any("open" in name or "tempfile" in name for name in event_names) + + def test_deactivated_no_detection_with_uvloop_run(self, reset_aiocop_state): + """Test no detection when deactivated with uvloop.""" + captured_events: list[SlowTaskEvent] = [] + + def callback(event: SlowTaskEvent) -> None: + captured_events.append(event) + + async def main(): + aiocop.patch_audit_functions() + aiocop.start_blocking_io_detection(trace_depth=10) + aiocop.detect_slow_tasks(threshold_ms=10) + aiocop.register_slow_task_callback(callback) + # Note: NOT calling aiocop.activate() + + async def task_with_io(): + time.sleep(0.015) + + task = asyncio.create_task(task_with_io()) + await task + await asyncio.sleep(0) + + run_with_uvloop(main()) + + assert len(captured_events) == 0 + + def test_exceeded_threshold_flag_with_uvloop_run(self, reset_aiocop_state): + """Test exceeded_threshold flag is set correctly with uvloop.""" + captured_events: list[SlowTaskEvent] = [] + + def callback(event: SlowTaskEvent) -> None: + captured_events.append(event) + + async def main(): + aiocop.patch_audit_functions() + aiocop.start_blocking_io_detection(trace_depth=10) + aiocop.detect_slow_tasks(threshold_ms=10) + aiocop.register_slow_task_callback(callback) + aiocop.activate() + + async def slow_task(): + time.sleep(0.02) # 20ms, threshold is 10ms + + task = asyncio.create_task(slow_task()) + await task + await asyncio.sleep(0) + + run_with_uvloop(main()) + + assert len(captured_events) >= 1 + event = captured_events[0] + assert event.exceeded_threshold is True + assert event.elapsed_ms >= 10 diff --git a/uv.lock b/uv.lock index 8e605cf..f1dbcce 100644 --- a/uv.lock +++ b/uv.lock @@ -8,7 +8,7 @@ resolution-markers = [ [[package]] name = "aiocop" -version = "1.0.1" +version = "1.1.0" source = { editable = "." } [package.optional-dependencies] @@ -22,6 +22,7 @@ test = [ { name = "pytest-asyncio" }, { name = "ruff" }, { name = "ty" }, + { name = "uvloop", marker = "sys_platform != 'win32'" }, ] [package.metadata] @@ -33,6 +34,7 @@ requires-dist = [ { name = "pytest-asyncio", marker = "extra == 'test'" }, { name = "ruff", marker = "extra == 'test'" }, { name = "ty", marker = "extra == 'test'" }, + { name = "uvloop", marker = "sys_platform != 'win32' and extra == 'test'" }, ] provides-extras = ["test", "docs"] @@ -1042,6 +1044,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/14/ecceb239b65adaaf7fde510aa8bd534075695d1e5f8dadfa32b5723d9cfb/uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c", size = 1343335, upload-time = "2025-10-16T22:16:11.43Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ae/6f6f9af7f590b319c94532b9567409ba11f4fa71af1148cab1bf48a07048/uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792", size = 742903, upload-time = "2025-10-16T22:16:12.979Z" }, + { url = "https://files.pythonhosted.org/packages/09/bd/3667151ad0702282a1f4d5d29288fce8a13c8b6858bf0978c219cd52b231/uvloop-0.22.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86", size = 3648499, upload-time = "2025-10-16T22:16:14.451Z" }, + { url = "https://files.pythonhosted.org/packages/b3/f6/21657bb3beb5f8c57ce8be3b83f653dd7933c2fd00545ed1b092d464799a/uvloop-0.22.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd", size = 3700133, upload-time = "2025-10-16T22:16:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/09/e0/604f61d004ded805f24974c87ddd8374ef675644f476f01f1df90e4cdf72/uvloop-0.22.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2", size = 3512681, upload-time = "2025-10-16T22:16:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ce/8491fd370b0230deb5eac69c7aae35b3be527e25a911c0acdffb922dc1cd/uvloop-0.22.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec", size = 3615261, upload-time = "2025-10-16T22:16:19.596Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + [[package]] name = "watchdog" version = "6.0.0"