diff --git a/python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py b/python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py index b15a2569c1..a46707ac0d 100644 --- a/python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py +++ b/python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py @@ -431,7 +431,7 @@ def _build_execution_contents( outputs.append(Content.from_text(stderr, raw_representation=result)) if not outputs: outputs.append(Content.from_text("Code executed successfully without output.")) - return [Content.from_code_interpreter_tool_result(outputs=outputs, raw_representation=result)] + return outputs error_details = stderr or "Unknown sandbox error" outputs.append( @@ -441,12 +441,16 @@ def _build_execution_contents( raw_representation=result, ) ) - return [Content.from_code_interpreter_tool_result(outputs=outputs, raw_representation=result)] + return outputs def _make_sandbox_callback(tool_obj: FunctionTool) -> Callable[..., Any]: sandbox_tool = copy.copy(tool_obj) - sandbox_tool.result_parser = _passthrough_result_parser + # Auto-assign a passthrough parser so the raw return value round-trips through + # `ast.literal_eval` in the sandbox callback below. User-supplied parsers are + # left in place so callers can customize how results are exposed to the guest. + if sandbox_tool.result_parser is None: + sandbox_tool.result_parser = _passthrough_result_parser def _callback(**kwargs: Any) -> Any: async def _invoke() -> list[Content]: @@ -765,6 +769,7 @@ class HyperlightExecuteCodeTool(FunctionTool): return build_codeact_instructions( tools=config.tools, tools_visible_to_model=tools_visible_to_model, + filesystem_enabled=config.filesystem_enabled, ) def create_run_tool(self) -> HyperlightExecuteCodeTool: diff --git a/python/packages/hyperlight/agent_framework_hyperlight/_instructions.py b/python/packages/hyperlight/agent_framework_hyperlight/_instructions.py index f866c1349c..c44a183062 100644 --- a/python/packages/hyperlight/agent_framework_hyperlight/_instructions.py +++ b/python/packages/hyperlight/agent_framework_hyperlight/_instructions.py @@ -68,6 +68,7 @@ def build_codeact_instructions( *, tools: Sequence[FunctionTool], tools_visible_to_model: bool, + filesystem_enabled: bool = False, ) -> str: """Build dynamic CodeAct instructions for the effective sandbox state.""" usage_note = ( @@ -77,12 +78,24 @@ def build_codeact_instructions( else "Provider-owned sandbox tools are not exposed separately; use `execute_code` when you need them." ) + output_note = ( + "To surface results from `execute_code`, end the code with `print(...)`; the sandbox does not " + "return the value of the last expression." + ) + if filesystem_enabled: + output_note += ( + " For larger artifacts, write them to `/output/` instead — returned files will be " + "attached to the tool result." + ) + return f"""You have one primary tool: execute_code. Prefer one execute_code call per request when possible. Its tool description contains the current `call_tool(...)` guidance, sandbox tool registry, and capability limits. +{output_note} + {usage_note} """ diff --git a/python/packages/hyperlight/pyproject.toml b/python/packages/hyperlight/pyproject.toml index 9884152043..21034b1a8e 100644 --- a/python/packages/hyperlight/pyproject.toml +++ b/python/packages/hyperlight/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ dependencies = [ "agent-framework-core>=1.0.0,<2", "hyperlight-sandbox>=0.3.0,<0.4", - "hyperlight-sandbox-backend-wasm>=0.3.0,<0.4 ; (sys_platform == 'linux' or sys_platform == 'win32') and python_version < '3.14'", + "hyperlight-sandbox-backend-wasm>=0.3.0,<0.4 ; ((sys_platform == 'linux' and platform_machine == 'x86_64') or (sys_platform == 'win32' and platform_machine == 'AMD64')) and python_version < '3.14'", "hyperlight-sandbox-python-guest>=0.3.0,<0.4", ] diff --git a/python/packages/hyperlight/samples/codeact_benchmark.py b/python/packages/hyperlight/samples/codeact_benchmark.py new file mode 100644 index 0000000000..275187d3b8 --- /dev/null +++ b/python/packages/hyperlight/samples/codeact_benchmark.py @@ -0,0 +1,253 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Benchmark CodeAct vs. traditional tool-calling for a multi-tool-call task. + +This sample runs the same prompt against the same FoundryChatClient twice: + +1. **Traditional tool-calling**: the five business tools are passed directly to + the agent, so the model calls each tool individually via the LLM tool-call + interface. +2. **CodeAct**: the same tools are registered on a HyperlightCodeActProvider + and the model sees a single ``execute_code`` tool that calls them from + inside the Hyperlight sandbox via ``call_tool(...)``. + +The task (computing grand totals per user) naturally requires many tool calls +to complete. At the end, the sample prints elapsed time and token usage for +each run so the two approaches can be compared. + +Run with: + cd python + uv run --directory packages/hyperlight python samples/codeact_benchmark.py + +Required environment variables (loaded from ``.env`` if present): + FOUNDRY_PROJECT_ENDPOINT + FOUNDRY_MODEL +""" + +from __future__ import annotations + +import asyncio +import os +import time +from typing import Annotated, Any, Literal + +from agent_framework import Agent, AgentResponse, UsageDetails +from agent_framework.foundry import FoundryChatClient +from azure.identity import AzureCliCredential +from dotenv import load_dotenv +from pydantic import BaseModel, Field + +from agent_framework_hyperlight import HyperlightCodeActProvider + +load_dotenv() + + +# 1. Deterministic "business" data and tools. + +_USERS: list[dict[str, Any]] = [ + {"id": 1, "name": "Alice", "region": "EU", "tier": "gold"}, + {"id": 2, "name": "Bob", "region": "US", "tier": "silver"}, + {"id": 3, "name": "Charlie", "region": "US", "tier": "gold"}, + {"id": 4, "name": "Diana", "region": "APAC", "tier": "bronze"}, + {"id": 5, "name": "Evan", "region": "EU", "tier": "silver"}, + {"id": 6, "name": "Fiona", "region": "US", "tier": "gold"}, + {"id": 7, "name": "George", "region": "APAC", "tier": "gold"}, + {"id": 8, "name": "Hana", "region": "EU", "tier": "bronze"}, +] + +_ORDERS: dict[int, list[dict[str, Any]]] = { + 1: [{"product": "Widget", "qty": 3, "unit_price": 9.99}, {"product": "Gadget", "qty": 1, "unit_price": 19.99}], + 2: [{"product": "Widget", "qty": 1, "unit_price": 9.99}], + 3: [{"product": "Gadget", "qty": 2, "unit_price": 19.99}, {"product": "Thingamajig", "qty": 4, "unit_price": 4.50}], + 4: [{"product": "Widget", "qty": 10, "unit_price": 9.99}], + 5: [{"product": "Gadget", "qty": 1, "unit_price": 19.99}], + 6: [{"product": "Widget", "qty": 2, "unit_price": 9.99}, {"product": "Thingamajig", "qty": 5, "unit_price": 4.50}], + 7: [{"product": "Gadget", "qty": 3, "unit_price": 19.99}], + 8: [{"product": "Thingamajig", "qty": 2, "unit_price": 4.50}], +} + +_DISCOUNTS: dict[str, float] = {"gold": 0.20, "silver": 0.10, "bronze": 0.05} +_TAX_RATES: dict[str, float] = {"EU": 0.21, "US": 0.08, "APAC": 0.10} + + +def list_users() -> list[dict[str, Any]]: + """Return all users as a list of dictionaries. + + Each entry has keys: id (int), name (str), region (str), tier (str). + """ + return _USERS + + +def get_orders_for_user( + user_id: Annotated[int, "The user id whose orders to retrieve."], +) -> list[dict[str, Any]]: + """Return the user's orders as a list of dictionaries. + + Each entry has keys: product (str), qty (int), unit_price (float). + """ + return _ORDERS.get(user_id, []) + + +def get_discount_rate( + tier: Annotated[Literal["gold", "silver", "bronze"], "The customer tier."], +) -> float: + """Return the discount rate as a float fraction (e.g. 0.2 for 20%).""" + return _DISCOUNTS[tier] + + +def get_tax_rate( + region: Annotated[Literal["EU", "US", "APAC"], "The region code."], +) -> float: + """Return the tax rate as a float fraction (e.g. 0.21 for 21%).""" + return _TAX_RATES[region] + + +def compute_line_total( + qty: Annotated[int, "Line item quantity."], + unit_price: Annotated[float, "Line item unit price."], + discount_rate: Annotated[float, "Discount rate as a fraction (e.g. 0.2 for 20%)."], + tax_rate: Annotated[float, "Tax rate as a fraction (e.g. 0.21 for 21%)."], +) -> float: + """Compute a single order line total. + + Formula: qty * unit_price * (1 - discount_rate) * (1 + tax_rate), rounded to 2 decimals. + """ + subtotal = qty * unit_price + discounted = subtotal * (1.0 - discount_rate) + return round(discounted * (1.0 + tax_rate), 2) + + +TOOLS = [list_users, get_orders_for_user, get_discount_rate, get_tax_rate, compute_line_total] + + +# 2. Structured output schema shared between both runs. + + +class UserTotal(BaseModel): + """A user's grand total of all their orders.""" + + user_id: int = Field(description="The user's id.") + name: str = Field(description="The user's display name.") + grand_total: float = Field(description="Sum of all line totals, rounded to 2 decimals.") + + +class UserGrandTotals(BaseModel): + """Structured output schema for both runs.""" + + results: list[UserTotal] = Field(description="One entry per user, sorted by grand_total descending.") + + +INSTRUCTIONS = "You are a careful assistant. Use the provided tools for every lookup and computation." + +BENCHMARK_PROMPT = ( + "For every user in our system (there are 8 of them), compute the grand total of all their orders. " + "Use the compute_line_total tool for each user's orders, after looking up the relevant discount and " + "tax rates for that user. " + "Use the provided tools for EVERY data lookup (users, orders, discount rates, tax rates) and for EVERY " + "line-total computation via compute_line_total — do not invent values or hardcode any numbers. " + "The total per order item should apply the discount first and then the tax " + "(e.g. total = qty * unit_price * (1-discount) * (1+tax)). " + "Return one entry per user, sorted by grand_total descending." +) + + +def get_client() -> FoundryChatClient: + """Create a FoundryChatClient from environment variables.""" + return FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=AzureCliCredential(), + ) + + +# 3. Two runners that share the same tools, prompt, and structured output schema. + + +async def _run_traditional() -> tuple[float, AgentResponse]: + agent = Agent( + client=get_client(), + name="TraditionalAgent", + instructions=INSTRUCTIONS, + tools=TOOLS, + default_options={"response_format": UserGrandTotals}, + ) + start = time.perf_counter() + result = await agent.run(BENCHMARK_PROMPT) + elapsed = time.perf_counter() - start + return elapsed, result + + +async def _run_codeact() -> tuple[float, AgentResponse]: + codeact = HyperlightCodeActProvider( + tools=TOOLS, + approval_mode="never_require", + ) + agent = Agent( + client=get_client(), + name="CodeActAgent", + instructions=INSTRUCTIONS, + context_providers=[codeact], + default_options={"response_format": UserGrandTotals}, + ) + start = time.perf_counter() + result = await agent.run(BENCHMARK_PROMPT) + elapsed = time.perf_counter() - start + return elapsed, result + + +# 4. Report results side by side. + + +def _print_section(title: str) -> None: + bar = "=" * 70 + print(f"\n{bar}\n{title}\n{bar}") + + +def _format_usage(usage: UsageDetails | None) -> str: + if usage is None: + return "usage=" + return ( + f"input={usage.get('input_token_count') or 0:>6} " + f"output={usage.get('output_token_count') or 0:>6} " + f"total={usage.get('total_token_count') or 0:>6}" + ) + + +def _print_results(result: AgentResponse) -> None: + if result.value is not None: + for row in result.value.results: + print(f" user_id={row.user_id:>2} name={row.name:<8} grand_total={row.grand_total:>8.2f}") + else: + print(result.text) + + +async def main() -> None: + """Run the benchmark and print a comparison.""" + trad_time, trad_result = await _run_traditional() + code_time, code_result = await _run_codeact() + + _print_section("Traditional tool-calling") + print(f"time={trad_time:7.2f}s {_format_usage(trad_result.usage_details)}") + _print_results(trad_result) + + _print_section("CodeAct (HyperlightCodeActProvider)") + print(f"time={code_time:7.2f}s {_format_usage(code_result.usage_details)}") + _print_results(code_result) + + _print_section("Comparison") + trad_total = (trad_result.usage_details or {}).get("total_token_count") or 0 + code_total = (code_result.usage_details or {}).get("total_token_count") or 0 + + def pct(new: float, old: float) -> str: + if old == 0: + return "n/a" + delta = (new - old) / old * 100 + sign = "+" if delta >= 0 else "" + return f"{sign}{delta:.1f}%" + + print(f"time : traditional={trad_time:7.2f}s codeact={code_time:7.2f}s delta={pct(code_time, trad_time)}") + print(f"tokens : traditional={trad_total:7d} codeact={code_total:7d} delta={pct(code_total, trad_total)}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/packages/hyperlight/samples/codeact_context_provider.py b/python/packages/hyperlight/samples/codeact_context_provider.py index c0cc03c2f6..81b55034e5 100644 --- a/python/packages/hyperlight/samples/codeact_context_provider.py +++ b/python/packages/hyperlight/samples/codeact_context_provider.py @@ -72,15 +72,11 @@ async def log_function_calls( result = context.result if function_name == "execute_code" and isinstance(result, list): - for item in result: - if item.type != "code_interpreter_tool_result": - continue - - for output in item.outputs or []: - if output.type == "text" and output.text: - print(f"{_GREEN}stdout:\n{output.text}{_RESET}") - if output.type == "error" and output.error_details: - print(f"{_YELLOW}stderr:\n{output.error_details}{_RESET}") + for output in result: + if output.type == "text" and output.text: + print(f"{_GREEN}stdout:\n{output.text}{_RESET}") + elif output.type == "error" and output.error_details: + print(f"{_YELLOW}stderr:\n{output.error_details}{_RESET}") else: print(f"{_YELLOW}◀ {function_name} → {result!r}{_RESET}") diff --git a/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py b/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py index 528b6e3b5b..ab6a3f7c78 100644 --- a/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py +++ b/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py @@ -289,38 +289,20 @@ class _FakeSessionContext: self.tools.append((source_id, tools)) -def _extract_execute_code_result(function_result: Content) -> Content: +def _extract_text_output(function_result: Content) -> str: assert function_result.type == "function_result" assert function_result.exception is None, ( f"execute_code raised {function_result.exception!r} with items={function_result.items!r}" ) - - code_result = next( - (item for item in function_result.items or [] if item.type == "code_interpreter_tool_result"), + text_output = next( + (item for item in function_result.items or [] if item.type == "text" and item.text is not None), None, ) - if code_result is not None: - return code_result - - text_outputs = [item for item in function_result.items or [] if item.type == "text"] - if text_outputs: - return Content.from_code_interpreter_tool_result(outputs=text_outputs) - + if text_output is not None and text_output.text is not None: + return text_output.text if function_result.result: - return Content.from_code_interpreter_tool_result(outputs=[Content.from_text(function_result.result)]) - - raise AssertionError(f"execute_code returned no usable outputs: {function_result.items!r}") - - -def _extract_text_output(result_content: Content) -> str: - code_result = _extract_execute_code_result(result_content) - text_output = next( - (item for item in code_result.outputs or [] if item.type == "text" and item.text is not None), None - ) - assert text_output is not None and text_output.text is not None, ( - f"Expected text output from execute_code, got {code_result.outputs!r}" - ) - return text_output.text + return function_result.result + raise AssertionError(f"Expected text output from execute_code, got {function_result.items!r}") class _FakeCodeActChatClient(FunctionInvocationLayer[Any], BaseChatClient[Any]): @@ -432,7 +414,7 @@ async def test_execute_code_tool_populates_input_dir_with_workspace_and_file_mou ) result = await execute_code.invoke(arguments={"code": "None"}) - assert result[0].type == "code_interpreter_tool_result" + assert result[0].type == "text" assert _FakeSandbox.instances[0].input_dir is not None input_root = Path(_FakeSandbox.instances[0].input_dir) @@ -493,11 +475,9 @@ async def test_execute_code_tool_executes_with_structured_content(monkeypatch: p result = await execute_code.invoke(arguments={"code": "create-output"}) - assert result[0].type == "code_interpreter_tool_result" - assert result[0].outputs is not None - assert result[0].outputs[0].type == "text" - assert result[0].outputs[0].text == "done\n" - assert any(item.type == "data" for item in result[0].outputs) + assert result[0].type == "text" + assert result[0].text == "done\n" + assert any(item.type == "data" for item in result) assert _FakeSandbox.instances[0].allowed_domains == [("api.example.com", ["GET"])] assert "compute" in _FakeSandbox.instances[0].registered_tools @@ -512,11 +492,8 @@ async def test_execute_code_tool_collects_output_files_without_backend_listing( ) result = await execute_code.invoke(arguments={"code": "create-output"}) - assert result[0].type == "code_interpreter_tool_result" - assert result[0].outputs is not None - assert any( - item.type == "data" and item.additional_properties["path"] == "/output/report.txt" for item in result[0].outputs - ) + assert result[0].type == "text" + assert any(item.type == "data" and item.additional_properties["path"] == "/output/report.txt" for item in result) async def test_execute_code_tool_waits_for_unlisted_output_files_to_appear( @@ -535,11 +512,7 @@ async def test_execute_code_tool_waits_for_unlisted_output_files_to_appear( for writer_thread in _FakeSandboxWithDelayedUnlistedOutput.writer_threads: writer_thread.join() - assert result[0].type == "code_interpreter_tool_result" - assert result[0].outputs is not None - assert any( - item.type == "data" and item.additional_properties["path"] == "/output/report.txt" for item in result[0].outputs - ) + assert any(item.type == "data" and item.additional_properties["path"] == "/output/report.txt" for item in result) async def test_execute_code_tool_failure_returns_error_content(monkeypatch: pytest.MonkeyPatch) -> None: @@ -549,10 +522,8 @@ async def test_execute_code_tool_failure_returns_error_content(monkeypatch: pyte execute_code = HyperlightExecuteCodeTool() result = await execute_code.invoke(arguments={"code": "fail"}) - assert result[0].type == "code_interpreter_tool_result" - assert result[0].outputs is not None - assert result[0].outputs[0].type == "error" - assert result[0].outputs[0].error_details == "sandbox boom" + assert result[0].type == "error" + assert result[0].error_details == "sandbox boom" async def test_execute_code_tool_retries_allowed_domains_with_urls_when_backend_rejects_host_targets( @@ -596,7 +567,7 @@ async def test_execute_code_tool_retries_allowed_domains_with_urls_when_backend_ execute_code = HyperlightExecuteCodeTool(allowed_domains=[("127.0.0.1:8080", "get")]) result = await execute_code.invoke(arguments={"code": "None"}) - assert result[0].type == "code_interpreter_tool_result" + assert result[0].type == "text" assert len(_FakeStrictNetworkSandbox.instances) == 2 assert _FakeStrictNetworkSandbox.instances[0].allowed_domains == [("127.0.0.1:8080", ["GET"])] assert _FakeStrictNetworkSandbox.instances[1].allowed_domains == [ @@ -731,8 +702,7 @@ async def test_provider_run_tool_writes_files_with_real_sandbox(tmp_path: Path) } ) - assert result[0].type == "code_interpreter_tool_result" - outputs = result[0].outputs or [] + outputs = result error_outputs = [ f"{item.message}: {item.error_details}" for item in outputs @@ -795,8 +765,7 @@ async def test_provider_run_tool_pings_bing_with_real_sandbox() -> None: } ) - assert result[0].type == "code_interpreter_tool_result" - outputs = result[0].outputs or [] + outputs = result error_outputs = [ f"{item.message}: {item.error_details}" for item in outputs @@ -823,9 +792,7 @@ async def test_sandbox_runs_simple_code(restored_sandbox) -> None: @skip_if_hyperlight_integration_tests_disabled async def test_sandbox_stdout_and_stderr_captured(restored_sandbox) -> None: - result = restored_sandbox.run( - 'import sys\nprint("out")\nprint("err", file=sys.stderr)' - ) + result = restored_sandbox.run('import sys\nprint("out")\nprint("err", file=sys.stderr)') assert result.success assert "out" in result.stdout assert "err" in result.stderr @@ -910,24 +877,17 @@ async def test_output_dir_cleared_between_invocations() -> None: # First invocation: write a file result1 = await run_tool.invoke( - arguments={ - "code": ( - 'with open("/output/stale.txt", "w") as f:\n' - ' f.write("first")\n' - 'print("wrote")\n' - ) - } + arguments={"code": ('with open("/output/stale.txt", "w") as f:\n f.write("first")\nprint("wrote")\n')} ) - assert result1[0].type == "code_interpreter_tool_result" - outputs1 = result1[0].outputs or [] + assert result1[0].type == "text" or result1[0].type == "data" + outputs1 = result1 assert any( - item.type == "data" and "stale.txt" in (item.additional_properties or {}).get("path", "") - for item in outputs1 + item.type == "data" and "stale.txt" in (item.additional_properties or {}).get("path", "") for item in outputs1 ), "First invocation should produce stale.txt" # Second invocation: no file writes result2 = await run_tool.invoke(arguments={"code": 'print("clean")\n'}) - outputs2 = result2[0].outputs or [] + outputs2 = result2 stale_files = [ item for item in outputs2 @@ -971,11 +931,9 @@ async def test_run_code_does_not_block_event_loop() -> None: concurrent_ran = True release.set() - code_task = asyncio.create_task( - run_tool.invoke(arguments={"code": 'print("done")\n'}) - ) + code_task = asyncio.create_task(run_tool.invoke(arguments={"code": 'print("done")\n'})) await _concurrent_task() result = await code_task assert concurrent_ran, "Event loop was blocked during sandbox execution" - assert result[0].type == "code_interpreter_tool_result" + assert result[0].type == "text" diff --git a/python/uv.lock b/python/uv.lock index a0090d1f75..370fd7e46d 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -553,7 +553,7 @@ source = { editable = "packages/hyperlight" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "hyperlight-sandbox", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "hyperlight-sandbox-backend-wasm", marker = "(python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" }, + { name = "hyperlight-sandbox-backend-wasm", marker = "(python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.14' and platform_machine == 'AMD64' and sys_platform == 'win32')" }, { name = "hyperlight-sandbox-python-guest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] @@ -561,7 +561,7 @@ dependencies = [ requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, { name = "hyperlight-sandbox", specifier = ">=0.3.0,<0.4" }, - { name = "hyperlight-sandbox-backend-wasm", marker = "(python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')", specifier = ">=0.3.0,<0.4" }, + { name = "hyperlight-sandbox-backend-wasm", marker = "(python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.14' and platform_machine == 'AMD64' and sys_platform == 'win32')", specifier = ">=0.3.0,<0.4" }, { name = "hyperlight-sandbox-python-guest", specifier = ">=0.3.0,<0.4" }, ] @@ -2426,7 +2426,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/3f/9859f655d11901e7b2996c6e3d33e0caa9a1d4572c3bc61ed0faa64b2f4c/greenlet-3.3.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9bc885b89709d901859cf95179ec9f6bb67a3d2bb1f0e88456461bd4b7f8fd0d", size = 277747, upload-time = "2026-02-20T20:16:21.325Z" }, { url = "https://files.pythonhosted.org/packages/fb/07/cb284a8b5c6498dbd7cba35d31380bb123d7dceaa7907f606c8ff5993cbf/greenlet-3.3.2-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b568183cf65b94919be4438dc28416b234b678c608cafac8874dfeeb2a9bbe13", size = 579202, upload-time = "2026-02-20T20:47:28.955Z" }, { url = "https://files.pythonhosted.org/packages/ed/45/67922992b3a152f726163b19f890a85129a992f39607a2a53155de3448b8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:527fec58dc9f90efd594b9b700662ed3fb2493c2122067ac9c740d98080a620e", size = 590620, upload-time = "2026-02-20T20:55:55.581Z" }, - { url = "https://files.pythonhosted.org/packages/03/5f/6e2a7d80c353587751ef3d44bb947f0565ec008a2e0927821c007e96d3a7/greenlet-3.3.2-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508c7f01f1791fbc8e011bd508f6794cb95397fdb198a46cb6635eb5b78d85a7", size = 602132, upload-time = "2026-02-20T21:02:43.261Z" }, { url = "https://files.pythonhosted.org/packages/ad/55/9f1ebb5a825215fadcc0f7d5073f6e79e3007e3282b14b22d6aba7ca6cb8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad0c8917dd42a819fe77e6bdfcb84e3379c0de956469301d9fd36427a1ca501f", size = 591729, upload-time = "2026-02-20T20:20:58.395Z" }, { url = "https://files.pythonhosted.org/packages/24/b4/21f5455773d37f94b866eb3cf5caed88d6cea6dd2c6e1f9c34f463cba3ec/greenlet-3.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:97245cc10e5515dbc8c3104b2928f7f02b6813002770cfaffaf9a6e0fc2b94ef", size = 1551946, upload-time = "2026-02-20T20:49:31.102Z" }, { url = "https://files.pythonhosted.org/packages/00/68/91f061a926abead128fe1a87f0b453ccf07368666bd59ffa46016627a930/greenlet-3.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8c1fdd7d1b309ff0da81d60a9688a8bd044ac4e18b250320a96fc68d31c209ca", size = 1618494, upload-time = "2026-02-20T20:21:06.541Z" }, @@ -2434,7 +2433,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f3/47/16400cb42d18d7a6bb46f0626852c1718612e35dcb0dffa16bbaffdf5dd2/greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86", size = 278890, upload-time = "2026-02-20T20:19:39.263Z" }, { url = "https://files.pythonhosted.org/packages/a3/90/42762b77a5b6aa96cd8c0e80612663d39211e8ae8a6cd47c7f1249a66262/greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f", size = 581120, upload-time = "2026-02-20T20:47:30.161Z" }, { url = "https://files.pythonhosted.org/packages/bf/6f/f3d64f4fa0a9c7b5c5b3c810ff1df614540d5aa7d519261b53fba55d4df9/greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55", size = 594363, upload-time = "2026-02-20T20:55:56.965Z" }, - { url = "https://files.pythonhosted.org/packages/9c/8b/1430a04657735a3f23116c2e0d5eb10220928846e4537a938a41b350bed6/greenlet-3.3.2-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4375a58e49522698d3e70cc0b801c19433021b5c37686f7ce9c65b0d5c8677d2", size = 605046, upload-time = "2026-02-20T21:02:45.234Z" }, { url = "https://files.pythonhosted.org/packages/72/83/3e06a52aca8128bdd4dcd67e932b809e76a96ab8c232a8b025b2850264c5/greenlet-3.3.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358", size = 594156, upload-time = "2026-02-20T20:20:59.955Z" }, { url = "https://files.pythonhosted.org/packages/70/79/0de5e62b873e08fe3cef7dbe84e5c4bc0e8ed0c7ff131bccb8405cd107c8/greenlet-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99", size = 1554649, upload-time = "2026-02-20T20:49:32.293Z" }, { url = "https://files.pythonhosted.org/packages/5a/00/32d30dee8389dc36d42170a9c66217757289e2afb0de59a3565260f38373/greenlet-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be", size = 1619472, upload-time = "2026-02-20T20:21:07.966Z" }, @@ -2443,7 +2441,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, - { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" }, { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, @@ -2452,7 +2449,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, - { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, @@ -2461,7 +2457,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, - { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, @@ -2470,7 +2465,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, - { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" },