diff --git a/.gitignore b/.gitignore index 9c5befa4f..7472fd6ca 100644 --- a/.gitignore +++ b/.gitignore @@ -148,3 +148,6 @@ tests/integration/.binding-data/ # Crypto test material generated at test time (see tests/crypto_utils.py) tests/integration/keys/ + +# Generated benchmark report (machine-specific, regenerated by bench_async_activities.py) +ext/dapr-ext-workflow/benchmarks/RESULTS.md diff --git a/examples/workflow/async_activities.py b/examples/workflow/async_activities.py new file mode 100644 index 000000000..9c445c826 --- /dev/null +++ b/examples/workflow/async_activities.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 The Dapr Authors +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Async activities running alongside sync ones in a single workflow. + +Starts three async activities that do an HTTP request, then a sync activity that +sums up the results. Shows that sync and async activities work side by side. + +Run with: + + dapr run --app-id async-activities --app-protocol grpc --dapr-grpc-port 50001 \\ + -- python async_activities.py +""" + +from __future__ import annotations + +from time import sleep + +import dapr.ext.workflow as wf +import httpx +from pydantic import BaseModel + +wfr = wf.WorkflowRuntime() + + +class FetchRequest(BaseModel): + url: str + timeout_seconds: float = 5.0 + + +class FetchResult(BaseModel): + url: str + status_code: int + body_length: int + + +@wfr.workflow(name='parallel_fetch_workflow') +def parallel_fetch_workflow(ctx: wf.DaprWorkflowContext, urls: list[str]): + fetch_tasks = [ + ctx.call_activity(fetch_url, input=FetchRequest(url=url).model_dump()) for url in urls + ] + results = yield wf.when_all(fetch_tasks) + summary = yield ctx.call_activity(summarize_fetches, input=results) + return summary + + +@wfr.activity(name='fetch_url') +async def fetch_url(ctx: wf.WorkflowActivityContext, request: FetchRequest) -> dict: + """Async activity: fetch a URL with httpx. Multiple instances run concurrently.""" + async with httpx.AsyncClient(timeout=request.timeout_seconds) as client: + response = await client.get(request.url) + result = FetchResult( + url=request.url, + status_code=response.status_code, + body_length=len(response.content), + ) + print( + f'[async] fetched {result.url} -> {result.status_code} ({result.body_length}B)', flush=True + ) + return result.model_dump() + + +@wfr.activity(name='summarize_fetches') +def summarize_fetches(ctx: wf.WorkflowActivityContext, results: list[dict]) -> str: + """Sync activity: runs in the sync-fallback thread pool. Unchanged from before.""" + total_bytes = sum(r['body_length'] for r in results) + summary = f'fetched {len(results)} URLs, total {total_bytes} bytes' + print(f'[sync] {summary}', flush=True) + return summary + + +def main() -> None: + urls = [ + 'https://example.com', + 'https://example.org', + 'https://example.net', + ] + + wfr.start() + sleep(5) # wait for workflow runtime to start + + wf_client = wf.DaprWorkflowClient() + instance_id = wf_client.schedule_new_workflow(workflow=parallel_fetch_workflow, input=urls) + print(f'Workflow started. Instance ID: {instance_id}') + + state = wf_client.wait_for_workflow_completion(instance_id, timeout_in_seconds=60) + assert state is not None + print(f'Workflow completed! Status: {state.runtime_status.name}') + print(f'Workflow result: {state.serialized_output.strip(chr(34))}') + + wfr.shutdown() + + +if __name__ == '__main__': + main() diff --git a/ext/dapr-ext-workflow/AGENTS.md b/ext/dapr-ext-workflow/AGENTS.md index 635cb8705..8054287c7 100644 --- a/ext/dapr-ext-workflow/AGENTS.md +++ b/ext/dapr-ext-workflow/AGENTS.md @@ -105,6 +105,24 @@ The entry point for registration and lifecycle: Internally wraps user functions: workflow functions get a `DaprWorkflowContext`, activity functions get a `WorkflowActivityContext`. Tracks registration state via `_workflow_registered` / `_activity_registered` attributes on functions to prevent double registration. +#### Sync and async activities + +Activities can be either `def my_activity(ctx, inp)` or `async def my_activity(ctx, inp)`. At registration, `_make_activity_wrapper` calls `_is_async_callable(fn)` to detect async-ness. That helper unwraps `functools.partial`, `@functools.wraps` chains, and callable-class `__call__` so common decorator patterns route correctly. The wrapper is built `async def` or `def` to match, then stored in the registry. + +At dispatch time (the gRPC stream loop in `_durabletask/worker.py`), `is_async_callable(activity_fn)` on the wrapper selects between two handlers. + +- **Async activities** go through `_execute_activity_async`, then `_ActivityExecutor.execute_async`, which awaits `fn(...)` directly on the event loop. The gRPC response is delivered via `loop.run_in_executor(self._async_worker_manager.thread_pool, stub.CompleteActivityTask, ...)` — the same pool sync activities use, sized by `maximum_thread_pool_workers`. +- **Sync activities** go through `_execute_activity`, dispatched to the thread pool by `_AsyncWorkerManager._run_func`. The activity runs on a worker thread, and the response is delivered from the same thread. + +Workflow (orchestrator) functions must remain generators (`def` with `yield`). They cannot be `async def` because durabletask's deterministic replay depends on synchronous generator semantics. Only activities support async. + +**Decorator ordering gotcha.** Stacking `@wfr.activity` over `@alternate_name(...)` over `async def` works because `@alternate_name` now emits an `async def innerfn` when the wrapped function is async. A user-written decorator that wraps an async function in a sync `def` (without `@functools.wraps` exposing `__wrapped__`) defeats `_is_async_callable`, routes the activity to the sync path, and produces an un-awaited coroutine. Such decorators should use `@functools.wraps(fn)` so the unwrap walks through them. + +**`maximum_thread_pool_workers` covers both paths.** This knob sizes the worker thread pool used for sync-activity bodies and for async-activity gRPC response sends. Mixed workloads with long-running sync activities can starve async response delivery (and vice versa) since they share the pool — size to the sum of peak sync activity concurrency and peak in-flight async response sends. + +**Concurrency sizing and load characterization.** See `docs/concurrency.md` for sizing recommendations (`maximum_concurrent_activity_work_items`, `maximum_thread_pool_workers`) and an async-vs-sync decision tree. The `benchmarks/` directory ships `bench_async_activities.py`; re-run it locally before claiming a perf regression. The generated `RESULTS.md` is gitignored because numbers are machine-specific; see `docs/concurrency.md` for the regen command. + + ### DaprWorkflowClient (`dapr_workflow_client.py`) Client for workflow lifecycle management: @@ -163,7 +181,7 @@ Retry configuration for activities and child workflows: 1. **Registration**: User decorates functions with `@wfr.workflow` / `@wfr.activity`. The runtime wraps them and stores them in the durabletask worker's registry. 2. **Startup**: `wfr.start()` opens a gRPC stream to the Dapr sidecar. The worker polls for work items. 3. **Scheduling**: Client calls `schedule_new_workflow(fn, input=...)`. The function's name (or `_dapr_alternate_name`) is sent to the backend. -4. **Execution**: The durabletask engine dispatches work items. Workflow functions are Python **generators** that `yield` tasks (activity calls, timers, child workflows). The engine records history; on replay, yielded tasks return cached results without re-executing. +4. **Execution**: The durabletask engine dispatches work items. Workflow functions are Python **generators** that `yield` tasks (activity calls, timers, child workflows). Activity functions are either sync (dispatched to the worker's thread pool) or `async def` (awaited directly on the worker's event loop). The engine records history; on replay, yielded tasks return cached results without re-executing. 5. **Determinism**: Workflows must be deterministic — no random, no wall-clock time, no I/O. Use `ctx.current_utc_datetime` instead of `datetime.now()`. Use `ctx.is_replaying` to guard side effects like logging. 6. **Completion**: Client polls via `wait_for_workflow_completion()` or `get_workflow_state()`. @@ -191,6 +209,7 @@ Two example directories exercise workflows: - `cross-app1.py`, `cross-app2.py`, `cross-app3.py` — cross-app calls - `versioning.py` — workflow versioning with `is_patched()` - `simple_aio_client.py` — async client variant + - `async_activities.py` — `async def` activities (HTTP fan-out with `httpx.AsyncClient`) ## Testing diff --git a/ext/dapr-ext-workflow/benchmarks/bench_async_activities.py b/ext/dapr-ext-workflow/benchmarks/bench_async_activities.py new file mode 100644 index 000000000..65b42faea --- /dev/null +++ b/ext/dapr-ext-workflow/benchmarks/bench_async_activities.py @@ -0,0 +1,1457 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 The Dapr Authors +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Async-activity load benchmarks for ``dapr-ext-workflow``. + +Drives the production dispatch path (``TaskHubGrpcWorker._execute_activity_async`` +and ``_execute_activity``) through ``_AsyncWorkerManager`` against a mock sidecar +stub. Captures end-to-end latency (submit -> response delivery), peak in-flight +Tasks, peak RSS, and steady-state behavior so the sidecar response path is part +of the measurement instead of being skipped. + +Run: + + uv run python ext/dapr-ext-workflow/benchmarks/bench_async_activities.py + +Set ``DAPR_BENCH_SUSTAINED_SECONDS`` to override the 120 s sustained run. +Set ``DAPR_BENCH_WITH_SIDECAR=1`` to run the opt-in end-to-end scenario against +a real Dapr sidecar (requires ``dapr run`` wrapping the script). + +Writes ``benchmarks/RESULTS.md`` and asserts pass-criteria budgets so regressions +fail loudly. +""" + +from __future__ import annotations + +import asyncio +import logging +import math +import os +import platform +import shutil +import socket +import statistics +import subprocess +import sys +import time +from contextlib import asynccontextmanager +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import AsyncIterator, Awaitable, Callable + +import dapr.ext.workflow._durabletask.internal.protos as pb +import httpx +from aiohttp import web +from dapr.ext.workflow._durabletask import task +from dapr.ext.workflow._durabletask.worker import ( + ConcurrencyOptions, + TaskHubGrpcWorker, + _AsyncWorkerManager, +) + +LOGGER = logging.getLogger('bench') +RESULTS_PATH = Path(__file__).parent / 'RESULTS.md' +IS_DARWIN = sys.platform == 'darwin' + +SUSTAINED_DURATION_S = float(os.environ.get('DAPR_BENCH_SUSTAINED_SECONDS', '120')) + + +# ============================================================================ +# Data classes +# ============================================================================ + + +@dataclass(slots=True) +class LatencyStats: + """Summary statistics for a population of end-to-end latency samples.""" + + count: int + mean_ms: float + p50_ms: float + p95_ms: float + p99_ms: float + max_ms: float + + @classmethod + def from_samples(cls, samples_s: list[float]) -> 'LatencyStats': + if not samples_s: + return cls(count=0, mean_ms=0.0, p50_ms=0.0, p95_ms=0.0, p99_ms=0.0, max_ms=0.0) + samples_ms = sorted(s * 1000.0 for s in samples_s) + return cls( + count=len(samples_ms), + mean_ms=statistics.fmean(samples_ms), + p50_ms=_percentile(samples_ms, 0.50), + p95_ms=_percentile(samples_ms, 0.95), + p99_ms=_percentile(samples_ms, 0.99), + max_ms=samples_ms[-1], + ) + + +@dataclass(slots=True) +class ScenarioMetrics: + """Per-scenario summary written to the results table.""" + + name: str + n_items: int + semaphore_cap: int + thread_pool_workers: int + server_latency_s: float + wallclock_s: float + throughput_per_s: float + latency: LatencyStats + peak_tasks: int + peak_queue_depth: int + peak_rss_delta_mb: float + notes: str = '' + + +@dataclass +class _Sampler: + """Background sampler for in-flight task count, queue depth, and RSS.""" + + interval_s: float = 0.05 + peak_tasks: int = 0 + peak_rss_kb: int = 0 + peak_queue_depth: int = 0 + _queues: list[asyncio.Queue] = field(default_factory=list) + _stop_event: asyncio.Event = field(default_factory=asyncio.Event) + + def watch_queue(self, q: asyncio.Queue | None) -> None: + if q is not None: + self._queues.append(q) + + async def run(self) -> None: + while not self._stop_event.is_set(): + self.peak_tasks = max(self.peak_tasks, len(asyncio.all_tasks())) + self.peak_rss_kb = max(self.peak_rss_kb, _current_rss_kb()) + for q in self._queues: + self.peak_queue_depth = max(self.peak_queue_depth, q.qsize()) + try: + await asyncio.wait_for(self._stop_event.wait(), timeout=self.interval_s) + except asyncio.TimeoutError: + continue + + def stop(self) -> None: + self._stop_event.set() + + +# ============================================================================ +# Helpers +# ============================================================================ + + +def _percentile(sorted_samples_ms: list[float], q: float) -> float: + if not sorted_samples_ms: + return 0.0 + if len(sorted_samples_ms) == 1: + return sorted_samples_ms[0] + pos = q * (len(sorted_samples_ms) - 1) + lo = math.floor(pos) + hi = math.ceil(pos) + if lo == hi: + return sorted_samples_ms[lo] + frac = pos - lo + return sorted_samples_ms[lo] + frac * (sorted_samples_ms[hi] - sorted_samples_ms[lo]) + + +try: + import resource as _resource # POSIX only +except ImportError: + _resource = None + + +def _current_rss_kb() -> int: + """Process RSS in KB. macOS returns bytes from getrusage; Linux returns KB. + Returns 0 on Windows since `resource` is unavailable there. + """ + if _resource is None: + return 0 + rss = _resource.getrusage(_resource.RUSAGE_SELF).ru_maxrss + if IS_DARWIN: + return rss // 1024 + return rss + + +def _read_text(path: str) -> str: + try: + return Path(path).read_text(encoding='utf-8', errors='ignore') + except OSError: + return '' + + +def _cpu_model() -> str: + """Best-effort CPU model name. Cross-platform; returns a placeholder on failure.""" + if IS_DARWIN: + sysctl = shutil.which('sysctl') + if sysctl is not None: + try: + out = subprocess.run( + [sysctl, '-n', 'machdep.cpu.brand_string'], + capture_output=True, + text=True, + timeout=2, + ) + if out.returncode == 0 and out.stdout.strip(): + return out.stdout.strip() + except (subprocess.SubprocessError, OSError): + pass + cpuinfo = _read_text('/proc/cpuinfo') + for line in cpuinfo.splitlines(): + if line.startswith('model name'): + return line.split(':', 1)[1].strip() + return platform.processor() or platform.machine() or 'unknown' + + +def _total_memory_gb() -> float: + """Best-effort total physical memory in GB. Returns 0 on failure.""" + if IS_DARWIN: + sysctl = shutil.which('sysctl') + if sysctl is not None: + try: + out = subprocess.run( + [sysctl, '-n', 'hw.memsize'], + capture_output=True, + text=True, + timeout=2, + ) + if out.returncode == 0 and out.stdout.strip().isdigit(): + return int(out.stdout.strip()) / (1024**3) + except (subprocess.SubprocessError, OSError): + pass + meminfo = _read_text('/proc/meminfo') + for line in meminfo.splitlines(): + if line.startswith('MemTotal:'): + parts = line.split() + if len(parts) >= 2 and parts[1].isdigit(): + return int(parts[1]) / (1024**2) + return 0.0 + + +def _git_commit() -> str: + """Short git commit hash, or 'unknown' if not in a git repo.""" + git = shutil.which('git') + if git is None: + return 'unknown' + try: + out = subprocess.run( + [git, 'rev-parse', '--short', 'HEAD'], + capture_output=True, + text=True, + timeout=2, + cwd=Path(__file__).parent, + ) + if out.returncode == 0: + commit = out.stdout.strip() + # Mark dirty if there are uncommitted changes. + status = subprocess.run( + [git, 'status', '--porcelain'], + capture_output=True, + text=True, + timeout=2, + cwd=Path(__file__).parent, + ) + if status.returncode == 0 and status.stdout.strip(): + return f'{commit}-dirty' + return commit + except (subprocess.SubprocessError, OSError): + pass + return 'unknown' + + +@dataclass(slots=True) +class RunEnvironment: + """Snapshot of the machine the benchmark ran on.""" + + timestamp_utc: str + git_commit: str + python_version: str + python_implementation: str + platform: str + os_release: str + cpu_model: str + cpu_logical_cores: int + cpu_physical_cores_hint: int + total_memory_gb: float + is_ci: bool + + @classmethod + def capture(cls) -> 'RunEnvironment': + return cls( + timestamp_utc=datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC'), + git_commit=_git_commit(), + python_version=platform.python_version(), + python_implementation=platform.python_implementation(), + platform=platform.platform(), + os_release=f'{platform.system()} {platform.release()} ({platform.machine()})', + cpu_model=_cpu_model(), + cpu_logical_cores=os.cpu_count() or 0, + cpu_physical_cores_hint=os.cpu_count() or 0, + total_memory_gb=_total_memory_gb(), + is_ci=any(os.environ.get(k) for k in ('CI', 'GITHUB_ACTIONS', 'TRAVIS', 'BUILDKITE')), + ) + + +# ============================================================================ +# Mock sidecar stub (production response path goes through here) +# ============================================================================ + + +class _MockSidecarStub: + """In-process stand-in for ``TaskHubSidecarServiceStub``. + + ``_execute_activity_async`` and ``_execute_activity`` deliver responses via + ``stub.CompleteActivityTask``. The mock records completion timestamps so the + harness can compute end-to-end latency (submit -> delivery). ``send_latency_s`` + simulates a slow sidecar — useful for the response-delivery-overhead scenario. + """ + + def __init__(self, send_latency_s: float = 0.0): + self.send_latency_s = send_latency_s + self.completions: dict[int, float] = {} + self.calls = 0 + + def Hello(self, *_args, **_kwargs) -> None: # noqa: N802 + return None + + def CompleteActivityTask(self, response: pb.ActivityResponse) -> None: # noqa: N802 + if self.send_latency_s > 0: + time.sleep(self.send_latency_s) + self.completions[response.taskId] = time.perf_counter() + self.calls += 1 + + def CompleteOrchestratorTask(self, *_args, **_kwargs) -> None: # noqa: N802 + return None + + +def _build_activity_request(name: str, task_id: int, instance_id: str) -> pb.ActivityRequest: + return pb.ActivityRequest( + name=name, + taskId=task_id, + workflowInstance=pb.WorkflowInstance(instanceId=instance_id), + parentTraceContext=pb.TraceContext(traceParent=''), + taskExecutionId='', + ) + + +# ============================================================================ +# Activity factories — record per-invocation timestamps so the harness can +# decompose end-to-end latency into queue-wait / work / delivery. +# ============================================================================ + + +def _async_sleep_factory( + latency_s: float, start_ts: dict[int, float], end_ts: dict[int, float] +) -> Callable[[task.ActivityContext, object], Awaitable[None]]: + """Build an async activity that sleeps. Records per-task start/end timestamps.""" + + async def sleep(ctx: task.ActivityContext, _inp: object) -> None: + start_ts[ctx.task_id] = time.perf_counter() + await asyncio.sleep(latency_s) + end_ts[ctx.task_id] = time.perf_counter() + + return sleep + + +def _sync_sleep_factory( + latency_s: float, start_ts: dict[int, float], end_ts: dict[int, float] +) -> Callable[[task.ActivityContext, object], None]: + """Build a sync activity that sleeps. Records per-task start/end timestamps.""" + + def sleep(ctx: task.ActivityContext, _inp: object) -> None: + start_ts[ctx.task_id] = time.perf_counter() + time.sleep(latency_s) + end_ts[ctx.task_id] = time.perf_counter() + + return sleep + + +def _async_fetch_factory( + url: str, start_ts: dict[int, float], end_ts: dict[int, float] +) -> Callable[[task.ActivityContext, object], Awaitable[int]]: + """Build an async HTTP-fetch activity that mirrors a real user pattern.""" + + async def fetch(ctx: task.ActivityContext, _inp: object) -> int: + start_ts[ctx.task_id] = time.perf_counter() + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url) + end_ts[ctx.task_id] = time.perf_counter() + return response.status_code + + return fetch + + +def _sync_fetch_factory( + url: str, start_ts: dict[int, float], end_ts: dict[int, float] +) -> Callable[[task.ActivityContext, object], int]: + def fetch(ctx: task.ActivityContext, _inp: object) -> int: + start_ts[ctx.task_id] = time.perf_counter() + with httpx.Client(timeout=30.0) as client: + response = client.get(url) + end_ts[ctx.task_id] = time.perf_counter() + return response.status_code + + return fetch + + +@asynccontextmanager +async def _slow_aiohttp_server(latency_s: float) -> AsyncIterator[str]: + """Local aiohttp server that returns JSON after ``latency_s`` seconds.""" + + async def handler(_request: web.Request) -> web.Response: + await asyncio.sleep(latency_s) + return web.json_response({'ok': True, 'latency_s': latency_s}) + + app = web.Application() + app.router.add_get('/', handler) + runner = web.AppRunner(app) + await runner.setup() + + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + listener.bind(('127.0.0.1', 0)) + port = listener.getsockname()[1] + site = web.SockSite(runner, listener) + await site.start() + except BaseException: + listener.close() + raise + base_url = f'http://127.0.0.1:{port}/' + try: + yield base_url + finally: + await runner.cleanup() + + +# ============================================================================ +# Full-path harness — exercises _execute_activity_async / _execute_activity +# through _AsyncWorkerManager with a mock CompleteActivityTask stub. +# ============================================================================ + + +def _build_worker(options: ConcurrencyOptions) -> TaskHubGrpcWorker: + """Build a TaskHubGrpcWorker without calling start(). We only need its dispatch + code and registry; the gRPC stream is replaced by the mock stub. + """ + return TaskHubGrpcWorker( + host_address='in-process-mock', + concurrency_options=options, + ) + + +ActivityFactory = Callable[[dict[int, float], dict[int, float]], Callable[..., object]] + + +async def _run_full( + *, + name: str, + n_items: int, + semaphore_cap: int, + thread_pool_workers: int, + server_latency_s: float, + activity_kind: str, + activity_factory: ActivityFactory | None = None, + send_latency_s: float = 0.0, + notes: str = '', +) -> ScenarioMetrics: + """Submit ``n_items`` activities through the production dispatch path. + + Registers an async or sync activity on the worker's registry, builds real + ``pb.ActivityRequest`` protos, and submits ``_execute_activity_async`` / + ``_execute_activity`` to ``_AsyncWorkerManager``. The mock stub captures the + completion timestamp per task so we can compute end-to-end latency. + + ``activity_factory`` defaults to ``asyncio.sleep`` / ``time.sleep`` (synthetic + work). Pass a custom factory (e.g. ``_async_fetch_factory(url, ...)``) to + exercise real I/O instead. + """ + options = ConcurrencyOptions( + maximum_concurrent_activity_work_items=semaphore_cap, + maximum_concurrent_orchestration_work_items=semaphore_cap, + maximum_thread_pool_workers=thread_pool_workers, + ) + worker = _build_worker(options) + manager = worker._async_worker_manager + stub = _MockSidecarStub(send_latency_s=send_latency_s) + + start_ts: dict[int, float] = {} + end_ts: dict[int, float] = {} + activity_fn: Callable[..., object] + if activity_kind == 'async': + activity_fn = ( + activity_factory(start_ts, end_ts) + if activity_factory is not None + else _async_sleep_factory(server_latency_s, start_ts, end_ts) + ) + handler = worker._execute_activity_async + elif activity_kind == 'sync': + activity_fn = ( + activity_factory(start_ts, end_ts) + if activity_factory is not None + else _sync_sleep_factory(server_latency_s, start_ts, end_ts) + ) + handler = worker._execute_activity + else: + raise ValueError(f'unknown activity_kind: {activity_kind}') + + activity_name = f'bench_{activity_kind}' + worker._registry.add_named_activity(activity_name, activity_fn) + + baseline_rss_kb = _current_rss_kb() + sampler = _Sampler() + sampler_task = asyncio.create_task(sampler.run()) + worker_task = asyncio.create_task(manager.run()) + + # Wait for the manager to set up its activity queue, then attach the sampler. + while manager.activity_queue is None: + await asyncio.sleep(0) + sampler.watch_queue(manager.activity_queue) + + submit_ts: dict[int, float] = {} + submit_start = time.perf_counter() + for i in range(n_items): + req = _build_activity_request(activity_name, task_id=i, instance_id='bench') + submit_ts[i] = time.perf_counter() + manager.submit_activity(handler, activity_fn, req, stub, '') + + await manager.activity_queue.join() + wallclock_s = time.perf_counter() - submit_start + + manager._shutdown = True + sampler.stop() + await asyncio.gather(worker_task, sampler_task, return_exceptions=True) + manager.shutdown() + + e2e_samples: list[float] = [] + for task_id, t_submit in submit_ts.items(): + t_complete = stub.completions.get(task_id) + if t_complete is not None: + e2e_samples.append(t_complete - t_submit) + + throughput = len(e2e_samples) / wallclock_s if wallclock_s > 0 else 0.0 + return ScenarioMetrics( + name=name, + n_items=n_items, + semaphore_cap=semaphore_cap, + thread_pool_workers=thread_pool_workers, + server_latency_s=server_latency_s, + wallclock_s=wallclock_s, + throughput_per_s=throughput, + latency=LatencyStats.from_samples(e2e_samples), + peak_tasks=sampler.peak_tasks, + peak_queue_depth=sampler.peak_queue_depth, + peak_rss_delta_mb=max(0.0, (sampler.peak_rss_kb - baseline_rss_kb) / 1024.0), + notes=notes, + ) + + +# ============================================================================ +# Lite harness — used by the OOM safety test where we just need raw Task +# bookkeeping with no proto/stub overhead. +# ============================================================================ + + +def _make_activity_context(orchestration_id: str, task_id: int) -> task.ActivityContext: + return task.ActivityContext(orchestration_id, task_id, '', propagated_history=None) + + +async def _run_lite( + *, + name: str, + activity: Callable, + n_items: int, + semaphore_cap: int, + thread_pool_workers: int, + server_latency_s: float, + notes: str = '', +) -> ScenarioMetrics: + options = ConcurrencyOptions( + maximum_concurrent_activity_work_items=semaphore_cap, + maximum_concurrent_orchestration_work_items=semaphore_cap, + maximum_thread_pool_workers=thread_pool_workers, + ) + manager = _AsyncWorkerManager(options, logger=LOGGER) + + baseline_rss_kb = _current_rss_kb() + sampler = _Sampler() + sampler_task = asyncio.create_task(sampler.run()) + worker_task = asyncio.create_task(manager.run()) + + while manager.activity_queue is None: + await asyncio.sleep(0) + sampler.watch_queue(manager.activity_queue) + + for i in range(n_items): + ctx = _make_activity_context('bench', i) + manager.submit_activity(activity, ctx, None) + + start = time.perf_counter() + await manager.activity_queue.join() + wallclock_s = time.perf_counter() - start + + manager._shutdown = True + sampler.stop() + await asyncio.gather(worker_task, sampler_task, return_exceptions=True) + manager.shutdown() + + throughput = n_items / wallclock_s if wallclock_s > 0 else 0.0 + return ScenarioMetrics( + name=name, + n_items=n_items, + semaphore_cap=semaphore_cap, + thread_pool_workers=thread_pool_workers, + server_latency_s=server_latency_s, + wallclock_s=wallclock_s, + throughput_per_s=throughput, + latency=LatencyStats.from_samples([]), + peak_tasks=sampler.peak_tasks, + peak_queue_depth=sampler.peak_queue_depth, + peak_rss_delta_mb=max(0.0, (sampler.peak_rss_kb - baseline_rss_kb) / 1024.0), + notes=notes, + ) + + +# ============================================================================ +# Sustained-load harness — open-loop submission at a target rate for D seconds. +# ============================================================================ + + +@dataclass(slots=True) +class SustainedMetrics: + """Steady-state metrics for the sustained-load scenario.""" + + target_rate_per_s: float + duration_s: float + submitted: int + completed: int + wallclock_s: float + throughput_per_s: float + latency_overall: LatencyStats + latency_first_quarter: LatencyStats + latency_last_quarter: LatencyStats + peak_tasks: int + peak_queue_depth: int + peak_rss_delta_mb: float + + +async def _run_sustained( + *, + duration_s: float, + target_rate_per_s: float, + semaphore_cap: int, + thread_pool_workers: int, + server_latency_s: float, + activity_factory: ActivityFactory | None = None, +) -> SustainedMetrics: + """Continuously submit async activities for ``duration_s`` at a target rate. + + Records per-task submit/end timestamps so the harness can split tail latency + by quarter of the run, exposing drift. ``activity_factory`` defaults to + ``asyncio.sleep``; pass an HTTP fetch factory to exercise real I/O. + """ + options = ConcurrencyOptions( + maximum_concurrent_activity_work_items=semaphore_cap, + maximum_concurrent_orchestration_work_items=semaphore_cap, + maximum_thread_pool_workers=thread_pool_workers, + ) + worker = _build_worker(options) + manager = worker._async_worker_manager + stub = _MockSidecarStub() + start_ts: dict[int, float] = {} + end_ts: dict[int, float] = {} + activity_fn = ( + activity_factory(start_ts, end_ts) + if activity_factory is not None + else _async_sleep_factory(server_latency_s, start_ts, end_ts) + ) + activity_name = 'bench_sustained' + worker._registry.add_named_activity(activity_name, activity_fn) + + baseline_rss_kb = _current_rss_kb() + sampler = _Sampler() + sampler_task = asyncio.create_task(sampler.run()) + worker_task = asyncio.create_task(manager.run()) + + while manager.activity_queue is None: + await asyncio.sleep(0) + sampler.watch_queue(manager.activity_queue) + + submit_ts: dict[int, float] = {} + submit_interval = 1.0 / target_rate_per_s + + submitter_done = asyncio.Event() + submitted = 0 + bench_start = time.perf_counter() + + async def submitter() -> None: + nonlocal submitted + try: + next_submit = bench_start + while True: + now = time.perf_counter() + if now - bench_start >= duration_s: + return + if now >= next_submit: + req = _build_activity_request(activity_name, submitted, 'bench-sus') + submit_ts[submitted] = now + manager.submit_activity( + worker._execute_activity_async, activity_fn, req, stub, '' + ) + submitted += 1 + next_submit += submit_interval + continue + wait_s = max(0.0, next_submit - time.perf_counter()) + await asyncio.sleep(wait_s) + finally: + submitter_done.set() + + sub_task = asyncio.create_task(submitter()) + await sub_task + await manager.activity_queue.join() + wallclock_s = time.perf_counter() - bench_start + + manager._shutdown = True + sampler.stop() + await asyncio.gather(worker_task, sampler_task, return_exceptions=True) + manager.shutdown() + + e2e_samples_with_submit: list[tuple[float, float]] = [] + for task_id, t_submit in submit_ts.items(): + t_complete = stub.completions.get(task_id) + if t_complete is not None: + e2e_samples_with_submit.append((t_submit, t_complete - t_submit)) + + e2e_samples_with_submit.sort(key=lambda x: x[0]) + overall = [s for _, s in e2e_samples_with_submit] + quarter_size = max(1, len(overall) // 4) + first_quarter = overall[:quarter_size] + last_quarter = overall[-quarter_size:] + + return SustainedMetrics( + target_rate_per_s=target_rate_per_s, + duration_s=duration_s, + submitted=submitted, + completed=len(overall), + wallclock_s=wallclock_s, + throughput_per_s=len(overall) / wallclock_s if wallclock_s > 0 else 0.0, + latency_overall=LatencyStats.from_samples(overall), + latency_first_quarter=LatencyStats.from_samples(first_quarter), + latency_last_quarter=LatencyStats.from_samples(last_quarter), + peak_tasks=sampler.peak_tasks, + peak_queue_depth=sampler.peak_queue_depth, + peak_rss_delta_mb=max(0.0, (sampler.peak_rss_kb - baseline_rss_kb) / 1024.0), + ) + + +# ============================================================================ +# Scenario runners +# ============================================================================ + + +async def run_concurrency_win() -> list[ScenarioMetrics]: + """Issue #897 repro: async fan-out vs sync baseline at 100 x 1 s activities.""" + server_latency = 1.0 + n_items = 100 + async with _slow_aiohttp_server(server_latency) as url: + start_ts_async: dict[int, float] = {} + end_ts_async: dict[int, float] = {} + async_metrics = await _run_lite( + name='Async fan-out (issue #897 repro)', + activity=_async_fetch_factory(url, start_ts_async, end_ts_async), + n_items=n_items, + semaphore_cap=1000, + thread_pool_workers=8, + server_latency_s=server_latency, + notes='100 awaits run concurrently on the loop', + ) + start_ts_sync: dict[int, float] = {} + end_ts_sync: dict[int, float] = {} + sync_metrics = await _run_lite( + name='Sync baseline (pre-#897 behavior)', + activity=_sync_fetch_factory(url, start_ts_sync, end_ts_sync), + n_items=n_items, + semaphore_cap=1000, + thread_pool_workers=8, + server_latency_s=server_latency, + notes='gated by thread pool size, demonstrates the bug from #897', + ) + return [async_metrics, sync_metrics] + + +async def run_throughput_scaling() -> list[ScenarioMetrics]: + """Vary N at fixed 50 ms server latency. Capture throughput plateau.""" + server_latency = 0.05 + semaphore_cap = 5000 + thread_pool_workers = 16 + grid = [100, 500, 1000, 2500, 5000] + metrics: list[ScenarioMetrics] = [] + for n in grid: + m = await _run_full( + name=f'Throughput N={n}', + n_items=n, + semaphore_cap=semaphore_cap, + thread_pool_workers=thread_pool_workers, + server_latency_s=server_latency, + activity_kind='async', + notes='full _execute_activity_async path + mock CompleteActivityTask', + ) + metrics.append(m) + return metrics + + +async def run_semaphore_sensitivity() -> list[ScenarioMetrics]: + """Vary semaphore cap at fixed N=2500 / 50 ms. Shows cap-side trade-off.""" + server_latency = 0.05 + n_items = 2500 + thread_pool_workers = 16 + grid = [50, 100, 500, 1000, 5000] + metrics: list[ScenarioMetrics] = [] + for cap in grid: + m = await _run_full( + name=f'Sem cap={cap}', + n_items=n_items, + semaphore_cap=cap, + thread_pool_workers=thread_pool_workers, + server_latency_s=server_latency, + activity_kind='async', + notes=( + 'lower caps serialize the batch through fewer parallel slots' + if cap <= 100 + else 'caps above N x latency yield no further gain' + ), + ) + metrics.append(m) + return metrics + + +async def run_failure_threshold() -> list[ScenarioMetrics]: + """Hold cap=1000 / 50 ms and ramp N. The threshold is the first row where + p99 exceeds 2 x server_latency, marking the regime where queue wait + dominates work.""" + server_latency = 0.05 + semaphore_cap = 1000 + thread_pool_workers = 16 + grid = [500, 1000, 2500, 5000, 10000] + metrics: list[ScenarioMetrics] = [] + for n in grid: + m = await _run_full( + name=f'Threshold N={n} (cap={semaphore_cap})', + n_items=n, + semaphore_cap=semaphore_cap, + thread_pool_workers=thread_pool_workers, + server_latency_s=server_latency, + activity_kind='async', + notes='N > cap forces queue wait; p99 grows linearly', + ) + metrics.append(m) + return metrics + + +async def run_sustained_load(duration_s: float = SUSTAINED_DURATION_S) -> SustainedMetrics: + """Open-loop steady-state run at a target rate slightly below peak.""" + return await _run_sustained( + duration_s=duration_s, + target_rate_per_s=200.0, + semaphore_cap=1000, + thread_pool_workers=16, + server_latency_s=0.05, + ) + + +async def run_delivery_overhead() -> list[ScenarioMetrics]: + """Hold workload fixed and vary the simulated sidecar CompleteActivityTask + latency. Quantifies the response-delivery cost added by ``run_in_executor``. + """ + server_latency = 0.05 + n_items = 1000 + semaphore_cap = 1000 + thread_pool_workers = 16 + grid = [0.000, 0.001, 0.005, 0.010] + metrics: list[ScenarioMetrics] = [] + for send_latency in grid: + m = await _run_full( + name=f'Delivery latency={int(send_latency * 1000)}ms', + n_items=n_items, + semaphore_cap=semaphore_cap, + thread_pool_workers=thread_pool_workers, + server_latency_s=server_latency, + activity_kind='async', + send_latency_s=send_latency, + notes='response delivery shares the worker thread pool sized by maximum_thread_pool_workers', + ) + metrics.append(m) + return metrics + + +async def run_oom_safety() -> ScenarioMetrics: + """10 000 in-flight activities with a 1 000-cap semaphore. Validates that the + pile of Tasks parked on the semaphore does not blow up RSS. + """ + server_latency = 0.05 + start_ts: dict[int, float] = {} + end_ts: dict[int, float] = {} + return await _run_lite( + name='OOM safety (10k tasks, 1k semaphore)', + activity=_async_sleep_factory(server_latency, start_ts, end_ts), + n_items=10_000, + semaphore_cap=1000, + thread_pool_workers=8, + server_latency_s=server_latency, + notes='~9k tasks blocked on the semaphore. Peak RSS delta budget is 500 MB.', + ) + + +async def run_real_http_workload() -> list[ScenarioMetrics]: + """Production-shape scenarios driving real ``httpx.AsyncClient`` fetches. + + Mirrors ``examples/workflow/async_activities.py``: each activity opens a fresh + ``AsyncClient`` and GETs a local aiohttp endpoint that sleeps for 50 ms. Uses + the production dispatch path (``_execute_activity_async`` + mock stub) so the + measured latency is submit → response delivery, including TCP, HTTP, JSON + encode/decode, and ``run_in_executor`` for the response send. + + Async fetches at the same grid as the synthetic sweep let users compare + isolated SDK overhead to end-to-end behavior under real I/O. + """ + server_latency = 0.05 + grid = [(100, 1000, 16), (500, 1000, 16), (1000, 1000, 16), (2500, 5000, 16)] + metrics: list[ScenarioMetrics] = [] + async with _slow_aiohttp_server(server_latency) as url: + for n, cap, pool in grid: + async_metrics = await _run_full( + name=f'Real HTTP async N={n}', + n_items=n, + semaphore_cap=cap, + thread_pool_workers=pool, + server_latency_s=server_latency, + activity_kind='async', + activity_factory=lambda s, e, url=url: _async_fetch_factory(url, s, e), + notes='httpx.AsyncClient → aiohttp server (50 ms)', + ) + metrics.append(async_metrics) + # One sync row at N=100 to keep the comparison honest without making the + # bench painful — sync at higher N takes a long wall-clock. + sync_metrics = await _run_full( + name='Real HTTP sync N=100', + n_items=100, + semaphore_cap=1000, + thread_pool_workers=16, + server_latency_s=server_latency, + activity_kind='sync', + activity_factory=lambda s, e, url=url: _sync_fetch_factory(url, s, e), + notes='httpx.Client → aiohttp server, throttled by thread pool', + ) + metrics.append(sync_metrics) + return metrics + + +async def run_real_http_sustained( + duration_s: float = SUSTAINED_DURATION_S, +) -> SustainedMetrics: + """Sustained run mirroring real production: continuous httpx.AsyncClient fetches. + + Same shape as ``run_sustained_load`` but each activity is a real HTTP fetch + against a local aiohttp server, so the numbers reflect a workflow-heavy + deployment doing third-party API calls. + """ + server_latency = 0.05 + async with _slow_aiohttp_server(server_latency) as url: + return await _run_sustained( + duration_s=duration_s, + target_rate_per_s=100.0, + semaphore_cap=1000, + thread_pool_workers=16, + server_latency_s=server_latency, + activity_factory=lambda s, e: _async_fetch_factory(url, s, e), + ) + + +# ============================================================================ +# Report generation +# ============================================================================ + + +def _format_concurrency_table(metrics: list[ScenarioMetrics]) -> str: + header = ( + '| Scenario | N | Sem | Pool | Latency (s) | Wallclock (s) | Tput/s | p50 ms | p95 ms |' + ' p99 ms | Peak tasks | Peak queue | Peak RSS Δ (MB) | Notes |\n' + '| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |\n' + ) + rows = [] + for m in metrics: + rows.append( + f'| {m.name} | {m.n_items} | {m.semaphore_cap} | {m.thread_pool_workers} |' + f' {m.server_latency_s:.3f} | {m.wallclock_s:.2f} | {m.throughput_per_s:.1f} |' + f' {m.latency.p50_ms:.1f} | {m.latency.p95_ms:.1f} | {m.latency.p99_ms:.1f} |' + f' {m.peak_tasks} | {m.peak_queue_depth} | {m.peak_rss_delta_mb:.1f} | {m.notes} |' + ) + return header + '\n'.join(rows) + + +def _format_legacy_table(metrics: list[ScenarioMetrics]) -> str: + """Compatibility table for scenarios without per-item latency (#897 repro, OOM).""" + header = ( + '| Scenario | N | Sem | Pool | Latency (s) | Wallclock (s) | Tput/s | Peak tasks |' + ' Peak queue | Peak RSS Δ (MB) | Notes |\n' + '| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |\n' + ) + rows = [] + for m in metrics: + rows.append( + f'| {m.name} | {m.n_items} | {m.semaphore_cap} | {m.thread_pool_workers} |' + f' {m.server_latency_s:.3f} | {m.wallclock_s:.2f} | {m.throughput_per_s:.1f} |' + f' {m.peak_tasks} | {m.peak_queue_depth} | {m.peak_rss_delta_mb:.1f} | {m.notes} |' + ) + return header + '\n'.join(rows) + + +def _format_sustained_block(m: SustainedMetrics) -> str: + return ( + f'- **Target rate**: {m.target_rate_per_s:.0f}/s for {m.duration_s:.0f} s\n' + f'- **Submitted / completed**: {m.submitted} / {m.completed}\n' + f'- **Wallclock**: {m.wallclock_s:.2f} s (effective throughput' + f' {m.throughput_per_s:.1f}/s)\n' + f'- **Latency (overall)**: p50 {m.latency_overall.p50_ms:.1f} ms,' + f' p95 {m.latency_overall.p95_ms:.1f} ms, p99 {m.latency_overall.p99_ms:.1f} ms,' + f' max {m.latency_overall.max_ms:.1f} ms\n' + f'- **Latency (first 25%)**: p99 {m.latency_first_quarter.p99_ms:.1f} ms\n' + f'- **Latency (last 25%)**: p99 {m.latency_last_quarter.p99_ms:.1f} ms\n' + f'- **Peak tasks**: {m.peak_tasks}, peak queue depth: {m.peak_queue_depth},' + f' peak RSS Δ: {m.peak_rss_delta_mb:.1f} MB\n' + ) + + +def _find_failure_threshold(metrics: list[ScenarioMetrics], baseline_latency_ms: float) -> str: + threshold_factor = 2.0 + threshold_ms = baseline_latency_ms * threshold_factor + for m in metrics: + if m.latency.p99_ms > threshold_ms: + return ( + f'p99 first exceeds {threshold_factor:g}x server latency' + f' ({threshold_ms:.1f} ms) at **N={m.n_items}** with cap={m.semaphore_cap}' + f' (p99 = {m.latency.p99_ms:.1f} ms).' + ) + return ( + f'p99 stayed below {threshold_factor:g}x server latency across the full grid' + f' (max N={metrics[-1].n_items}); the SDK did not degrade in this run.' + ) + + +def _format_environment_block(env: RunEnvironment) -> str: + mem_str = f'{env.total_memory_gb:.1f} GB' if env.total_memory_gb > 0 else 'unknown' + return ( + '## Run environment\n' + '\n' + f'- **Timestamp**: {env.timestamp_utc}\n' + f'- **Git commit**: `{env.git_commit}`\n' + f'- **Python**: {env.python_implementation} {env.python_version}\n' + f'- **OS**: {env.os_release}\n' + f'- **Platform**: `{env.platform}`\n' + f'- **CPU**: {env.cpu_model} ({env.cpu_logical_cores} logical cores)\n' + f'- **Memory**: {mem_str}\n' + f'- **CI environment**: {"yes" if env.is_ci else "no"}\n' + '\n' + '**Numbers from this report are specific to this machine.** Re-run the benchmark' + ' on your hardware before drawing conclusions; on a small CI runner or a busy' + ' workstation they will diverge. The shape of the curves (throughput plateau,' + ' p99 inflection, drift) is what to compare across machines.\n' + ) + + +def _write_results( + *, + env: RunEnvironment, + concurrency: list[ScenarioMetrics], + throughput: list[ScenarioMetrics], + semaphore: list[ScenarioMetrics], + threshold: list[ScenarioMetrics], + delivery: list[ScenarioMetrics], + sustained: SustainedMetrics, + oom: ScenarioMetrics, + real_http: list[ScenarioMetrics], + real_http_sustained: SustainedMetrics, +) -> None: + threshold_summary = _find_failure_threshold( + threshold, baseline_latency_ms=threshold[0].server_latency_s * 1000.0 + ) + body = [ + '# Async-activity load benchmark results', + '', + 'Generated by `bench_async_activities.py`. Re-run with:', + '', + '```bash', + 'uv run python ext/dapr-ext-workflow/benchmarks/bench_async_activities.py', + '```', + '', + _format_environment_block(env), + '', + 'Each scenario drives the production dispatch path' + ' (`TaskHubGrpcWorker._execute_activity_async`) through `_AsyncWorkerManager` against' + ' a mock `CompleteActivityTask` stub. End-to-end latency is measured from `submit_activity`' + ' to the mock stub receiving the response, so queue wait, semaphore acquisition,' + ' activity work, response build, and `run_in_executor` delivery are all included.', + '', + '## 1. Concurrency win (issue #897 repro)', + '', + 'Proves async activities run concurrently on the loop; the sync path is gated by the' + ' thread pool. This row reuses the original repro at 100 × 1 s HTTP fetches.', + '', + _format_legacy_table(concurrency), + '', + '## 2. Throughput scaling', + '', + 'Async fan-out at 50 ms server latency, semaphore cap 5000, thread pool 16. Throughput' + ' is reported as items completed per wallclock second; the sweep shows where the curve' + ' flattens.', + '', + _format_concurrency_table(throughput), + '', + '## 3. Semaphore-cap sensitivity', + '', + 'N=2500 async activities at 50 ms server latency. Cap below ~500 starves the loop and' + ' inflates queue wait. Above that, gains compress.', + '', + _format_concurrency_table(semaphore), + '', + '## 4. Failure threshold (queue-wait inflection)', + '', + 'Cap held at 1000, ramp N. Until N approaches cap, p99 stays close to server latency.' + ' Past it, queue wait dominates and p99 grows ~linearly with `N / cap`.', + '', + _format_concurrency_table(threshold), + '', + f'**Threshold**: {threshold_summary}', + '', + '## 5. Sidecar response delivery overhead', + '', + 'Mock `CompleteActivityTask` is given an artificial delay. Async responses go through' + ' `loop.run_in_executor(thread_pool, ...)`, sharing the worker thread pool sized by' + ' `maximum_thread_pool_workers`. Delivery latency above ~5 ms × concurrency exceeds the' + ' pool and serializes, inflating tail latency.', + '', + _format_concurrency_table(delivery), + '', + '## 6. Sustained load', + '', + _format_sustained_block(sustained), + '', + '## 7. Real HTTP workload (production shape)', + '', + 'Each activity opens a fresh `httpx.AsyncClient` and GETs a local aiohttp endpoint' + ' that sleeps 50 ms. Mirrors `examples/workflow/async_activities.py`. The sync row' + ' at N=100 shows the same workload throttled by the thread pool — directly comparable' + ' to the rest of the table.', + '', + _format_concurrency_table(real_http), + '', + '## 8. Real HTTP sustained load', + '', + 'Open-loop submission of real `httpx.AsyncClient` fetches at 100/s. Confirms steady' + ' state under genuine I/O, not synthetic sleep.', + '', + _format_sustained_block(real_http_sustained), + '', + '## 9. OOM safety', + '', + '10 000 in-flight async activities at 50 ms with a 1 000-cap semaphore. The' + ' ~9 000 Tasks parked on the semaphore are the design-discussion concern. Peak RSS' + ' delta stays well under the 500 MB budget, so the unbounded-pending-Task pattern is' + ' fine in practice.', + '', + _format_legacy_table([oom]), + '', + '## How to read this report', + '', + '- **Tput/s** is the closed-loop throughput (items completed / wallclock).' + ' For the sustained scenario it is the steady-state value over the full run.', + '- **p99 ms** is the end-to-end latency for the 99th-percentile item: time from' + ' `submit_activity` to the mock stub seeing the response.', + "- **Peak queue** is the maximum depth of the manager's `activity_queue` during the" + ' run. Non-zero peak queue means submission temporarily outran the semaphore.', + '- **Peak tasks** is the maximum number of live `asyncio.Task` objects in the process,' + ' which doubles as a sanity check on the unbounded-pending-Task pattern.', + '', + '## Operational guidance', + '', + 'See `ext/dapr-ext-workflow/docs/concurrency.md` for the full operational write-up,' + ' including sizing recommendations for `maximum_concurrent_activity_work_items`,' + ' `maximum_thread_pool_workers`, and the asyncio default-executor caveat.', + ] + RESULTS_PATH.write_text('\n'.join(body) + '\n', encoding='utf-8') + + +# ============================================================================ +# Budget assertions +# ============================================================================ + + +def _assert_budgets( + *, + concurrency: list[ScenarioMetrics], + throughput: list[ScenarioMetrics], + semaphore: list[ScenarioMetrics], + threshold: list[ScenarioMetrics], + delivery: list[ScenarioMetrics], + sustained: SustainedMetrics, + oom: ScenarioMetrics, + real_http: list[ScenarioMetrics], + real_http_sustained: SustainedMetrics, +) -> None: + """Pass criteria. Loud failure if a regression makes any of them false. + + Budgets are intentionally generous so CI doesn't flake; they catch order-of-magnitude + regressions, not micro-fluctuations. + """ + async_repro, sync_baseline = concurrency + # Issue #897: async must finish close to a single server-latency window. + assert async_repro.wallclock_s < async_repro.server_latency_s * 5, ( + f'Async fan-out took {async_repro.wallclock_s:.2f}s for' + f' {async_repro.n_items} × {async_repro.server_latency_s}s activities;' + f' async dispatch is not actually concurrent.' + ) + # Issue #897: sync baseline must be at least one extra latency window slower. + assert sync_baseline.wallclock_s > async_repro.wallclock_s + async_repro.server_latency_s, ( + f'Sync baseline ({sync_baseline.wallclock_s:.2f}s) was not at least one' + f' latency window slower than async ({async_repro.wallclock_s:.2f}s);' + f' the comparison is meaningless.' + ) + + # Throughput scaling: each larger N must be at least 80% as fast as the smallest; + # we tolerate the inevitable plateau but reject a collapse. + base_throughput = throughput[0].throughput_per_s + for m in throughput[1:]: + assert m.throughput_per_s >= base_throughput * 0.5, ( + f'Throughput collapsed at N={m.n_items}: {m.throughput_per_s:.1f}/s' + f' vs base {base_throughput:.1f}/s. The scaling curve regressed.' + ) + + # Semaphore sensitivity: the smallest cap must be at least 3x slower than the largest. + smallest_cap = semaphore[0] + largest_cap = semaphore[-1] + assert smallest_cap.wallclock_s > largest_cap.wallclock_s * 1.5, ( + f'Wallclock at cap={smallest_cap.semaphore_cap} ({smallest_cap.wallclock_s:.2f}s)' + f' was not meaningfully slower than at cap={largest_cap.semaphore_cap}' + f' ({largest_cap.wallclock_s:.2f}s). The semaphore is not gating concurrency.' + ) + + # Failure threshold: at N ≤ cap, p99 must be within 5x of server latency. + cap = threshold[0].semaphore_cap + server_latency_ms = threshold[0].server_latency_s * 1000.0 + for m in threshold: + if m.n_items <= cap: + assert m.latency.p99_ms <= server_latency_ms * 5, ( + f'p99 at N={m.n_items} (≤ cap={cap}) was {m.latency.p99_ms:.1f} ms,' + f' >5x server latency ({server_latency_ms:.1f} ms).' + f' The dispatch path has unexpected overhead.' + ) + + # Delivery overhead: zero-delay delivery must keep p99 < 200 ms at N=1000. + zero_delay = delivery[0] + assert zero_delay.latency.p99_ms < 200.0, ( + f'p99 with zero delivery delay was {zero_delay.latency.p99_ms:.1f} ms at N={zero_delay.n_items};' + f' the SDK adds more than 200 ms of overhead on top of the {zero_delay.server_latency_s * 1000:.0f}' + f' ms activity, which is too much.' + ) + + # Sustained: last-quarter p99 must not be more than 3x the first-quarter p99. + drift = sustained.latency_last_quarter.p99_ms + first = max(sustained.latency_first_quarter.p99_ms, 1.0) + assert drift <= first * 3.0, ( + f'Sustained tail latency drifted: first-quarter p99 = {first:.1f} ms,' + f' last-quarter p99 = {drift:.1f} ms.' + f' Steady state is degrading over the run.' + ) + + # OOM safety budgets — unchanged from the original benchmark. + assert oom.peak_tasks <= int(oom.n_items * 1.5), ( + f'Peak Tasks ({oom.peak_tasks}) exceeded 1.5 × N={oom.n_items}.' + f' The per-item Task accounting is inflated.' + ) + assert oom.peak_rss_delta_mb < 500.0, ( + f'Peak RSS delta {oom.peak_rss_delta_mb:.1f} MB exceeded the 500 MB budget.' + f' The unbounded pending-Task pattern needs an asyncio.Queue cap.' + ) + + # Real-HTTP workload: async path must beat the sync path's wallclock + # decisively. At small N, per-call ``httpx.AsyncClient(...)`` setup masks the + # win, so we compare the peak async throughput across the sweep against the + # sync row. + *real_async_rows, real_sync = real_http + peak_async_throughput = max(m.throughput_per_s for m in real_async_rows) + assert peak_async_throughput > real_sync.throughput_per_s * 1.25, ( + f'Real-HTTP peak async throughput ({peak_async_throughput:.1f}/s) was not' + f' >1.25x sync N={real_sync.n_items} ({real_sync.throughput_per_s:.1f}/s).' + f' The async path lost its concurrency advantage under real I/O.' + ) + # And at the largest async N, p99 must scale with the batch — not with the + # entire history of the run. + largest_async = max(real_async_rows, key=lambda m: m.n_items) + assert largest_async.latency.p99_ms < largest_async.wallclock_s * 1000.0 * 1.2, ( + f'Real-HTTP async N={largest_async.n_items}: p99 {largest_async.latency.p99_ms:.0f} ms' + f' exceeds 1.2x the wallclock ({largest_async.wallclock_s * 1000:.0f} ms),' + f' which means some items are blocked beyond the entire batch — pathological.' + ) + + # Real-HTTP sustained: same drift guard as the synthetic sustained run, + # but with slightly more slack because httpx connection churn adds jitter. + first_http = max(real_http_sustained.latency_first_quarter.p99_ms, 1.0) + last_http = real_http_sustained.latency_last_quarter.p99_ms + assert last_http <= first_http * 4.0, ( + f'Real-HTTP sustained tail latency drifted: first-quarter p99 = {first_http:.1f} ms,' + f' last-quarter p99 = {last_http:.1f} ms. Steady state regressed during the run.' + ) + + +# ============================================================================ +# Real-sidecar opt-in scenario +# ============================================================================ + + +async def run_with_real_sidecar() -> None: + """End-to-end scenario against a real Dapr sidecar. + + Skipped unless ``DAPR_BENCH_WITH_SIDECAR=1``. Requires the script to be run under + ``dapr run`` with a workflow-enabled state store, e.g.:: + + dapr run --app-id bench --app-protocol grpc --dapr-grpc-port 50001 \\ + -- env DAPR_BENCH_WITH_SIDECAR=1 \\ + uv run python ext/dapr-ext-workflow/benchmarks/bench_async_activities.py + """ + import dapr.ext.workflow as wf + + n_items = 50 + server_latency_s = 0.5 + wfr = wf.WorkflowRuntime() + + @wfr.workflow(name='bench_real_workflow') + def bench_workflow(ctx: wf.DaprWorkflowContext, payload: list[int]): + tasks = [ctx.call_activity(bench_async_activity, input=i) for i in payload] + return (yield wf.when_all(tasks)) + + @wfr.activity(name='bench_async_activity') + async def bench_async_activity(_ctx: wf.WorkflowActivityContext, _i: int) -> int: + await asyncio.sleep(server_latency_s) + return _i + + wfr.start() + time.sleep(2) + try: + client = wf.DaprWorkflowClient() + instance_id = client.schedule_new_workflow( + workflow=bench_workflow, input=list(range(n_items)) + ) + start = time.perf_counter() + state = client.wait_for_workflow_completion(instance_id, timeout_in_seconds=120) + wallclock = time.perf_counter() - start + assert state is not None, 'workflow timed out against real sidecar' + print( + f'[real-sidecar] {n_items} async activities × {server_latency_s}s' + f' completed in {wallclock:.2f}s (status {state.runtime_status.name})' + ) + finally: + wfr.shutdown() + + +# ============================================================================ +# Entry point +# ============================================================================ + + +async def main() -> None: + logging.basicConfig(level=logging.WARNING) + + env = RunEnvironment.capture() + print( + f'[env] {env.cpu_model} | {env.cpu_logical_cores} cores |' + f' {env.total_memory_gb:.1f} GB | {env.python_implementation} {env.python_version}', + flush=True, + ) + + print('[1/9] concurrency win (issue #897 repro)...', flush=True) + concurrency = await run_concurrency_win() + + print('[2/9] throughput scaling sweep...', flush=True) + throughput = await run_throughput_scaling() + + print('[3/9] semaphore-cap sensitivity sweep...', flush=True) + semaphore = await run_semaphore_sensitivity() + + print('[4/9] failure-threshold ramp...', flush=True) + threshold = await run_failure_threshold() + + print('[5/9] sidecar-delivery overhead sweep...', flush=True) + delivery = await run_delivery_overhead() + + print(f'[6/9] sustained load ({SUSTAINED_DURATION_S:.0f}s)...', flush=True) + sustained = await run_sustained_load() + + print('[7/9] real-HTTP workload sweep...', flush=True) + real_http = await run_real_http_workload() + + real_http_duration = min(SUSTAINED_DURATION_S, 60.0) + print(f'[8/9] real-HTTP sustained load ({real_http_duration:.0f}s)...', flush=True) + real_http_sustained = await run_real_http_sustained(duration_s=real_http_duration) + + print('[9/9] OOM safety...', flush=True) + oom = await run_oom_safety() + + _write_results( + env=env, + concurrency=concurrency, + throughput=throughput, + semaphore=semaphore, + threshold=threshold, + delivery=delivery, + sustained=sustained, + oom=oom, + real_http=real_http, + real_http_sustained=real_http_sustained, + ) + print('\n=== concurrency win ===') + print(_format_legacy_table(concurrency)) + print('\n=== throughput scaling ===') + print(_format_concurrency_table(throughput)) + print('\n=== semaphore sensitivity ===') + print(_format_concurrency_table(semaphore)) + print('\n=== failure threshold ===') + print(_format_concurrency_table(threshold)) + print('\n=== sidecar delivery overhead ===') + print(_format_concurrency_table(delivery)) + print('\n=== sustained load (synthetic) ===') + print(_format_sustained_block(sustained)) + print('\n=== real HTTP workload ===') + print(_format_concurrency_table(real_http)) + print('\n=== real HTTP sustained load ===') + print(_format_sustained_block(real_http_sustained)) + print('\n=== OOM safety ===') + print(_format_legacy_table([oom])) + print(f'\nWrote {RESULTS_PATH.relative_to(Path.cwd())}') + + _assert_budgets( + concurrency=concurrency, + throughput=throughput, + semaphore=semaphore, + threshold=threshold, + delivery=delivery, + sustained=sustained, + oom=oom, + real_http=real_http, + real_http_sustained=real_http_sustained, + ) + + if os.environ.get('DAPR_BENCH_WITH_SIDECAR') == '1': + print('\n[opt-in] running real-sidecar scenario...') + await run_with_real_sidecar() + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/ext/dapr-ext-workflow/dapr/ext/workflow/_durabletask/internal/shared.py b/ext/dapr-ext-workflow/dapr/ext/workflow/_durabletask/internal/shared.py index 5c9bd9f9b..f2629775c 100644 --- a/ext/dapr-ext-workflow/dapr/ext/workflow/_durabletask/internal/shared.py +++ b/ext/dapr-ext-workflow/dapr/ext/workflow/_durabletask/internal/shared.py @@ -10,6 +10,8 @@ # limitations under the License. import dataclasses +import functools +import inspect import json import logging import os @@ -19,6 +21,28 @@ import grpc from dapr.ext.workflow import _model_protocol + +def is_async_callable(fn: Any) -> bool: + """Return True if ``fn`` is async. Catches ``functools.partial`` of coroutines, + sync decorators that wrap async functions, and callable instances with ``async __call__``. + """ + candidate = fn + while isinstance(candidate, functools.partial): + candidate = candidate.func + if callable(candidate): + try: + candidate = inspect.unwrap(candidate) + except ValueError: + # Cyclic ``__wrapped__`` chain from a malformed decorator. Fall back to the + # outermost callable; misclassification is preferable to crashing dispatch. + pass + if inspect.iscoroutinefunction(candidate): + return True + if not inspect.isfunction(candidate) and hasattr(candidate, '__call__'): + return inspect.iscoroutinefunction(candidate.__call__) + return False + + ClientInterceptor = Union[ grpc.UnaryUnaryClientInterceptor, grpc.UnaryStreamClientInterceptor, diff --git a/ext/dapr-ext-workflow/dapr/ext/workflow/_durabletask/worker.py b/ext/dapr-ext-workflow/dapr/ext/workflow/_durabletask/worker.py index 84663064f..0316e357e 100644 --- a/ext/dapr-ext-workflow/dapr/ext/workflow/_durabletask/worker.py +++ b/ext/dapr-ext-workflow/dapr/ext/workflow/_durabletask/worker.py @@ -30,6 +30,7 @@ import grpc from dapr.ext.workflow._durabletask import deterministic, task from dapr.ext.workflow._durabletask.internal.grpc_interceptor import DefaultClientInterceptorImpl +from dapr.ext.workflow._durabletask.internal.shared import is_async_callable from dapr.ext.workflow.propagation import PropagatedHistory, PropagationScope from google.protobuf import empty_pb2, timestamp_pb2 @@ -65,10 +66,10 @@ def _log_all_threads(logger: logging.Logger, context: str = ''): class ConcurrencyOptions: - """Configuration options for controlling concurrency of different work item types and the thread pool size. + """Concurrency limits for the worker. - This class provides fine-grained control over concurrent processing limits for - activities, orchestrations and the thread pool size. + ``maximum_thread_pool_workers`` sizes the pool used to run sync activities and to + deliver async-activity responses to the sidecar. """ def __init__( @@ -80,11 +81,13 @@ def __init__( """Initialize concurrency options. Args: - maximum_concurrent_activity_work_items: Maximum number of activity work items - that can be processed concurrently. Defaults to 100 * processor_count. - maximum_concurrent_orchestration_work_items: Maximum number of orchestration work items - that can be processed concurrently. Defaults to 100 * processor_count. - maximum_thread_pool_workers: Maximum number of thread pool workers to use. + maximum_concurrent_activity_work_items: Cap on concurrent activity work items. + Defaults to ``100 * cpu_count``. + maximum_concurrent_orchestration_work_items: Cap on concurrent orchestration work + items. Defaults to ``100 * cpu_count``. + maximum_thread_pool_workers: Size of the worker thread pool. Sync activities run + on this pool, and async-activity gRPC response sends also borrow a thread + from it. Defaults to ``cpu_count + 4``. """ processor_count = os.cpu_count() or 1 default_concurrency = 100 * processor_count @@ -349,6 +352,7 @@ def __init__( self._interceptors = None self._async_worker_manager = _AsyncWorkerManager(self._concurrency_options, self._logger) + self._activity_executor = _ActivityExecutor(self._logger) @property def concurrency_options(self) -> ConcurrencyOptions: @@ -658,8 +662,19 @@ def stream_reader(): work_item.completionToken, ) elif work_item.HasField('activityRequest'): + # Async user activities run on the event loop. Sync ones fall through + # to the thread pool via _execute_activity. + activity_fn = self._registry.get_activity( + work_item.activityRequest.name + ) + activity_handler = ( + self._execute_activity_async + if activity_fn is not None and is_async_callable(activity_fn) + else self._execute_activity + ) self._async_worker_manager.submit_activity( - self._execute_activity, + activity_handler, + activity_fn, work_item.activityRequest, stub, work_item.completionToken, @@ -964,98 +979,178 @@ def _execute_orchestrator( f"Failed to deliver orchestrator response for '{req.instanceId}' to sidecar: {ex}" ) + def _activity_span(self, req: pb.ActivityRequest, instance_id: str): + """Return an OTel span context manager, or a nullcontext if OTel is not installed.""" + if otel_tracer is None: + return contextlib.nullcontext() + return otel_tracer.start_as_current_span( + name=f'activity: {req.name}', + context=otel_propagator.extract( + carrier={'traceparent': req.parentTraceContext.traceParent} + ), + attributes={ + 'dapr.ext.workflow._durabletask.task.instance_id': instance_id, + 'dapr.ext.workflow._durabletask.task.id': req.taskId, + 'dapr.ext.workflow._durabletask.activity.name': req.name, + }, + ) + + def _propagated_history(self, req: pb.ActivityRequest) -> PropagatedHistory | None: + if req.HasField('propagatedHistory'): + return PropagatedHistory.from_proto(req.propagatedHistory) + return None + + def _build_activity_result_response( + self, + req: pb.ActivityRequest, + instance_id: str, + result: str | None, + completion_token, + ) -> pb.ActivityResponse: + return pb.ActivityResponse( + instanceId=instance_id, + taskId=req.taskId, + result=ph.get_string_value(result), + completionToken=completion_token, + ) + + def _build_activity_failure_response( + self, + req: pb.ActivityRequest, + instance_id: str, + ex: BaseException, + completion_token, + ) -> pb.ActivityResponse: + return pb.ActivityResponse( + instanceId=instance_id, + taskId=req.taskId, + failureDetails=ph.new_failure_details(ex), + completionToken=completion_token, + ) + + def _send_activity_response( + self, + req: pb.ActivityRequest, + stub: stubs.TaskHubSidecarServiceStub, + res: pb.ActivityResponse, + completion_token, + instance_id: str, + ): + """Send an activity response, falling back to a failure response when the + result is too large to deliver.""" + try: + stub.CompleteActivityTask(res) + except grpc.RpcError as rpc_error: # type: ignore + if _is_message_too_large(rpc_error): + # Result is too large to deliver - fail the activity immediately. + # This can only be fixed with infrastructure changes (increasing gRPC max message size). + self._logger.error( + f"Activity '{req.name}#{req.taskId}' result is too large to deliver " + f'(RESOURCE_EXHAUSTED). Failing the activity task: {rpc_error.details()}' + ) + oversize_error = RuntimeError( + f'Activity result exceeds gRPC max message size: {rpc_error.details()}' + ) + failure_res = self._build_activity_failure_response( + req, instance_id, oversize_error, completion_token + ) + try: + stub.CompleteActivityTask(failure_res) + except Exception as ex: + self._logger.exception( + f"Failed to deliver activity failure response for '{req.name}#{req.taskId}' " + f"of orchestration ID '{instance_id}': {ex}" + ) + else: + self._handle_grpc_execution_error(rpc_error, 'activity') + except ValueError: + # gRPC raises ValueError when the underlying channel has been closed (e.g. during reconnection). + self._logger.debug( + f"Could not deliver activity response for '{req.name}#{req.taskId}' of " + f"orchestration ID '{instance_id}': channel was closed (likely due to " + f'reconnection). The sidecar will re-dispatch this work item.' + ) + except Exception as ex: + self._logger.exception( + f"Failed to deliver activity response for '{req.name}#{req.taskId}' of orchestration ID '{instance_id}' to sidecar: {ex}" + ) + def _execute_activity( self, + fn: task.Activity | None, req: pb.ActivityRequest, stub: stubs.TaskHubSidecarServiceStub, completionToken, ): instance_id = req.workflowInstance.instanceId - - if otel_tracer is not None: - span_context = otel_tracer.start_as_current_span( - name=f'activity: {req.name}', - context=otel_propagator.extract( - carrier={'traceparent': req.parentTraceContext.traceParent} - ), - attributes={ - 'dapr.ext.workflow._durabletask.task.instance_id': instance_id, - 'dapr.ext.workflow._durabletask.task.id': req.taskId, - 'dapr.ext.workflow._durabletask.activity.name': req.name, - }, - ) - else: - span_context = contextlib.nullcontext() - - with span_context: + with self._activity_span(req, instance_id): try: - executor = _ActivityExecutor(self._registry, self._logger) - propagated = ( - PropagatedHistory.from_proto(req.propagatedHistory) - if req.HasField('propagatedHistory') - else None - ) - result = executor.execute( + result = self._activity_executor.execute( + fn, instance_id, req.name, req.taskId, req.input.value, req.taskExecutionId, - propagated_history=propagated, + propagated_history=self._propagated_history(req), ) - res = pb.ActivityResponse( - instanceId=instance_id, - taskId=req.taskId, - result=ph.get_string_value(result), - completionToken=completionToken, + res = self._build_activity_result_response( + req, instance_id, result, completionToken ) except Exception as ex: - res = pb.ActivityResponse( - instanceId=instance_id, - taskId=req.taskId, - failureDetails=ph.new_failure_details(ex), - completionToken=completionToken, - ) + res = self._build_activity_failure_response(req, instance_id, ex, completionToken) + self._send_activity_response(req, stub, res, completionToken, instance_id) + async def _execute_activity_async( + self, + fn: task.Activity, + req: pb.ActivityRequest, + stub: stubs.TaskHubSidecarServiceStub, + completionToken, + ): + """Run an async activity on the event loop and send its result to the sidecar. + The gRPC send runs on the worker thread pool to avoid blocking the loop. + """ + instance_id = req.workflowInstance.instanceId + with self._activity_span(req, instance_id): try: - stub.CompleteActivityTask(res) - except grpc.RpcError as rpc_error: # type: ignore - if _is_message_too_large(rpc_error): - # Result is too large to deliver - fail the activity immediately. - # This can only be fixed with infrastructure changes (increasing gRPC max message size). - self._logger.error( - f"Activity '{req.name}#{req.taskId}' result is too large to deliver " - f'(RESOURCE_EXHAUSTED). Failing the activity task: {rpc_error.details()}' - ) - failure_res = pb.ActivityResponse( - instanceId=instance_id, - taskId=req.taskId, - failureDetails=ph.new_failure_details( - RuntimeError( - f'Activity result exceeds gRPC max message size: {rpc_error.details()}' - ) - ), - completionToken=completionToken, - ) - try: - stub.CompleteActivityTask(failure_res) - except Exception as ex: - self._logger.exception( - f"Failed to deliver activity failure response for '{req.name}#{req.taskId}' " - f"of orchestration ID '{instance_id}': {ex}" - ) - else: - self._handle_grpc_execution_error(rpc_error, 'activity') - except ValueError: - # gRPC raises ValueError when the underlying channel has been closed (e.g. during reconnection). - self._logger.debug( - f"Could not deliver activity response for '{req.name}#{req.taskId}' of " - f"orchestration ID '{instance_id}': channel was closed (likely due to " - f'reconnection). The sidecar will re-dispatch this work item.' + result = await self._activity_executor.execute_async( + fn, + instance_id, + req.name, + req.taskId, + req.input.value, + req.taskExecutionId, + propagated_history=self._propagated_history(req), ) + res = self._build_activity_result_response( + req, instance_id, result, completionToken + ) + except asyncio.CancelledError: + raise except Exception as ex: - self._logger.exception( - f"Failed to deliver activity response for '{req.name}#{req.taskId}' of orchestration ID '{instance_id}' to sidecar: {ex}" + res = self._build_activity_failure_response(req, instance_id, ex, completionToken) + loop = asyncio.get_running_loop() + try: + await loop.run_in_executor( + self._async_worker_manager.thread_pool, + self._send_activity_response, + req, + stub, + res, + completionToken, + instance_id, + ) + except RuntimeError as exc: + # Swallow only when the thread pool itself is shut down (worker tearing down). + # Other RuntimeErrors are unexpected and propagate to the work-item processor. + # The sidecar will re-dispatch this work item once the worker reconnects. + pool = self._async_worker_manager.thread_pool + if not getattr(pool, '_shutdown', False): + raise + self._logger.warning( + f"Could not deliver activity response for '{req.name}#{req.taskId}': " + f'{exc}. The sidecar will re-dispatch this work item.' ) @@ -1998,27 +2093,25 @@ def process_event(self, ctx: _RuntimeOrchestrationContext, event: pb.HistoryEven class _ActivityExecutor: - def __init__(self, registry: _Registry, logger: logging.Logger): - self._registry = registry + def __init__(self, logger: logging.Logger): self._logger = logger - def execute( + def _resolve( self, + fn: task.Activity | None, orchestration_id: str, name: str, task_id: int, - encoded_input: Optional[str], - task_execution_id: str = '', - propagated_history: Optional[PropagatedHistory] = None, - ) -> Optional[str]: - """Executes an activity function and returns the serialized result, if any.""" + encoded_input: str | None, + task_execution_id: str, + propagated_history: PropagatedHistory | None, + ) -> tuple[task.Activity, task.ActivityContext, Any]: + """Validate ``fn`` and build its ``(fn, ctx, input)`` call args.""" self._logger.debug(f"{orchestration_id}/{task_id}: Executing activity '{name}'...") - fn = self._registry.get_activity(name) - if not fn: + if fn is None: raise ActivityNotRegisteredError( f"Activity function named '{name}' was not registered!" ) - activity_input = shared.from_json(encoded_input) if encoded_input else None ctx = task.ActivityContext( orchestration_id, @@ -2026,10 +2119,11 @@ def execute( task_execution_id, propagated_history=propagated_history, ) + return fn, ctx, activity_input - # Execute the activity function - activity_output = fn(ctx, activity_input) - + def _encode_output( + self, orchestration_id: str, name: str, task_id: int, activity_output: Any + ) -> str | None: encoded_output = shared.to_json(activity_output) if activity_output is not None else None chars = len(encoded_output) if encoded_output else 0 self._logger.debug( @@ -2037,6 +2131,64 @@ def execute( ) return encoded_output + def execute( + self, + fn: task.Activity | None, + orchestration_id: str, + name: str, + task_id: int, + encoded_input: str | None, + task_execution_id: str = '', + propagated_history: PropagatedHistory | None = None, + ) -> str | None: + """Run a sync activity function and return the serialized result, if any. + + Raises ``RuntimeError`` if the activity returns a coroutine, which happens when + ``is_async_callable`` fails to detect an async callable at registration. + """ + resolved_fn, ctx, activity_input = self._resolve( + fn, + orchestration_id, + name, + task_id, + encoded_input, + task_execution_id, + propagated_history, + ) + activity_output = resolved_fn(ctx, activity_input) + if inspect.iscoroutine(activity_output): + activity_output.close() + raise RuntimeError( + f"Activity '{name}' returned a coroutine on the sync path. " + f'Declare it with ``async def``, or if it already is, ensure any decorator ' + f'wrapping it uses ``@functools.wraps(fn)`` so the runtime can detect the ' + f'underlying async function.' + ) + return self._encode_output(orchestration_id, name, task_id, activity_output) + + async def execute_async( + self, + fn: task.Activity, + orchestration_id: str, + name: str, + task_id: int, + encoded_input: str | None, + task_execution_id: str = '', + propagated_history: PropagatedHistory | None = None, + ) -> str | None: + """Await a coroutine activity function and return the serialized result, if any.""" + resolved_fn, ctx, activity_input = self._resolve( + fn, + orchestration_id, + name, + task_id, + encoded_input, + task_execution_id, + propagated_history, + ) + activity_output = await resolved_fn(ctx, activity_input) + return self._encode_output(orchestration_id, name, task_id, activity_output) + def _get_non_determinism_error(task_id: int, action_name: str) -> task.NonDeterminismError: return task.NonDeterminismError( @@ -2274,7 +2426,7 @@ async def _process_work_item( queue.task_done() async def _run_func(self, func, *args, **kwargs): - if inspect.iscoroutinefunction(func): + if is_async_callable(func): return await func(*args, **kwargs) else: loop = asyncio.get_running_loop() diff --git a/ext/dapr-ext-workflow/dapr/ext/workflow/workflow_runtime.py b/ext/dapr-ext-workflow/dapr/ext/workflow/workflow_runtime.py index f33622a15..edb89c10d 100644 --- a/ext/dapr-ext-workflow/dapr/ext/workflow/workflow_runtime.py +++ b/ext/dapr-ext-workflow/dapr/ext/workflow/workflow_runtime.py @@ -16,10 +16,11 @@ import inspect import time from functools import wraps -from typing import Optional, Sequence, TypeVar, Union +from typing import Any, Awaitable, Callable, Optional, Sequence, TypeVar, Union import grpc from dapr.ext.workflow._durabletask import task, worker +from dapr.ext.workflow._durabletask.internal.shared import is_async_callable as _is_async_callable from dapr.ext.workflow.dapr_workflow_context import DaprWorkflowContext from dapr.ext.workflow.logger import Logger, LoggerOptions from dapr.ext.workflow.util import getAddress @@ -45,6 +46,60 @@ grpc.StreamStreamClientInterceptor, ] +# Durabletask returns decoded JSON, so we type the input as ``object | None`` and let the +# wrapper narrow it via the activity's declared model. +SyncActivityWrapper = Callable[[task.ActivityContext, object | None], object] +AsyncActivityWrapper = Callable[[task.ActivityContext, object | None], Awaitable[object]] +ActivityWrapper = SyncActivityWrapper | AsyncActivityWrapper + + +def _coerce_activity_input(inp: object | None, input_model: type | None) -> object | None: + """Coerce the raw input to the activity's declared model, if it has one.""" + if inp is None or input_model is None or isinstance(inp, input_model): + return inp + return _model_protocol.coerce_to_model(inp, input_model) + + +def _make_activity_wrapper(fn: Activity, logger: Logger) -> ActivityWrapper: + """Wrap a user activity for the durabletask worker. + + Returns: + An ``async def`` wrapper for async activities, a plain ``def`` for sync. + """ + accepts_input, input_model = _model_protocol.resolve_input(fn) + + def _call_args(ctx: task.ActivityContext, inp: object | None) -> tuple: + wf_ctx = WorkflowActivityContext(ctx) + if not accepts_input: + return (wf_ctx,) + return (wf_ctx, _coerce_activity_input(inp, input_model)) + + def _log_failure(ctx: task.ActivityContext, exc: Exception) -> None: + activity_id = getattr(ctx, 'task_id', 'unknown') + logger.warning(f'Activity execution failed - task_id: {activity_id}, error: {exc}') + + if _is_async_callable(fn): + + async def async_activity_wrapper( + ctx: task.ActivityContext, inp: object | None = None + ) -> object: + try: + return await fn(*_call_args(ctx, inp)) + except Exception as exc: + _log_failure(ctx, exc) + raise + + return async_activity_wrapper + + def sync_activity_wrapper(ctx: task.ActivityContext, inp: object | None = None) -> object: + try: + return fn(*_call_args(ctx, inp)) + except Exception as exc: + _log_failure(ctx, exc) + raise + + return sync_activity_wrapper + class WorkflowRuntime: """WorkflowRuntime is the entry point for registering workflows and activities.""" @@ -180,36 +235,14 @@ def orchestrationWrapper(ctx: task.OrchestrationContext, inp: Optional[TInput] = fn.__dict__['_workflow_registered'] = True def register_activity(self, fn: Activity, *, name: Optional[str] = None): - """Registers a workflow activity as a function that takes - a specified input type and returns a specified output type. + """Register a workflow activity. ``def`` and ``async def`` are both supported. + Async activities run on the worker's event loop. Sync activities run in the + thread pool sized by ``maximum_thread_pool_workers``. """ effective_name = name or fn.__name__ self._logger.info(f"Registering activity '{effective_name}' with runtime") - accepts_input, input_model = _model_protocol.resolve_input(fn) - - def activityWrapper(ctx: task.ActivityContext, inp: Optional[TInput] = None): - """Responsible to call Activity function in activityWrapper""" - activity_id = getattr(ctx, 'task_id', 'unknown') - - try: - wfActivityContext = WorkflowActivityContext(ctx) - if not accepts_input: - result = fn(wfActivityContext) - else: - if ( - (inp is not None) - and (input_model is not None) - and not isinstance(inp, input_model) - ): - inp = _model_protocol.coerce_to_model(inp, input_model) - result = fn(wfActivityContext, inp) - return result - except Exception as e: - self._logger.warning( - f'Activity execution failed - task_id: {activity_id}, error: {e}' - ) - raise + activity_wrapper = _make_activity_wrapper(fn, self._logger) if hasattr(fn, '_activity_registered'): # whenever an activity is registered, it has a _dapr_alternate_name attribute @@ -224,7 +257,7 @@ def activityWrapper(ctx: task.ActivityContext, inp: Optional[TInput] = None): fn.__dict__['_dapr_alternate_name'] = name if name else fn.__name__ self.__worker._registry.add_named_activity( - fn.__dict__['_dapr_alternate_name'], activityWrapper + fn.__dict__['_dapr_alternate_name'], activity_wrapper ) fn.__dict__['_activity_registered'] = True @@ -446,16 +479,23 @@ def add(ctx, x: int, y: int) -> int: the workflow runtime. Defaults to None. """ - def wrapper(fn: any): + def wrapper(fn: Any): if hasattr(fn, '_dapr_alternate_name'): raise ValueError( f'Function {fn.__name__} already has an alternate name {fn._dapr_alternate_name}' ) fn.__dict__['_dapr_alternate_name'] = name if name else fn.__name__ - @wraps(fn) - def innerfn(*args, **kwargs): - return fn(*args, **kwargs) + if _is_async_callable(fn): + + @wraps(fn) + async def innerfn(*args, **kwargs): + return await fn(*args, **kwargs) + else: + + @wraps(fn) + def innerfn(*args, **kwargs): + return fn(*args, **kwargs) innerfn.__dict__['_dapr_alternate_name'] = name if name else fn.__name__ innerfn.__signature__ = inspect.signature(fn) diff --git a/ext/dapr-ext-workflow/docs/concurrency.md b/ext/dapr-ext-workflow/docs/concurrency.md new file mode 100644 index 000000000..c598464cc --- /dev/null +++ b/ext/dapr-ext-workflow/docs/concurrency.md @@ -0,0 +1,79 @@ +# Concurrency configuration for `dapr-ext-workflow` + +Sizing notes for the worker's concurrency knobs. Numbers come from +`benchmarks/bench_async_activities.py`. Re-run it on local hardware to validate. + +## Knobs + +| Setting | Default | Effect | +| --- | --- | --- | +| `maximum_concurrent_activity_work_items` | `100 × cpu_count` | Async semaphore cap on in-flight activity work items. | +| `maximum_concurrent_orchestration_work_items` | `100 × cpu_count` | Same, for orchestrations. | +| `maximum_thread_pool_workers` | `cpu_count + 4` | Worker thread pool size. Sync activities run on this pool, and async-activity gRPC response sends also borrow a thread from it. | + +A `def` activity consumes a semaphore slot **and** a thread pool worker. An +`async def` activity consumes only a semaphore slot. + +## Sizing the activity cap + +The cap is the lever for throughput and queue wait. Throughput plateaus around +`cap ≈ peak_in_flight`. Past the cap, queue wait grows linearly. The benchmark's +failure-threshold sweep shows the inflection point clearly. Rule of thumb: set +the cap to ~2x the expected steady-state in-flight count to absorb bursts. + +If activities call a downstream with a hard concurrency limit (e.g. a database +with a 100-connection pool), set the cap below that limit so it doubles as +backpressure. + +## Sizing the thread pool + +The worker thread pool, sized by `maximum_thread_pool_workers`, has two uses. + +**Sync activity execution.** Each `def` activity holds one thread for its +duration. Size to peak concurrent sync-activity count. + +**Async response delivery.** Each async activity, on completion, schedules +`stub.CompleteActivityTask` on the same pool to avoid blocking the loop during +the gRPC send. If the sidecar takes >5 ms to acknowledge and the worker runs +many concurrent async activities, response delivery can serialize through the +pool and tail latency inflates. Raise `maximum_thread_pool_workers` to widen +response-delivery throughput. + +Mixed workloads with long-running sync activities can starve async response +delivery (and vice versa) since they share the pool. If that becomes an issue, +size `maximum_thread_pool_workers` to the sum of peak sync activity concurrency +and peak in-flight async response sends. + +This thread hop goes away when the worker migrates to `grpc.aio`. + +## Sharing httpx clients + +The pattern in `examples/workflow/async_activities.py` opens a fresh +`httpx.AsyncClient` per activity. Correct for most workloads, but each call pays +TCP + TLS setup, and throughput plateaus around a few hundred req/s. + +For higher throughput, share a single client across activities: + +```python +_shared_client: httpx.AsyncClient | None = None + +def _get_client() -> httpx.AsyncClient: + global _shared_client + if _shared_client is None: + _shared_client = httpx.AsyncClient(timeout=30.0) + return _shared_client +``` + +The caller owns closing it during worker shutdown. For activities that hit many +hosts or need per-call timeout isolation, stick with per-call clients. + +## Re-running the benchmark + +```bash +uv sync --all-packages --group dev +uv run python ext/dapr-ext-workflow/benchmarks/bench_async_activities.py +``` + +Override the 120 s sustained run with `DAPR_BENCH_SUSTAINED_SECONDS=30` +for a faster local check. Set `DAPR_BENCH_WITH_SIDECAR=1` to exercise the +end-to-end path against a real sidecar. The script creates `benchmarks/RESULTS.md`. diff --git a/ext/dapr-ext-workflow/tests/durabletask/test_activity_dispatch_routing.py b/ext/dapr-ext-workflow/tests/durabletask/test_activity_dispatch_routing.py new file mode 100644 index 000000000..404088182 --- /dev/null +++ b/ext/dapr-ext-workflow/tests/durabletask/test_activity_dispatch_routing.py @@ -0,0 +1,92 @@ +# Copyright 2026 The Dapr Authors +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Contract tests for the activity dispatch handlers on ``TaskHubGrpcWorker``. + +The work-item dispatcher at the top of ``worker.py``'s gRPC loop selects between +``_execute_activity`` (sync, runs in the thread pool) and ``_execute_activity_async`` +(coroutine, awaited on the event loop) using ``is_async_callable(handler)`` via +``_AsyncWorkerManager._run_func``. These tests pin the async-ness of each handler so +the dispatch routing stays correct. +""" + +import asyncio +import inspect +import logging +import threading +from typing import Iterator + +import pytest +from dapr.ext.workflow._durabletask.worker import ( + ConcurrencyOptions, + TaskHubGrpcWorker, + _AsyncWorkerManager, +) + + +@pytest.fixture +def worker() -> Iterator[TaskHubGrpcWorker]: + instance = TaskHubGrpcWorker() + try: + yield instance + finally: + # The worker was never started, so ``stop()`` early-returns; shut the manager + # down directly so the test doesn't leak threads if any work was submitted. + instance.stop() + instance._async_worker_manager.shutdown() + + +@pytest.fixture +def manager() -> Iterator[_AsyncWorkerManager]: + instance = _AsyncWorkerManager(ConcurrencyOptions(), logger=logging.getLogger()) + try: + yield instance + finally: + instance.shutdown() + + +def test_sync_activity_handler_is_not_a_coroutine_function(worker: TaskHubGrpcWorker): + assert not inspect.iscoroutinefunction(worker._execute_activity) + + +def test_async_activity_handler_is_a_coroutine_function(worker: TaskHubGrpcWorker): + assert inspect.iscoroutinefunction(worker._execute_activity_async) + + +def test_run_func_awaits_coroutines_directly(manager: _AsyncWorkerManager): + """``_AsyncWorkerManager._run_func`` is the single point that branches on async-ness. + + A coroutine handler returns its value without going through the thread pool. + """ + + async def coroutine_handler(value: int) -> int: + return value + 1 + + async def driver() -> int: + return await manager._run_func(coroutine_handler, 41) + + assert asyncio.run(driver()) == 42 + + +def test_run_func_dispatches_sync_callables_to_thread_pool(manager: _AsyncWorkerManager): + main_thread_id = threading.get_ident() + captured: dict[str, int] = {} + + def sync_handler(value: int) -> int: + captured['thread_id'] = threading.get_ident() + return value + 1 + + async def driver() -> int: + return await manager._run_func(sync_handler, 41) + + result = asyncio.run(driver()) + assert result == 42 + assert captured['thread_id'] != main_thread_id diff --git a/ext/dapr-ext-workflow/tests/durabletask/test_activity_executor.py b/ext/dapr-ext-workflow/tests/durabletask/test_activity_executor.py index f65aaf3f6..8a0b3fe63 100644 --- a/ext/dapr-ext-workflow/tests/durabletask/test_activity_executor.py +++ b/ext/dapr-ext-workflow/tests/durabletask/test_activity_executor.py @@ -34,7 +34,9 @@ def test_activity(ctx: task.ActivityContext, test_input: Any): activity_input = 'Hello, 世界!' executor, name = _get_activity_executor(test_activity) - result = executor.execute(TEST_INSTANCE_ID, name, TEST_TASK_ID, json.dumps(activity_input)) + result = executor.execute( + test_activity, TEST_INSTANCE_ID, name, TEST_TASK_ID, json.dumps(activity_input) + ) assert result is not None result_input, result_orchestration_id, result_task_id = json.loads(result) @@ -44,14 +46,14 @@ def test_activity(ctx: task.ActivityContext, test_input: Any): def test_activity_not_registered(): - def test_activity(ctx: task.ActivityContext, _): - pass # not used - - executor, _ = _get_activity_executor(test_activity) + """Dispatch site passes ``fn=None`` for unknown activity names. Executor surfaces + that as ``ActivityNotRegisteredError`` carrying the requested name. + """ + executor = worker._ActivityExecutor(TEST_LOGGER) caught_exception: Optional[Exception] = None try: - executor.execute(TEST_INSTANCE_ID, 'Bogus', TEST_TASK_ID, None) + executor.execute(None, TEST_INSTANCE_ID, 'Bogus', TEST_TASK_ID, None) except Exception as ex: caught_exception = ex @@ -59,8 +61,29 @@ def test_activity(ctx: task.ActivityContext, _): assert 'Bogus' in str(caught_exception) +def test_sync_execute_rejects_async_activity(): + """Sync ``execute`` must raise a clear RuntimeError when the activity returns a + coroutine. Guards against ``_is_async_callable`` missing an async callable at + registration; without this, JSON encoding would fail with a confusing TypeError. + """ + + async def async_activity(ctx: task.ActivityContext, _): + return 'never reached' + + executor, name = _get_activity_executor(async_activity) + + caught_exception: Optional[Exception] = None + try: + executor.execute(async_activity, TEST_INSTANCE_ID, name, TEST_TASK_ID, None) + except Exception as ex: + caught_exception = ex + + assert type(caught_exception) is RuntimeError + assert 'returned a coroutine' in str(caught_exception) + + def _get_activity_executor(fn: task.Activity) -> Tuple[worker._ActivityExecutor, str]: registry = worker._Registry() name = registry.add_activity(fn) - executor = worker._ActivityExecutor(registry, TEST_LOGGER) + executor = worker._ActivityExecutor(TEST_LOGGER) return executor, name diff --git a/ext/dapr-ext-workflow/tests/durabletask/test_activity_executor_async.py b/ext/dapr-ext-workflow/tests/durabletask/test_activity_executor_async.py new file mode 100644 index 000000000..311e1533c --- /dev/null +++ b/ext/dapr-ext-workflow/tests/durabletask/test_activity_executor_async.py @@ -0,0 +1,98 @@ +# Copyright 2026 The Dapr Authors +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for the async branch of ``_ActivityExecutor``. + +These mirror ``test_activity_executor.py`` but exercise the ``execute_async`` path used +when a registered activity is a coroutine function. +""" + +import asyncio +import inspect +import json +import logging +from typing import Any + +import pytest +from dapr.ext.workflow._durabletask import task, worker + +logging.basicConfig( + format='%(asctime)s.%(msecs)03d %(name)s %(levelname)s: %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + level=logging.DEBUG, +) +TEST_LOGGER = logging.getLogger('tests') +TEST_INSTANCE_ID = 'abc123' +TEST_TASK_ID = 42 + + +def _get_activity_executor(fn: task.Activity) -> tuple[worker._ActivityExecutor, str]: + registry = worker._Registry() + name = registry.add_activity(fn) + executor = worker._ActivityExecutor(TEST_LOGGER) + return executor, name + + +def test_async_activity_inputs(): + """Validates that execute_async awaits the activity and returns the encoded result.""" + + async def test_async_activity(ctx: task.ActivityContext, test_input: Any): + await asyncio.sleep(0) + return test_input, ctx.orchestration_id, ctx.task_id + + activity_input = 'Hello, 世界!' + executor, name = _get_activity_executor(test_async_activity) + result = asyncio.run( + executor.execute_async( + test_async_activity, + TEST_INSTANCE_ID, + name, + TEST_TASK_ID, + json.dumps(activity_input), + ) + ) + assert result is not None + + result_input, result_orchestration_id, result_task_id = json.loads(result) + assert activity_input == result_input + assert TEST_INSTANCE_ID == result_orchestration_id + assert TEST_TASK_ID == result_task_id + + +def test_async_activity_exception_propagates(): + async def test_async_activity(ctx: task.ActivityContext, _): + raise RuntimeError('boom') + + executor, name = _get_activity_executor(test_async_activity) + + with pytest.raises(RuntimeError) as exc_info: + asyncio.run( + executor.execute_async(test_async_activity, TEST_INSTANCE_ID, name, TEST_TASK_ID, None) + ) + assert 'boom' in str(exc_info.value) + + +def test_async_activity_registry_preserves_coroutine_function(): + """The dispatcher relies on iscoroutinefunction(fn) at the registry lookup level. + + If the registry's add_activity ever wraps coroutine functions in a way that hides their + async-ness (e.g. functools.wraps with a sync decorator), the dispatcher would route + them to the thread pool and break I/O concurrency. This test pins that contract. + """ + + async def test_async_activity(ctx: task.ActivityContext, _): + return None + + registry = worker._Registry() + name = registry.add_activity(test_async_activity) + + retrieved = registry.get_activity(name) + assert inspect.iscoroutinefunction(retrieved) diff --git a/ext/dapr-ext-workflow/tests/durabletask/test_propagation_wiring.py b/ext/dapr-ext-workflow/tests/durabletask/test_propagation_wiring.py index f74b8fb8e..010f54aae 100644 --- a/ext/dapr-ext-workflow/tests/durabletask/test_propagation_wiring.py +++ b/ext/dapr-ext-workflow/tests/durabletask/test_propagation_wiring.py @@ -191,12 +191,13 @@ def reading_activity(ctx: task.ActivityContext, _): registry = worker._Registry() activity_name = registry.add_activity(reading_activity) - executor = worker._ActivityExecutor(registry, TEST_LOGGER) + executor = worker._ActivityExecutor(TEST_LOGGER) propagated = PropagatedHistory.from_proto(_single_chunk_history('Caller')) assert propagated is not None encoded_output = executor.execute( + reading_activity, orchestration_id='wf-1', name=activity_name, task_id=1, @@ -221,8 +222,9 @@ def reading_activity(ctx: task.ActivityContext, _): registry = worker._Registry() activity_name = registry.add_activity(reading_activity) - executor = worker._ActivityExecutor(registry, TEST_LOGGER) + executor = worker._ActivityExecutor(TEST_LOGGER) executor.execute( + reading_activity, orchestration_id='wf-1', name=activity_name, task_id=1, diff --git a/ext/dapr-ext-workflow/tests/test_async_activity_registration.py b/ext/dapr-ext-workflow/tests/test_async_activity_registration.py new file mode 100644 index 000000000..e154aedb0 --- /dev/null +++ b/ext/dapr-ext-workflow/tests/test_async_activity_registration.py @@ -0,0 +1,259 @@ +# -*- coding: utf-8 -*- + +# Copyright 2026 The Dapr Authors +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for sync/async activity registration and the resulting wrappers. + +These tests exercise the helpers in workflow_runtime that decide whether an activity +runs in a thread pool (sync) or as a coroutine on the event loop (async). The +WorkflowRuntime is constructed against a fake registry so we don't need a sidecar. +""" + +import asyncio +import functools +import inspect +import unittest +from unittest import mock + +from dapr.ext.workflow.workflow_activity_context import WorkflowActivityContext +from dapr.ext.workflow.workflow_runtime import WorkflowRuntime, _is_async_callable +from pydantic import BaseModel + + +class OrderInput(BaseModel): + order_id: str + amount: float + + +class FakeRegistry: + def __init__(self): + self.activities: dict[str, object] = {} + + def add_named_activity(self, name: str, fn) -> None: + self.activities[name] = fn + + +class _AsyncActivityRegistrationTestBase(unittest.TestCase): + def setUp(self) -> None: + self._registry_patch = mock.patch( + 'dapr.ext.workflow._durabletask.worker._Registry', return_value=FakeRegistry() + ) + self._registry_patch.start() + self.runtime = WorkflowRuntime() + # Reach into the runtime to grab its registry for assertions. + self.registry: FakeRegistry = self.runtime._WorkflowRuntime__worker._registry + + def tearDown(self) -> None: + # Tear down the worker's ThreadPoolExecutor so each test doesn't leak threads/fds. + # The runtime never started, so ``shutdown()`` -> ``stop()`` early-returns; + # shut the manager down directly to actually close the executor. + worker = self.runtime._WorkflowRuntime__worker + self.runtime.shutdown() + worker._async_worker_manager.shutdown() + self._registry_patch.stop() + + +class AsyncActivityRegistrationTest(_AsyncActivityRegistrationTestBase): + def test_async_activity_registers_coroutine_wrapper(self) -> None: + async def my_async_activity(ctx: WorkflowActivityContext, payload: str) -> str: + return payload.upper() + + self.runtime.register_activity(my_async_activity) + + wrapper = self.registry.activities['my_async_activity'] + self.assertTrue(inspect.iscoroutinefunction(wrapper)) + + def test_sync_activity_registers_plain_wrapper(self) -> None: + def my_sync_activity(ctx: WorkflowActivityContext, payload: str) -> str: + return payload.upper() + + self.runtime.register_activity(my_sync_activity) + + wrapper = self.registry.activities['my_sync_activity'] + self.assertFalse(inspect.iscoroutinefunction(wrapper)) + self.assertTrue(callable(wrapper)) + + def test_async_wrapper_awaits_user_function(self) -> None: + recorded: list[tuple[WorkflowActivityContext, str]] = [] + + async def my_async_activity(ctx: WorkflowActivityContext, payload: str) -> str: + await asyncio.sleep(0) + recorded.append((ctx, payload)) + return payload.upper() + + self.runtime.register_activity(my_async_activity) + wrapper = self.registry.activities['my_async_activity'] + + fake_ctx = mock.MagicMock(spec=['task_id']) + fake_ctx.task_id = 7 + result = asyncio.run(wrapper(fake_ctx, 'hello')) + + self.assertEqual(result, 'HELLO') + self.assertEqual(len(recorded), 1) + self.assertEqual(recorded[0][1], 'hello') + self.assertIsInstance(recorded[0][0], WorkflowActivityContext) + + def test_sync_wrapper_calls_user_function(self) -> None: + recorded: list[tuple[WorkflowActivityContext, str]] = [] + + def my_sync_activity(ctx: WorkflowActivityContext, payload: str) -> str: + recorded.append((ctx, payload)) + return payload.upper() + + self.runtime.register_activity(my_sync_activity) + wrapper = self.registry.activities['my_sync_activity'] + + fake_ctx = mock.MagicMock(spec=['task_id']) + fake_ctx.task_id = 3 + result = wrapper(fake_ctx, 'world') + + self.assertEqual(result, 'WORLD') + self.assertEqual(len(recorded), 1) + self.assertEqual(recorded[0][1], 'world') + self.assertIsInstance(recorded[0][0], WorkflowActivityContext) + + def test_async_wrapper_coerces_input_to_declared_model(self) -> None: + seen: list[OrderInput] = [] + + async def place_order(ctx: WorkflowActivityContext, order: OrderInput) -> str: + seen.append(order) + return order.order_id + + self.runtime.register_activity(place_order) + wrapper = self.registry.activities['place_order'] + + fake_ctx = mock.MagicMock(spec=['task_id']) + fake_ctx.task_id = 99 + raw_input = {'order_id': 'abc-1', 'amount': 9.5} + result = asyncio.run(wrapper(fake_ctx, raw_input)) + + self.assertEqual(result, 'abc-1') + self.assertEqual(len(seen), 1) + self.assertIsInstance(seen[0], OrderInput) + self.assertEqual(seen[0].amount, 9.5) + + def test_async_wrapper_propagates_exceptions(self) -> None: + async def failing(ctx: WorkflowActivityContext, payload: str) -> str: + raise RuntimeError('boom') + + self.runtime.register_activity(failing) + wrapper = self.registry.activities['failing'] + + fake_ctx = mock.MagicMock(spec=['task_id']) + fake_ctx.task_id = 1 + with self.assertRaises(RuntimeError) as caught: + asyncio.run(wrapper(fake_ctx, 'x')) + self.assertEqual(str(caught.exception), 'boom') + + def test_async_wrapper_supports_no_input_parameter(self) -> None: + async def heartbeat(ctx: WorkflowActivityContext) -> str: + return 'ok' + + self.runtime.register_activity(heartbeat) + wrapper = self.registry.activities['heartbeat'] + + fake_ctx = mock.MagicMock(spec=['task_id']) + fake_ctx.task_id = 0 + result = asyncio.run(wrapper(fake_ctx, None)) + self.assertEqual(result, 'ok') + + +class IsAsyncCallableTest(unittest.TestCase): + """Pin the contract of ``_is_async_callable`` against decorator shapes that a bare + ``inspect.iscoroutinefunction`` would miss. These are the patterns the fix for finding + #5 was meant to address. Without coverage, a future refactor can silently regress + async-activity routing for any of them. + """ + + def test_plain_async_function_is_async(self) -> None: + async def fn() -> None: ... + + self.assertTrue(_is_async_callable(fn)) + + def test_plain_sync_function_is_not_async(self) -> None: + def fn() -> None: ... + + self.assertFalse(_is_async_callable(fn)) + + def test_functools_partial_of_async_is_async(self) -> None: + async def fn(prefix: str, payload: str) -> str: + return prefix + payload + + partial_fn = functools.partial(fn, 'hello-') + self.assertTrue(_is_async_callable(partial_fn)) + + def test_functools_partial_of_sync_is_not_async(self) -> None: + def fn(prefix: str, payload: str) -> str: + return prefix + payload + + partial_fn = functools.partial(fn, 'hello-') + self.assertFalse(_is_async_callable(partial_fn)) + + def test_wraps_chain_over_async_is_async(self) -> None: + """A sync decorator that uses @functools.wraps exposes the inner via __wrapped__.""" + + async def inner(ctx: object, inp: object) -> None: ... + + @functools.wraps(inner) + def outer(ctx: object, inp: object) -> object: + return inner(ctx, inp) + + self.assertTrue(_is_async_callable(outer)) + + def test_nested_partial_and_wraps_chain_is_async(self) -> None: + """partial(@wraps over async). Exercises both unwrap stages in order.""" + + async def inner(prefix: str, payload: str) -> str: + return prefix + payload + + @functools.wraps(inner) + def wrapped(prefix: str, payload: str) -> str: + return inner(prefix, payload) + + partial_wrapped = functools.partial(wrapped, 'hi-') + self.assertTrue(_is_async_callable(partial_wrapped)) + + def test_callable_class_instance_with_async_call_is_async(self) -> None: + class AsyncCallable: + async def __call__(self, ctx: object, inp: object) -> str: + return 'ok' + + self.assertTrue(_is_async_callable(AsyncCallable())) + + def test_callable_class_instance_with_sync_call_is_not_async(self) -> None: + class SyncCallable: + def __call__(self, ctx: object, inp: object) -> str: + return 'ok' + + self.assertFalse(_is_async_callable(SyncCallable())) + + +class AsyncAndSyncCoexistTest(_AsyncActivityRegistrationTestBase): + def test_runtime_registers_mixed_sync_and_async_activities(self) -> None: + async def async_activity(ctx: WorkflowActivityContext, payload: int) -> int: + return payload + 1 + + def sync_activity(ctx: WorkflowActivityContext, payload: int) -> int: + return payload * 2 + + self.runtime.register_activity(async_activity) + self.runtime.register_activity(sync_activity) + + async_wrapper = self.registry.activities['async_activity'] + sync_wrapper = self.registry.activities['sync_activity'] + + self.assertTrue(inspect.iscoroutinefunction(async_wrapper)) + self.assertFalse(inspect.iscoroutinefunction(sync_wrapper)) + + +if __name__ == '__main__': + unittest.main() diff --git a/uv.lock b/uv.lock index c9df9766b..fe68bbc18 100644 --- a/uv.lock +++ b/uv.lock @@ -209,6 +209,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, ] +[[package]] +name = "aws-requests-auth" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/54/b2/455c0bfcbd772dafd4c9e93c4b713e36790abf9ccbca9b8e661968b29798/aws-requests-auth-0.4.3.tar.gz", hash = "sha256:33593372018b960a31dbbe236f89421678b885c35f0b6a7abfae35bb77e069b2", size = 10096, upload-time = "2020-05-27T23:10:34.742Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/11/5dc8be418e1d54bed15eaf3a7461797e5ebb9e6a34869ad750561f35fa5b/aws_requests_auth-0.4.3-py2.py3-none-any.whl", hash = "sha256:646bc37d62140ea1c709d20148f5d43197e6bd2d63909eb36fa4bb2345759977", size = 6838, upload-time = "2020-05-27T23:10:33.658Z" }, +] + [[package]] name = "backports-asyncio-runner" version = "1.2.0" @@ -218,6 +230,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, ] +[[package]] +name = "beautifulsoup4" +version = "4.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, +] + [[package]] name = "blinker" version = "1.9.0" @@ -825,6 +850,7 @@ dependencies = [ { name = "msgpack-python" }, { name = "python-ulid" }, { name = "strands-agents" }, + { name = "strands-agents-tools" }, ] [package.metadata] @@ -833,6 +859,7 @@ requires-dist = [ { name = "msgpack-python", specifier = ">=0.4.5,<1.0.0" }, { name = "python-ulid", specifier = ">=3.0.0,<4.0.0" }, { name = "strands-agents", specifier = ">=1.30.0,<2.0.0" }, + { name = "strands-agents-tools", specifier = ">=0.2.22,<1.0.0" }, ] [[package]] @@ -857,6 +884,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178, upload-time = "2020-04-20T14:23:36.581Z" }, ] +[[package]] +name = "dill" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, +] + [[package]] name = "docstring-parser" version = "0.17.0" @@ -1467,7 +1503,7 @@ wheels = [ [[package]] name = "langsmith" -version = "0.8.6" +version = "0.7.17" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -1477,13 +1513,12 @@ dependencies = [ { name = "requests" }, { name = "requests-toolbelt" }, { name = "uuid-utils" }, - { name = "websockets" }, { name = "xxhash" }, { name = "zstandard" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7a/61/d269b8bd3376031de7be6ac2de8ba94fafff67635195d97aa0e842027ac7/langsmith-0.8.6.tar.gz", hash = "sha256:a46fd3403c2de3a9c34f72ebb7b2e45872627671adcc67c6a4c571520b6931cc", size = 4463093, upload-time = "2026-05-27T22:51:52.928Z" } +sdist = { url = "https://files.pythonhosted.org/packages/71/79/81041dde07a974e728db7def23c1c7255950b8874102925cc77093bc847d/langsmith-0.7.17.tar.gz", hash = "sha256:6c1b0c2863cdd6636d2a58b8d5b1b80060703d98cac2593f4233e09ac25b5a9d", size = 1132228, upload-time = "2026-03-12T20:41:10.808Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/c5/28f99eccd79ce89ec93de9a5039a74ddf4740f2d9671b0a06c5d2e200914/langsmith-0.8.6-py3-none-any.whl", hash = "sha256:b304888ea5ec5fe397db24f0bf474b0c8e472fb23ee36a2007e9837f6ff29cc1", size = 399954, upload-time = "2026-05-27T22:51:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/34/31/62689d57f4d25792bd6a3c05c868771899481be2f3e31f9e71d31e1ac4ab/langsmith-0.7.17-py3-none-any.whl", hash = "sha256:cbec10460cb6c6ecc94c18c807be88a9984838144ae6c4693c9f859f378d7d02", size = 359147, upload-time = "2026-03-12T20:41:08.758Z" }, ] [[package]] @@ -1571,6 +1606,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "markdownify" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/bc/c8c8eea5335341306b0fa7e1cb33c5e1c8d24ef70ddd684da65f41c49c92/markdownify-1.2.2.tar.gz", hash = "sha256:b274f1b5943180b031b699b199cbaeb1e2ac938b75851849a31fd0c3d6603d09", size = 18816, upload-time = "2025-11-16T19:21:18.565Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ce/f1e3e9d959db134cedf06825fae8d5b294bd368aacdd0831a3975b7c4d55/markdownify-1.2.2-py3-none-any.whl", hash = "sha256:3f02d3cc52714084d6e589f70397b6fc9f2f3a8531481bf35e8cc39f975e186a", size = 15724, upload-time = "2025-11-16T19:21:17.622Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -1681,6 +1741,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, ] +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "mechanical-markdown" version = "0.8.0" @@ -1709,6 +1778,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/7f/a946aa4f8752b37102b41e64dca18a1976ac705c3a0d1dfe74d820a02552/mistune-3.2.1-py3-none-any.whl", hash = "sha256:78cdb0ba5e938053ccf63651b352508d2efa9411dc8810bfb05f2dc5140c0048", size = 53749, upload-time = "2026-05-03T14:33:20.551Z" }, ] +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + [[package]] name = "msgpack-python" version = "0.5.6" @@ -2228,6 +2306,104 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, ] +[[package]] +name = "pillow" +version = "12.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/30/5bd3d794762481f8c8ae9c80e7b76ecea73b916959eb587521358ef0b2f9/pillow-12.1.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1f1625b72740fdda5d77b4def688eb8fd6490975d06b909fd19f13f391e077e0", size = 5304099, upload-time = "2026-02-11T04:20:06.13Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c1/aab9e8f3eeb4490180e357955e15c2ef74b31f64790ff356c06fb6cf6d84/pillow-12.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:178aa072084bd88ec759052feca8e56cbb14a60b39322b99a049e58090479713", size = 4657880, upload-time = "2026-02-11T04:20:09.291Z" }, + { url = "https://files.pythonhosted.org/packages/f1/0a/9879e30d56815ad529d3985aeff5af4964202425c27261a6ada10f7cbf53/pillow-12.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b66e95d05ba806247aaa1561f080abc7975daf715c30780ff92a20e4ec546e1b", size = 6222587, upload-time = "2026-02-11T04:20:10.82Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5f/a1b72ff7139e4f89014e8d451442c74a774d5c43cd938fb0a9f878576b37/pillow-12.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89c7e895002bbe49cdc5426150377cbbc04767d7547ed145473f496dfa40408b", size = 8027678, upload-time = "2026-02-11T04:20:12.455Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c2/c7cb187dac79a3d22c3ebeae727abee01e077c8c7d930791dc592f335153/pillow-12.1.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a5cbdcddad0af3da87cb16b60d23648bc3b51967eb07223e9fed77a82b457c4", size = 6335777, upload-time = "2026-02-11T04:20:14.441Z" }, + { url = "https://files.pythonhosted.org/packages/0c/7b/f9b09a7804ec7336effb96c26d37c29d27225783dc1501b7d62dcef6ae25/pillow-12.1.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f51079765661884a486727f0729d29054242f74b46186026582b4e4769918e4", size = 7027140, upload-time = "2026-02-11T04:20:16.387Z" }, + { url = "https://files.pythonhosted.org/packages/98/b2/2fa3c391550bd421b10849d1a2144c44abcd966daadd2f7c12e19ea988c4/pillow-12.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:99c1506ea77c11531d75e3a412832a13a71c7ebc8192ab9e4b2e355555920e3e", size = 6449855, upload-time = "2026-02-11T04:20:18.554Z" }, + { url = "https://files.pythonhosted.org/packages/96/ff/9caf4b5b950c669263c39e96c78c0d74a342c71c4f43fd031bb5cb7ceac9/pillow-12.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36341d06738a9f66c8287cf8b876d24b18db9bd8740fa0672c74e259ad408cff", size = 7151329, upload-time = "2026-02-11T04:20:20.646Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f8/4b24841f582704da675ca535935bccb32b00a6da1226820845fac4a71136/pillow-12.1.1-cp310-cp310-win32.whl", hash = "sha256:6c52f062424c523d6c4db85518774cc3d50f5539dd6eed32b8f6229b26f24d40", size = 6325574, upload-time = "2026-02-11T04:20:22.43Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f9/9f6b01c0881d7036063aa6612ef04c0e2cad96be21325a1e92d0203f8e91/pillow-12.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:c6008de247150668a705a6338156efb92334113421ceecf7438a12c9a12dab23", size = 7032347, upload-time = "2026-02-11T04:20:23.932Z" }, + { url = "https://files.pythonhosted.org/packages/79/13/c7922edded3dcdaf10c59297540b72785620abc0538872c819915746757d/pillow-12.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:1a9b0ee305220b392e1124a764ee4265bd063e54a751a6b62eff69992f457fa9", size = 2453457, upload-time = "2026-02-11T04:20:25.392Z" }, + { url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084, upload-time = "2026-02-11T04:20:27.501Z" }, + { url = "https://files.pythonhosted.org/packages/78/93/a29e9bc02d1cf557a834da780ceccd54e02421627200696fcf805ebdc3fb/pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", size = 4657866, upload-time = "2026-02-11T04:20:29.827Z" }, + { url = "https://files.pythonhosted.org/packages/13/84/583a4558d492a179d31e4aae32eadce94b9acf49c0337c4ce0b70e0a01f2/pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", size = 6232148, upload-time = "2026-02-11T04:20:31.329Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e2/53c43334bbbb2d3b938978532fbda8e62bb6e0b23a26ce8592f36bcc4987/pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090", size = 8038007, upload-time = "2026-02-11T04:20:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/3d0e79c8a9d58150dd98e199d7c1c56861027f3829a3a60b3c2784190180/pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af", size = 6345418, upload-time = "2026-02-11T04:20:35.858Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c8/46dfeac5825e600579157eea177be43e2f7ff4a99da9d0d0a49533509ac5/pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b", size = 7034590, upload-time = "2026-02-11T04:20:37.91Z" }, + { url = "https://files.pythonhosted.org/packages/af/bf/e6f65d3db8a8bbfeaf9e13cc0417813f6319863a73de934f14b2229ada18/pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5", size = 6458655, upload-time = "2026-02-11T04:20:39.496Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c2/66091f3f34a25894ca129362e510b956ef26f8fb67a0e6417bc5744e56f1/pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d", size = 7159286, upload-time = "2026-02-11T04:20:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5a/24bc8eb526a22f957d0cec6243146744966d40857e3d8deb68f7902ca6c1/pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c", size = 6328663, upload-time = "2026-02-11T04:20:43.184Z" }, + { url = "https://files.pythonhosted.org/packages/31/03/bef822e4f2d8f9d7448c133d0a18185d3cce3e70472774fffefe8b0ed562/pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563", size = 7031448, upload-time = "2026-02-11T04:20:44.696Z" }, + { url = "https://files.pythonhosted.org/packages/49/70/f76296f53610bd17b2e7d31728b8b7825e3ac3b5b3688b51f52eab7c0818/pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80", size = 2453651, upload-time = "2026-02-11T04:20:46.243Z" }, + { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, + { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, + { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, + { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, + { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, + { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, + { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, + { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, + { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, + { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, + { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, + { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, + { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, + { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, + { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, + { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, + { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, + { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, + { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, + { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, + { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, + { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, + { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, + { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, + { url = "https://files.pythonhosted.org/packages/56/11/5d43209aa4cb58e0cc80127956ff1796a68b928e6324bbf06ef4db34367b/pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", size = 5228606, upload-time = "2026-02-11T04:22:52.106Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d5/3b005b4e4fda6698b371fa6c21b097d4707585d7db99e98d9b0b87ac612a/pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", size = 4622321, upload-time = "2026-02-11T04:22:53.827Z" }, + { url = "https://files.pythonhosted.org/packages/df/36/ed3ea2d594356fd8037e5a01f6156c74bc8d92dbb0fa60746cc96cabb6e8/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", size = 5247579, upload-time = "2026-02-11T04:22:56.094Z" }, + { url = "https://files.pythonhosted.org/packages/54/9a/9cc3e029683cf6d20ae5085da0dafc63148e3252c2f13328e553aaa13cfb/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9", size = 6989094, upload-time = "2026-02-11T04:22:58.288Z" }, + { url = "https://files.pythonhosted.org/packages/00/98/fc53ab36da80b88df0967896b6c4b4cd948a0dc5aa40a754266aa3ae48b3/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3", size = 5313850, upload-time = "2026-02-11T04:23:00.554Z" }, + { url = "https://files.pythonhosted.org/packages/30/02/00fa585abfd9fe9d73e5f6e554dc36cc2b842898cbfc46d70353dae227f8/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735", size = 5963343, upload-time = "2026-02-11T04:23:02.934Z" }, + { url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -2237,6 +2413,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + [[package]] name = "propcache" version = "0.4.1" @@ -2611,11 +2799,11 @@ wheels = [ [[package]] name = "python-multipart" -version = "0.0.29" +version = "0.0.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/fe/70bd71a6738b09a0bdf6480ca6436b167469ca4578b2a0efbe390b4b0e70/python_multipart-0.0.29.tar.gz", hash = "sha256:643e93849196645e2dbdd81a0f8829a23123ad7f797a84a364c6fb3563f18904", size = 45678, upload-time = "2026-05-17T17:29:47.654Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/cb/769cfc37177252872a45a71f3fbdde9d51b471a3f3c14bfe95dde3407386/python_multipart-0.0.29-py3-none-any.whl", hash = "sha256:2ddcc971cef266225f54f552d8fa10bcfbb1f14446caec199060daac59ff2d69", size = 29640, upload-time = "2026-05-17T17:29:45.69Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, ] [[package]] @@ -2766,6 +2954,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, ] +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, +] + [[package]] name = "rpds-py" version = "0.30.0" @@ -2944,6 +3145,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "slack-bolt" +version = "1.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "slack-sdk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/28/50ed0b86e48b48e6ddcc71de93b91c8ac14a55d1249e4bff0586494a2f90/slack_bolt-1.27.0.tar.gz", hash = "sha256:3db91d64e277e176a565c574ae82748aa8554f19e41a4fceadca4d65374ce1e0", size = 129101, upload-time = "2025-11-13T20:17:46.878Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/a8/1acb355759747ba4da5f45c1a33d641994b9e04b914908c9434f18bd97e8/slack_bolt-1.27.0-py2.py3-none-any.whl", hash = "sha256:c43c94bf34740f2adeb9b55566c83f1e73fed6ba2878bd346cdfd6fd8ad22360", size = 230428, upload-time = "2025-11-13T20:17:45.465Z" }, +] + +[[package]] +name = "slack-sdk" +version = "3.41.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/35/fc009118a13187dd9731657c60138e5a7c2dea88681a7f04dc406af5da7d/slack_sdk-3.41.0.tar.gz", hash = "sha256:eb61eb12a65bebeca9cb5d36b3f799e836ed2be21b456d15df2627cfe34076ca", size = 250568, upload-time = "2026-03-12T16:10:11.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/df/2e4be347ff98281b505cc0ccf141408cdd25eb5ca9f3830deb361b2472d3/slack_sdk-3.41.0-py2.py3-none-any.whl", hash = "sha256:bb18dcdfff1413ec448e759cf807ec3324090993d8ab9111c74081623b692a89", size = 313885, upload-time = "2026-03-12T16:10:09.811Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, +] + [[package]] name = "sse-starlette" version = "3.3.2" @@ -2993,6 +3224,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/94/ecc2df8100fdf745d41d10ac2de4c9cb0325384d0e28b4bb90c82a6ec63b/strands_agents-1.30.0-py3-none-any.whl", hash = "sha256:457ba7b063df61d00f122c913b6b85ba6431d17741b9e34484a7e16fb7e00430", size = 386493, upload-time = "2026-03-11T18:38:30.503Z" }, ] +[[package]] +name = "strands-agents-tools" +version = "0.2.22" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "aws-requests-auth" }, + { name = "botocore" }, + { name = "dill" }, + { name = "markdownify" }, + { name = "pillow" }, + { name = "prompt-toolkit" }, + { name = "pyjwt" }, + { name = "requests" }, + { name = "rich" }, + { name = "slack-bolt" }, + { name = "strands-agents" }, + { name = "sympy" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/4e/c8c355cd7ca6d28f98231fd66fe5d4627c9ecd9ea79db21980ec429247e9/strands_agents_tools-0.2.22.tar.gz", hash = "sha256:99d202b329f12df8568f104d5301452526640fd0e4db164c0be23e0b22e3d350", size = 474105, upload-time = "2026-03-04T21:19:38.858Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/45/900277b3e0180017eaf1490eef3ed1c459c833a0d86746d777d67fb919ab/strands_agents_tools-0.2.22-py3-none-any.whl", hash = "sha256:4d0743b90bed9acd840d2dee943ea2de8a644d4e139686912b6239a0bd410963", size = 312781, upload-time = "2026-03-04T21:19:37.348Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + [[package]] name = "tenacity" version = "9.1.4" @@ -3125,13 +3396,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + [[package]] name = "urllib3" -version = "2.7.0" +version = "2.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, + { 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]] @@ -3210,71 +3490,12 @@ wheels = [ ] [[package]] -name = "websockets" -version = "16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" }, - { url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" }, - { url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" }, - { url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" }, - { url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" }, - { url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" }, - { url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" }, - { url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" }, - { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, - { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, - { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, - { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, - { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, - { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, - { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, - { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, - { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, - { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, - { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, - { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, - { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, - { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, - { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, - { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, - { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, - { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, - { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, - { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, - { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, - { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, - { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, - { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, - { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, - { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, - { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, - { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, - { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, - { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, - { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, - { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, - { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, - { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, - { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, - { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, - { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, - { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, - { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, - { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +name = "wcwidth" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, ] [[package]]