diff --git a/pyproject.toml b/pyproject.toml index 4b87c71..2cc091e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,8 @@ dependencies = [ "pyreadline3>=3.5; sys_platform == 'win32'", "rich>=13.0", "prompt_toolkit>=3.0.40", + "pyobjc-framework-Quartz>=12.2; sys_platform == 'darwin'", + "pynput>=1.8.0" ] [project.optional-dependencies] diff --git a/src/leapflow/cli/banner.py b/src/leapflow/cli/banner.py index 6f54400..1edd720 100644 --- a/src/leapflow/cli/banner.py +++ b/src/leapflow/cli/banner.py @@ -47,13 +47,20 @@ def _categorize_tools( tool_defs: Sequence[Dict[str, Any]], ) -> Dict[str, List[str]]: - """Group tool names by category for display.""" + """Group tool names by category for display. + + The static display map wins for known names; tools injected at runtime + (semantic desktop schemas) fall back to their declared x_leapflow + category instead of collapsing into "other". + """ groups: Dict[str, List[str]] = {} for td in tool_defs: name = td.get("function", {}).get("name", "") if not name: continue - cat = _TOOL_CATEGORIES.get(name, "other") + cat = _TOOL_CATEGORIES.get(name) + if not cat: + cat = str((td.get("x_leapflow") or {}).get("category") or "") or "other" groups.setdefault(cat, []).append(name) return dict(sorted(groups.items())) diff --git a/src/leapflow/cli/commands/host.py b/src/leapflow/cli/commands/host.py index 15fdcc1..6d3db6e 100644 --- a/src/leapflow/cli/commands/host.py +++ b/src/leapflow/cli/commands/host.py @@ -79,6 +79,8 @@ def _cua_driver_version() -> Optional[str]: [_CUA_DRIVER_CMD, "--version"], capture_output=True, text=True, + encoding="utf-8", + errors="replace", timeout=5.0, ) if result.returncode == 0 and result.stdout.strip(): @@ -377,11 +379,13 @@ async def _cmd_doctor() -> int: client.start() _ok("MCP session established") - # Step 3: Ping test (list_apps as health probe) + # Step 3: Ping test (get_screen_size as health probe — a real driver + # round-trip that responds instantly; list_apps enumerates the whole + # UI tree and can take 20s+ on Windows, making it a poor probe) print() print(f" {_BOLD}3. Ping test{_RESET}") - _info("Sending probe (list_apps)...") - result = client._session.call_tool_sync("list_apps", {}, timeout=5.0) + _info("Sending probe (get_screen_size)...") + result = client._session.call_tool_sync("get_screen_size", {}, timeout=10.0) if result.get("isError"): _warn("Probe returned error (non-fatal)") else: diff --git a/src/leapflow/cli/commands/slash_handlers.py b/src/leapflow/cli/commands/slash_handlers.py index 1e64144..08de0b4 100644 --- a/src/leapflow/cli/commands/slash_handlers.py +++ b/src/leapflow/cli/commands/slash_handlers.py @@ -23,15 +23,18 @@ def build_tool_payload(ctx: "Context") -> dict[str, Any]: """Build a serializable tool summary for local or daemon rendering.""" from leapflow.cli.banner import _categorize_tools - from leapflow.tools.registry_bootstrap import TOOL_DEFINITIONS + from leapflow.tools.registry_bootstrap import _capability_catalog - tool_groups = _categorize_tools(TOOL_DEFINITIONS) + # Live catalog: static registry plus semantic desktop tools while + # perception is online (falls back to the static list otherwise). + tool_groups = _categorize_tools(_capability_catalog()) groups = {category: sorted(names) for category, names in tool_groups.items()} mcp_count = 0 if hasattr(ctx.rpc, "connected") and ctx.rpc.connected: mcp_count = len(getattr(ctx, "platform_tools", [])) return { "ok": True, + "view": "tools", "groups": groups, "total": sum(len(names) for names in groups.values()), "mcp_count": mcp_count, @@ -1886,6 +1889,9 @@ def render_command_payload(console: "LeapConsole", payload: dict[str, Any]) -> N if view == "status": _render_status_view(console, payload) return + if view == "tools": + render_tool_payload(console, payload) + return if view == "model": render_model_payload(console, payload) return diff --git a/src/leapflow/cli/context.py b/src/leapflow/cli/context.py index 601ee29..972a5e6 100644 --- a/src/leapflow/cli/context.py +++ b/src/leapflow/cli/context.py @@ -5,7 +5,6 @@ import asyncio import logging import os -import re import sys import time from concurrent.futures import ThreadPoolExecutor @@ -263,13 +262,6 @@ def _promote(frag: MemoryFragment) -> None: return _promote -def sanitize_skill_name(title: str) -> str: - """Convert a skill title to a registry-safe name.""" - name = re.sub(r"[^\w\s-]", "", title.lower()) - name = re.sub(r"[\s]+", "-", name.strip()) - return name or "unnamed-skill" - - def _make_stored_skill_fn(stored: "StoredSkill", llm: Any): """Create an LLM-backed execution function from a StoredSkill.""" steps_text = "\n".join(f" {i+1}. {step}" for i, step in enumerate(stored.steps)) @@ -310,6 +302,7 @@ def _register_stored_skill_fallbacks( llm: Any, ) -> int: """Register StoredSkills that lack a parameterized or doc counterpart.""" + from leapflow.learning.document import title_to_kebab from leapflow.skills.registry import Skill, SkillMetadata registered_names = set(registry.names()) if hasattr(registry, 'names') else {s.name for s in registry.list_all()} @@ -317,7 +310,9 @@ def _register_stored_skill_fallbacks( count = 0 for s in stored: - name = sanitize_skill_name(s.title) + # Same naming function as the SKILL.md write paths, otherwise the + # dedup below misses doc-backed skills and registers a duplicate. + name = title_to_kebab(s.title) if name in registered_names: continue if not s.trigger_phrases: @@ -1512,6 +1507,12 @@ async def _summarize_via_llm(prompt: str) -> str: logger.debug("Shell approval gate: action orchestrator mode") except Exception: logger.debug("Shell approval gate setup skipped", exc_info=True) + try: + from leapflow.tools.registry_bootstrap import set_desktop_gate + set_desktop_gate(self._approval_orchestrator) + logger.debug("Desktop approval gate: action orchestrator mode") + except Exception: + logger.debug("Desktop approval gate setup skipped", exc_info=True) self._critical_tool_bridge = tool_bridge diff --git a/src/leapflow/config.py b/src/leapflow/config.py index f628427..e203312 100644 --- a/src/leapflow/config.py +++ b/src/leapflow/config.py @@ -484,6 +484,7 @@ class Settings: # ── Cua Driver ── use_cua_driver: bool = True cua_driver_cmd: str = "cua-driver" + desktop_tools_enabled: bool = True # ── Workflow Copilot ── copilot_enabled: bool = True @@ -989,6 +990,7 @@ def _tuple_env(key: str, default: tuple) -> tuple: # Cua Driver use_cua_driver = _bool("LEAPFLOW_USE_CUA_DRIVER", "true") cua_driver_cmd = os.getenv("LEAPFLOW_CUA_DRIVER_CMD", "cua-driver").strip() + desktop_tools_enabled = _bool("LEAPFLOW_DESKTOP_TOOLS_ENABLED", "true") # Workflow Copilot copilot_enabled = _bool("LEAPFLOW_COPILOT_ENABLED", "true") @@ -1312,6 +1314,7 @@ def _tuple_env(key: str, default: tuple) -> tuple: # Cua Driver use_cua_driver=use_cua_driver, cua_driver_cmd=cua_driver_cmd, + desktop_tools_enabled=desktop_tools_enabled, # Workflow Copilot copilot_enabled=copilot_enabled, copilot_min_idle_ms=copilot_min_idle_ms, diff --git a/src/leapflow/copilot/context.py b/src/leapflow/copilot/context.py index 087aafe..2e49c18 100644 --- a/src/leapflow/copilot/context.py +++ b/src/leapflow/copilot/context.py @@ -181,7 +181,7 @@ def _warmup_pipeline(self, event: "SystemEvent", state: "ContextState") -> None: loop = asyncio.get_running_loop() ctx_snapshot = self._encoder.snapshot() loop.create_task(on_observed( - action_id=f"{event.event_type}:{event.source}", + # action_id=f"{event.event_type}:{event.source}", context=ctx_snapshot, )) except RuntimeError: diff --git a/src/leapflow/daemon/approval_coordinator.py b/src/leapflow/daemon/approval_coordinator.py index f9eca89..c329b73 100644 --- a/src/leapflow/daemon/approval_coordinator.py +++ b/src/leapflow/daemon/approval_coordinator.py @@ -31,7 +31,11 @@ def install_gate(self, ctx: Any, service: Any) -> None: from leapflow.security.orchestrator import ApprovalOrchestrator from leapflow.tools.config_tools import set_config_approval_gate from leapflow.tools.gateway_tool import set_gateway_approval_gate - from leapflow.tools.registry_bootstrap import set_file_read_gate, set_file_write_gate + from leapflow.tools.registry_bootstrap import ( + set_desktop_gate, + set_file_read_gate, + set_file_write_gate, + ) from leapflow.tools.shell_tools import set_approval_gate from leapflow.tools.web_fetch import set_web_approval_gate @@ -51,6 +55,9 @@ def install_gate(self, ctx: Any, service: Any) -> None: set_config_approval_gate(orchestrator) # Same for outbound fetches that resolve to internal addresses. set_web_approval_gate(orchestrator) + # Mutating semantic desktop tools (click, type_text, ...) share the + # same approval path. + set_desktop_gate(orchestrator) class _FileReadGate: def __init__(self) -> None: diff --git a/src/leapflow/engine/engine.py b/src/leapflow/engine/engine.py index 1ecc9c3..939f212 100644 --- a/src/leapflow/engine/engine.py +++ b/src/leapflow/engine/engine.py @@ -1210,6 +1210,16 @@ def __init__( # truth — never derived from re-parsing text. self._last_turn_tool_categories: frozenset[str] = frozenset() self._manifests_by_name: Dict[str, Any] | None = None + # Semantic desktop schema cache: rebuilt when the bridge object changes + # (perception hot-swap via reconfigure_host_backend). + self._semantic_schema_bridge: Any = None + self._semantic_schemas: List[Dict[str, Any]] = [] + self._unified_catalog_key: Any = object() + self._unified_catalog: List[Dict[str, Any]] = [] + # Capability discovery resolves the live catalog through this engine, so + # runtime-injected categories (desktop) become expandable. + from leapflow.tools.registry_bootstrap import set_capability_catalog_provider + set_capability_catalog_provider(self._unified_tool_catalog) self._healer = MessageHealer() # B2: Prompt cache optimization (None = disabled) @@ -1972,10 +1982,8 @@ def _record_tool_call_categories(self, native_calls: list) -> None: the last round. Reset once per turn by the caller before the first round. """ if self._manifests_by_name is None: - from leapflow.tools.registry_bootstrap import TOOL_DEFINITIONS - self._manifests_by_name = { - m.name: m for m in build_capability_manifests(TOOL_DEFINITIONS) + m.name: m for m in build_capability_manifests(self._unified_tool_catalog()) } categories = set(self._last_turn_tool_categories) for call in native_calls: @@ -2495,10 +2503,11 @@ def _cost_ceiling_notice(self) -> str: ) def _full_tool_schema_tokens(self) -> int: - """Cached token estimate of the full tool catalog schema (static per process).""" + """Cached token estimate of the full tool catalog schema (per bridge).""" if self._full_tools_tokens is None: - from leapflow.tools.registry_bootstrap import TOOL_DEFINITIONS - self._full_tools_tokens = self._context_controller.estimator.estimate_tools(TOOL_DEFINITIONS) + self._full_tools_tokens = self._context_controller.estimator.estimate_tools( + self._unified_tool_catalog() + ) return self._full_tools_tokens def _evaluate_prefix_commitment(self, budget: IterationBudget) -> None: @@ -2947,19 +2956,18 @@ async def _run_agent_loop(self, frame: AgentLoopFrame) -> str: if injection: user_text = injection # Replace user_text with skill injection - from leapflow.tools.registry_bootstrap import TOOL_DEFINITIONS, TOOL_HANDLERS - # A restricted frame (e.g. a subagent) is offered only its permitted - # tools; the root frame (tool_filter=None) sees the full registry. - tool_defs = TOOL_DEFINITIONS - tool_handlers = TOOL_HANDLERS + # tools; the root frame (tool_filter=None) sees the full registry, + # including semantic desktop tools while perception is online. + tool_defs = self._unified_tool_catalog() + tool_handlers = self._unified_tool_handlers() if frame.tool_filter is not None: tool_defs = [ - td for td in TOOL_DEFINITIONS + td for td in tool_defs if td.get("function", {}).get("name", "") in frame.tool_filter ] tool_handlers = { - name: fn for name, fn in TOOL_HANDLERS.items() if name in frame.tool_filter + name: fn for name, fn in tool_handlers.items() if name in frame.tool_filter } trace = ExecutionTrace() @@ -3469,13 +3477,14 @@ async def _unified_tool_loop_stream( if injection: user_text = injection - from leapflow.tools.registry_bootstrap import TOOL_DEFINITIONS, TOOL_HANDLERS + tool_defs = self._unified_tool_catalog() + tool_handlers = self._unified_tool_handlers() budget = IterationBudget.for_react(self._budget_config) trace = ExecutionTrace() assembly = await self._assemble_unified_prompt( user_text, - tool_definitions=TOOL_DEFINITIONS, + tool_definitions=tool_defs, enable_thinking=enable_thinking, slash_command=user_text.startswith("/"), ) @@ -3674,7 +3683,7 @@ async def _unified_tool_loop_stream( ), ) results = await self._execute_tools_concurrent( - native_calls, TOOL_HANDLERS, trace=trace, messages=messages + native_calls, tool_handlers, trace=trace, messages=messages ) self._record_tool_call_categories(native_calls) tools_kwarg = self._merge_expanded_tool_schemas(tools_kwarg, results) @@ -3717,7 +3726,7 @@ async def _unified_tool_loop_stream( if retryable_unknown and not unknown_tool_retry_used: unknown_tool_retry_used = True - tools_kwarg = self._expand_tools_kwarg_full(tools_kwarg, TOOL_DEFINITIONS) + tools_kwarg = self._expand_tools_kwarg_full(tools_kwarg, tool_defs) use_native_tools = bool(tools_kwarg) messages.append(build_user_message_text(_unknown_tool_retry_prompt(retryable_unknown))) continue @@ -3956,7 +3965,7 @@ async def _unified_tool_loop_stream( ), ) result = await self._execute_tool_with_ledger( - normalized_tool_call, TOOL_HANDLERS, tool_call_id=f"text-{budget.used}", + normalized_tool_call, tool_handlers, tool_call_id=f"text-{budget.used}", ) _clear_indicator() self._emit_chat_event("tool_result", { @@ -4065,6 +4074,86 @@ async def _unified_tool_loop_stream( # ── Unified Loop Helpers ─────────────────────────────────────────────── + def _semantic_tool_schemas(self) -> List[Dict[str, Any]]: + """Callable schemas for the semantic desktop tools on the live bridge. + + Empty when the bridge is absent or carries no semantic tools + (perception offline) — the bridge itself is the dynamic on/off switch. + Cached by bridge object identity so a hot-swapped bridge rebuilds on + first access. ``desktop_tools_enabled`` is the process-level master + switch (off in the journey harness to keep replayed prompts stable). + """ + if not getattr(self._settings, "desktop_tools_enabled", True): + return [] + if self._tool_bridge is None: + return [] + if self._semantic_schema_bridge is not self._tool_bridge: + from leapflow.skills.semantic_schema import build_semantic_schemas + + self._semantic_schemas = build_semantic_schemas(self._tool_bridge) + self._semantic_schema_bridge = self._tool_bridge + return self._semantic_schemas + + def _unified_tool_catalog(self) -> List[Dict[str, Any]]: + """Per-turn tool catalog: static registry plus live semantic schemas. + + Cached on (bridge identity, static-registry size): the registry is + append-only (session_search, platform schemas land after engine + construction), so a length change invalidates exactly like a + bridge hot-swap does. + """ + from leapflow.tools.registry_bootstrap import TOOL_DEFINITIONS + + cache_key = (id(self._tool_bridge), len(TOOL_DEFINITIONS)) + if self._unified_catalog_key != cache_key: + self._unified_catalog = list(TOOL_DEFINITIONS) + self._semantic_tool_schemas() + self._unified_catalog_key = cache_key + # Downstream caches are keyed on the catalog contents. + self._manifests_by_name = None + self._full_tools_tokens = None + return self._unified_catalog + + def _unified_tool_handlers(self) -> Dict[str, Any]: + """Per-turn handler table: static handlers plus bridge semantic handlers.""" + from leapflow.tools.registry_bootstrap import TOOL_HANDLERS + from leapflow.skills.semantic_schema import build_semantic_handlers + + handlers: Dict[str, Any] = dict(TOOL_HANDLERS) + if getattr(self._settings, "desktop_tools_enabled", True): + handlers.update(build_semantic_handlers(self._tool_bridge)) + return handlers + + async def _approve_desktop_action(self, name: str, args: Any) -> tuple[bool, str]: + """Consult the desktop approval gate before a mutating semantic tool. + + Fail-closed: a missing gate or a failed evaluation blocks the action, + mirroring the dangerous-command gate in shell_tools. + """ + from leapflow.skills.semantic_schema import semantic_requires_approval + + if not semantic_requires_approval(name): + return True, "" + from leapflow.tools.registry_bootstrap import get_desktop_gate + + gate = get_desktop_gate() + if gate is None: + return False, f"Desktop action '{name}' blocked: no approval gate configured" + try: + from leapflow.security.actions import ActionDescriptor + + payload = args if isinstance(args, dict) else {} + result = await gate.evaluate(ActionDescriptor.platform_action("desktop", name, payload)) + if getattr(result, "approved", False): + return True, "" + message = str( + getattr(result, "denial_message", "") + or f"Desktop action '{name}' requires approval (denied)" + ) + return False, message + except Exception: + logger.debug("desktop approval check failed", exc_info=True) + return False, f"Desktop action '{name}' requires approval (denied)" + @staticmethod def _format_tool_catalog(tool_definitions: List[Dict[str, Any]]) -> str: """Format available tools for the unified system prompt. @@ -4464,6 +4553,9 @@ async def _execute_general_tool( """Execute a general-purpose tool via ToolBridge (preferred) or TOOL_HANDLERS fallback. Routing priority: + 0. Semantic desktop tools (bridge-registered, not in the static + registry) — admitted only when this turn's handler table carries + them, and gated by the desktop approval gate when mutating 1. ToolBridge dispatch (gp_-prefixed) — local Python GP tools, always available 2. ToolBridge dispatch (exact name) — may route to ExecutionPort or semantic tools 3. TOOL_HANDLERS dict (static fallback when no bridge) @@ -4473,26 +4565,39 @@ async def _execute_general_tool( """ from leapflow.skills.tool_executor import ToolCall as TC from leapflow.security.redact import redact_sensitive_text + from leapflow.skills.semantic_schema import SEMANTIC_TOOL_NAMES original_name = str(tool_call.get("original_tool_name") or tool_call.get("name", "")) proposed_name = str(tool_call.get("name", "")) args = tool_call.get("arguments", {}) - registry = _default_tool_registry() - resolution = registry.resolve(proposed_name, args) - if not resolution.auto_executable or resolution.normalized_name is None: - return registry.unknown_result( - ToolResolution( - original_name=original_name, - normalized_name=resolution.normalized_name, - status=resolution.status, - confidence=resolution.confidence, - reason=resolution.reason, - suggestions=resolution.suggestions, - auto_executable=False, - risk_level=resolution.risk_level, + + if proposed_name in SEMANTIC_TOOL_NAMES: + if proposed_name not in handlers: + return { + "ok": False, + "error": f"Desktop tool '{proposed_name}' is unavailable (perception offline)", + } + approved, denial = await self._approve_desktop_action(proposed_name, args) + if not approved: + return {"ok": False, "error": denial} + name = proposed_name + else: + registry = _default_tool_registry() + resolution = registry.resolve(proposed_name, args) + if not resolution.auto_executable or resolution.normalized_name is None: + return registry.unknown_result( + ToolResolution( + original_name=original_name, + normalized_name=resolution.normalized_name, + status=resolution.status, + confidence=resolution.confidence, + reason=resolution.reason, + suggestions=resolution.suggestions, + auto_executable=False, + risk_level=resolution.risk_level, + ) ) - ) - name = resolution.normalized_name + name = resolution.normalized_name result: Dict[str, Any] @@ -5352,10 +5457,9 @@ async def _bridge_fn() -> Any: return {"ok": True, "result": result} if a_type == "tool": - from leapflow.tools.registry_bootstrap import TOOL_HANDLERS tool_call_dict = {"name": name, "arguments": payload} result = await self._execute_tool_with_ledger( - tool_call_dict, TOOL_HANDLERS, tool_call_id=f"action-{name}", + tool_call_dict, self._unified_tool_handlers(), tool_call_id=f"action-{name}", ) logger.info("audit.tool name=%s ok=%s", name, result.get("ok")) return result diff --git a/src/leapflow/platform/adapters/darwin.py b/src/leapflow/platform/adapters/darwin.py index 2c8c5e8..175c9f3 100644 --- a/src/leapflow/platform/adapters/darwin.py +++ b/src/leapflow/platform/adapters/darwin.py @@ -141,6 +141,11 @@ async def run_intent(self, intent_name: str, params: Dict[str, Any]) -> Dict[str async def activate_app(self, app_id: str) -> Dict[str, Any]: return await self._rpc.call(Methods.APP_ACTIVATE, {"bundle_id": app_id}) + async def open_url(self, url: str) -> Dict[str, Any]: + """Open a URL in the default browser (local OS dispatch).""" + result = await self._rpc.call(Methods.OPEN_URL, {"url": url}) + return result if isinstance(result, dict) else {"ok": True, "result": result} + async def list_apps(self, filter: str = "", running_only: bool = False) -> Dict[str, Any]: """List available applications on the system.""" return await self._rpc.call( diff --git a/src/leapflow/platform/cua_client.py b/src/leapflow/platform/cua_client.py index 271e140..ac9d58d 100644 --- a/src/leapflow/platform/cua_client.py +++ b/src/leapflow/platform/cua_client.py @@ -43,6 +43,11 @@ _CALL_TIMEOUT_S = float(os.environ.get("LEAPFLOW_CUA_CALL_TIMEOUT", "30.0")) _KEEPALIVE_INTERVAL_S = float(os.environ.get("LEAPFLOW_CUA_KEEPALIVE_INTERVAL", "20.0")) _MANIFEST_TIMEOUT_S = float(os.environ.get("LEAPFLOW_CUA_MANIFEST_TIMEOUT", "6.0")) +# Cold app.list on Windows enumerates Start-Menu shortcuts + WinRT packages and +# can exceed a minute. Cutting it short does not free the serial MCP pipe — the +# driver keeps enumerating and every later call queues behind it — so the +# timeout must outlast the worst cold enumeration. +_APP_LIST_TIMEOUT_S = float(os.environ.get("LEAPFLOW_CUA_APP_LIST_TIMEOUT", "120.0")) # ── Telemetry policy ───────────────────────────────────────────────────────── @@ -80,6 +85,8 @@ def _resolve_mcp_invocation( [driver_cmd, "manifest"], capture_output=True, text=True, + encoding="utf-8", + errors="replace", timeout=timeout, stdin=subprocess.DEVNULL, ) @@ -161,7 +168,12 @@ def run(self, coro: Any, timeout: Optional[float] = _CALL_TIMEOUT_S) -> Any: coro.close() raise RuntimeError("cua-driver bridge not running") fut = asyncio.run_coroutine_threadsafe(coro, self._loop) - return fut.result(timeout=timeout) + try: + return fut.result(timeout=timeout) + except concurrent.futures.TimeoutError: + raise RuntimeError( + f"cua-driver call timed out after {timeout}s" + ) from None def stop(self) -> None: if self._loop and self._loop.is_running(): @@ -462,7 +474,10 @@ def _clipboard_get() -> str: else: cmd = ["xclip", "-selection", "clipboard", "-o"] try: - result = subprocess.run(cmd, capture_output=True, text=True, timeout=5.0) + result = subprocess.run( + cmd, capture_output=True, text=True, + errors="replace", timeout=5.0, + ) return result.stdout except Exception as e: raise RpcError("clipboard_error", f"Failed to read clipboard: {e}", {}) @@ -534,6 +549,20 @@ def _file_delete(params: Dict[str, Any]) -> Dict[str, str]: # ── Dispatch helpers ───────────────────────────────────────────────────────── +def _launch_app_key(app: str) -> str: + """Pick the launch_app schema field for an app identifier. + + cua-driver 0.17 distinguishes AUMIDs (``bundle_id``), executable paths + (``path``), and plain aliases (``name``); sending the wrong one makes + resolution fail. + """ + if "!" in app: + return "bundle_id" + if "/" in app or "\\" in app: + return "path" + return "name" + + def _resolve_ax_perform_tool(params: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]: """Map ax.perform params to the appropriate cua-driver tool + args. @@ -595,7 +624,7 @@ class CuaDriverClient(HostRpc): - Capability negotiation: tools/list discovery at startup - Verify-Then-Escalate: AX background → PX pixel → foreground - Element Token: opaque token tracking for staleness detection - - Heartbeat keepalive: periodic list_apps probe, auto-reconnect + - Heartbeat keepalive: periodic get_screen_size probe, auto-reconnect """ def __init__( @@ -613,11 +642,14 @@ def __init__( self._closed = False self._last_start_time: Optional[float] = None self._last_error = "" - # Per-method-prefix timeout overrides + # Per-method-prefix timeout overrides. Exact method names win over + # prefixes: app.list enumerates installed + running apps on Windows + # (can exceed a minute when cold), far beyond any fast-path budget. self._timeout_map: Dict[str, float] = { "ping": 3.0, "ax": 8.0, - "app": 5.0, + "app": 30.0, + "app.list": _APP_LIST_TIMEOUT_S, "input": 5.0, "screen": 10.0, "recording": 10.0, @@ -629,7 +661,10 @@ def __init__( self._timeout_map.update(timeout_overrides) def _resolve_timeout(self, method: str) -> float: - """Resolve timeout by method prefix.""" + """Resolve timeout by exact method name, then by method prefix.""" + exact = self._timeout_map.get(method) + if exact is not None: + return exact prefix = method.split(".", 1)[0] if method else "" return self._timeout_map.get(prefix, self._call_timeout) @@ -699,7 +734,10 @@ async def _heartbeat() -> None: if self._closed: break try: - await self._session.call_tool("list_apps", {}) + # get_screen_size is an instant liveness round-trip; + # list_apps enumerates the UI tree (~20s on Windows) + # and would saturate the serial MCP pipe. + await self._session.call_tool("get_screen_size", {}) except Exception as e: logger.debug("keepalive probe failed: %s", e) break @@ -806,11 +844,17 @@ def _map_to_cua_tool(self, method: str, params: Dict[str, Any]) -> Tuple[str, Di elif method == Methods.APP_LAUNCH: app = params.get("app_name") or params.get("name") or params.get("bundle_id", "") - return "launch_app", {"app_name": app} + args: Dict[str, Any] = {} + if app: + args[_launch_app_key(app)] = app + return "launch_app", args elif method == Methods.APP_ACTIVATE: app = params.get("app_name") or params.get("name") or params.get("bundle_id", "") - return "launch_app", {"app_name": app} + args = {} + if app: + args[_launch_app_key(app)] = app + return "launch_app", args elif method == Methods.APP_LIST: return "list_apps", {} @@ -850,7 +894,10 @@ def _map_to_cua_tool(self, method: str, params: Dict[str, Any]) -> Tuple[str, Di return "stop_recording", {} elif method == Methods.PING: - return "list_apps", {} + # Liveness probe only — callers never read the payload, and + # list_apps' UI enumeration (~20s on Windows) would exceed + # the 3s ping timeout. + return "get_screen_size", {} elif method == Methods.SYSTEM_INFO: return self._build_system_info(params) @@ -971,6 +1018,26 @@ def _local_screen_permission_status(params: Dict[str, Any]) -> Dict[str, Any]: return {"status": "unknown", "message": "Permission managed by OS (check System Settings)"} +def _local_open_url(params: Dict[str, Any]) -> Dict[str, Any]: + """Open a URL via the OS default browser. + + cua-driver's launch_app(urls=...) blocks until the browser window + settles (~80s, and effectively forever when the URL lands in an + already-running browser), so URL dispatch stays with the OS shell, + which hands off to the default handler and returns immediately. + """ + import webbrowser + + url = str(params.get("url", "")).strip() + if not url: + return {"ok": False, "error": "url required"} + try: + opened = webbrowser.open(url) + except Exception as exc: + return {"ok": False, "error": f"open_url failed: {exc}"} + return {"ok": bool(opened), "url": url} + + _LOCAL_DISPATCH: Dict[str, Callable[[Dict[str, Any]], Any]] = { Methods.CLIPBOARD_GET: _local_clipboard_get, Methods.CLIPBOARD_SET: _local_clipboard_set, @@ -980,5 +1047,6 @@ def _local_screen_permission_status(params: Dict[str, Any]) -> Dict[str, Any]: Methods.FILE_COPY: _file_copy, Methods.FILE_DELETE: _file_delete, Methods.FS_SUBSCRIBE: _local_fs_subscribe, + Methods.OPEN_URL: _local_open_url, Methods.SCREEN_PERMISSION_STATUS: _local_screen_permission_status, } diff --git a/src/leapflow/platform/facade.py b/src/leapflow/platform/facade.py index 6f05144..265a762 100644 --- a/src/leapflow/platform/facade.py +++ b/src/leapflow/platform/facade.py @@ -118,18 +118,23 @@ def _parse_manifest(raw: dict) -> PlatformManifest: # ── Capability mapping from cua-driver tools to PlatformManifest ───────────── -_CUA_TOOL_TO_CAPABILITIES: dict[str, list[str]] = { - "get_window_state": ["accessibility", "ax_tree"], - "click": ["accessibility", "ax_perform"], - "type_text": ["accessibility", "input"], - "set_value": ["accessibility", "ax_perform"], - "scroll": ["accessibility", "input"], - "hotkey": ["input"], - "screenshot": ["screen_capture"], - "launch_app": ["app_management"], - "list_apps": ["app_management"], - "start_recording": ["recording"], - "stop_recording": ["recording"], +# Map discovered cua-driver tools to host capabilities. Values are Capability +# members directly — string round-trips through capability_from_str previously +# matched nothing here and silently produced an empty capability set. +_CUA_TOOL_TO_CAPABILITIES: dict[str, list[Capability]] = { + "get_window_state": [Capability.AX_TREE_READ], + "click": [Capability.AX_PERFORM_ACTION], + "type_text": [Capability.AX_PERFORM_ACTION], + "set_value": [Capability.AX_PERFORM_ACTION], + "scroll": [Capability.AX_PERFORM_ACTION], + "hotkey": [Capability.AX_PERFORM_ACTION], + "screenshot": [Capability.SCREEN_CAPTURE], + "launch_app": [Capability.APP_LAUNCH], + "list_apps": [Capability.APP_ACTIVATE], + # No Capability member exists for recording yet; re-enable by adding e.g. + # SCREEN_RECORD to the enum and uncommenting: + # "start_recording": [Capability.SCREEN_RECORD], + # "stop_recording": [Capability.SCREEN_RECORD], } @@ -142,21 +147,17 @@ def _manifest_from_cua_tools(rpc: "CuaDriverClient") -> PlatformManifest: tools = session.available_tools # Derive capabilities from discovered tool names - caps_strs: set[str] = set() + caps: set[Capability] = set() for tool_name in tools: if tool_name in _CUA_TOOL_TO_CAPABILITIES: - caps_strs.update(_CUA_TOOL_TO_CAPABILITIES[tool_name]) - - caps = frozenset( - cap for s in caps_strs if (cap := capability_from_str(s)) is not None - ) + caps.update(_CUA_TOOL_TO_CAPABILITIES[tool_name]) pid = PlatformID.resolve() return PlatformManifest( platform_id=pid, os_version=_platform.version(), - capabilities=caps, + capabilities=frozenset(caps), metadata={ "driver": "cua-driver", "capability_version": session.capability_version, diff --git a/src/leapflow/platform/protocol.py b/src/leapflow/platform/protocol.py index 06be363..d3c4d59 100644 --- a/src/leapflow/platform/protocol.py +++ b/src/leapflow/platform/protocol.py @@ -66,6 +66,7 @@ class Methods: APP_LAUNCH = "app.launch" APP_ACTIVATE = "app.activate" APP_LIST = "app.list" + OPEN_URL = "url.open" CLIPBOARD_GET = "clipboard.get" CLIPBOARD_SET = "clipboard.set" diff --git a/src/leapflow/skills/discovery.py b/src/leapflow/skills/discovery.py index be5ce17..6380540 100644 --- a/src/leapflow/skills/discovery.py +++ b/src/leapflow/skills/discovery.py @@ -133,7 +133,7 @@ async def skill_view(params: Dict[str, Any]) -> Dict[str, Any]: if not skill_md.exists(): return {"ok": False, "error": f"SKILL.md not found in {skill_dir}"} - content = skill_md.read_text(errors="replace") + content = skill_md.read_text(encoding="utf-8", errors="replace") # Cap content to prevent oversized tool results (configurable) max_chars = _skill_view_max_chars truncated = len(content) > max_chars diff --git a/src/leapflow/skills/index.py b/src/leapflow/skills/index.py index 9b0f435..582c95e 100644 --- a/src/leapflow/skills/index.py +++ b/src/leapflow/skills/index.py @@ -143,7 +143,7 @@ def _scan_skills_dir(self) -> List[SkillEntry]: def _parse_skill_md(self, path: Path, skill_dir: Path) -> Optional[SkillEntry]: """Parse SKILL.md frontmatter YAML into SkillEntry.""" try: - content = path.read_text(errors="replace") + content = path.read_text(encoding="utf-8", errors="replace") # No frontmatter — use directory name and first heading if not content.startswith("---"): diff --git a/src/leapflow/skills/injector.py b/src/leapflow/skills/injector.py index 7440fce..cc098a4 100644 --- a/src/leapflow/skills/injector.py +++ b/src/leapflow/skills/injector.py @@ -52,7 +52,7 @@ def build_injection_message( if not skill_md.exists(): return None - content = skill_md.read_text(errors="replace") + content = skill_md.read_text(encoding="utf-8", errors="replace") # Build 5-phase injection parts: List[str] = [] diff --git a/src/leapflow/skills/semantic_schema.py b/src/leapflow/skills/semantic_schema.py new file mode 100644 index 0000000..7c00d3c --- /dev/null +++ b/src/leapflow/skills/semantic_schema.py @@ -0,0 +1,239 @@ +"""Semantic tool schema conversion — expose ToolBridge semantic tools to the LLM. + +The unified tool loop discloses tools from OpenAI function-calling schemas, +while semantic desktop tools (observe_ui, click, switch_app, ...) are only +registered on the ToolBridge as ``ToolDefinition`` objects with free-form +parameter strings. This module bridges the two representations: + +- ``SEMANTIC_TOOL_NAMES``: the fixed set of SemanticAdapter-backed tools that + may be disclosed (ToolBridge defaults such as file_list/shell/launch_app are + excluded — they overlap with the gp_* catalog). +- ``parse_param_spec``: parses bridge parameter strings, e.g. + ``"string (optional, default=30) — max seconds to wait"``. +- ``build_semantic_schemas``: converts whatever semantic tools are currently + registered on a bridge into OpenAI schemas carrying ``x_leapflow`` metadata. + A bridge without semantic tools (perception offline) yields an empty list, + which makes the bridge itself the dynamic on/off switch. +- ``build_semantic_handlers``: the dispatch-side counterpart — extracts the + bridge's own async handlers for the semantic tools so the unified tool loop + can merge them into its per-turn handler table. Schemas and handlers are + built from the same bridge snapshot, so disclosure and execution never + disagree about which desktop tools exist. + +Risk metadata is declared explicitly per tool: the disclosure planner treats +missing metadata as fail-closed for core admission, and ``desktop`` is not a +category its risk table recognizes, so every entry states category, risk, +schema cost, and approval requirement. All desktop tools are non-core +(schema_cost="high"); the model obtains them via ``capability_expand``. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any, Dict, FrozenSet, List, Optional, Protocol, runtime_checkable + +# Parameter spec head, e.g. "string" or "number (optional, default=30)". +_HEAD_RE = re.compile(r"^(?P[A-Za-z]+)\s*(?:\((?P[^)]*)\))?$") + +# JSON schema types the bridge registration format uses; anything else maps to string. +_TYPE_MAP = {"string": "string", "number": "number", "boolean": "boolean", "int": "integer"} + + +@dataclass(frozen=True) +class ParamSpec: + """Parsed bridge parameter description.""" + + type: str + required: bool + description: str + + +@runtime_checkable +class ToolDefinitionsSource(Protocol): + """Anything that can list ToolDefinition objects (e.g. ToolBridge).""" + + def tool_definitions(self) -> List[Any]: ... + + @property + def handlers(self) -> Dict[str, Any]: ... + + +SEMANTIC_TOOL_NAMES: FrozenSet[str] = frozenset({ + "observe_ui", + "click", + "type_text", + "shortcut", + "switch_app", + "list_apps", + "open_url", + "get_clipboard", + "set_clipboard", + "read_text", + "wait", + "wait_until", + "wait_until_stable", + "scroll", + "select_text", + "right_click", + "screenshot", +}) + +_OBSERVATION_TOOLS: FrozenSet[str] = frozenset({ + "observe_ui", "list_apps", "read_text", "get_clipboard", "screenshot", +}) +_WAIT_TOOLS: FrozenSet[str] = frozenset({ + "wait", "wait_until", "wait_until_stable", +}) +_MUTATING_TOOLS: FrozenSet[str] = frozenset({ + "click", "type_text", "shortcut", "switch_app", "open_url", + "set_clipboard", "scroll", "select_text", "right_click", +}) + +DESKTOP_CATEGORY = "desktop" + + +def _metadata_for(name: str) -> Dict[str, Any]: + """Build x_leapflow metadata for one semantic tool. + + Observation and wait tools are read-only and run without approval; + mutating tools are medium-risk and gated by the desktop approval gate. + schema_cost is high for every desktop tool so none qualifies as core + (core admission requires read_only risk AND non-high schema cost). + """ + if name in _MUTATING_TOOLS: + risk, approval = "medium", True + else: + risk, approval = "read_only", False + return { + "category": DESKTOP_CATEGORY, + "risk_level": risk, + "schema_cost": "high", + "requires_approval": approval, + } + + +def semantic_requires_approval(name: str) -> bool: + """Return whether executing this semantic tool requires desktop approval.""" + return name in _MUTATING_TOOLS + + +def parse_param_spec(spec: str) -> ParamSpec: + """Parse a bridge parameter string into a structured spec. + + Accepts the registration format used by bridge_factory, e.g. + ``"string (required) — text to type"`` or ``"number (optional, default=30) + — max seconds to wait"``. Tolerates a missing flags group, a missing + description, and empty input. + """ + text = (spec or "").strip() + if not text: + return ParamSpec(type="string", required=False, description="") + + head, sep, description = text.partition("\u2014") + head = head.strip() + description = description.strip() if sep else "" + + match = _HEAD_RE.match(head) + if match is None: + return ParamSpec(type="string", required=False, description=text) + + raw_type = match.group("type").lower() + flags = (match.group("flags") or "").lower() + required = "required" in flags and "optional" not in flags + return ParamSpec( + type=_TYPE_MAP.get(raw_type, "string"), + required=required, + description=description, + ) + + +def semantic_tool_to_openai(definition: Any) -> Optional[Dict[str, Any]]: + """Convert one ToolDefinition into an OpenAI function schema. + + Returns None for tools outside SEMANTIC_TOOL_NAMES so callers can pass + arbitrary bridge definitions without pre-filtering. + """ + name = str(getattr(definition, "name", "") or "") + if name not in SEMANTIC_TOOL_NAMES: + return None + + parameters = getattr(definition, "parameters", None) or {} + properties: Dict[str, Any] = {} + required_names: List[str] = [] + for param_name, param_spec in parameters.items(): + parsed = parse_param_spec(str(param_spec)) + properties[str(param_name)] = { + "type": parsed.type, + "description": parsed.description, + } + if parsed.required: + required_names.append(str(param_name)) + + schema: Dict[str, Any] = { + "type": "function", + "function": { + "name": name, + "description": str(getattr(definition, "description", "") or ""), + "parameters": { + "type": "object", + "properties": properties, + }, + }, + "x_leapflow": _metadata_for(name), + } + if required_names: + schema["function"]["parameters"]["required"] = required_names + return schema + + +def build_semantic_schemas(bridge: Optional[ToolDefinitionsSource]) -> List[Dict[str, Any]]: + """Collect OpenAI schemas for the semantic tools registered on a bridge. + + Returns an empty list when the bridge is None or carries no semantic + tools (perception offline), which is the signal for the unified tool + catalog to omit the desktop category entirely. Output order is sorted by + tool name for deterministic disclosure. + """ + if bridge is None: + return [] + + schemas: List[Dict[str, Any]] = [] + try: + definitions = bridge.tool_definitions() + except (AttributeError, TypeError, RuntimeError): + return [] + + for definition in definitions: + schema = semantic_tool_to_openai(definition) + if schema is not None: + schemas.append(schema) + schemas.sort(key=lambda item: item["function"]["name"]) + return schemas + + +def build_semantic_handlers(bridge: Optional[ToolDefinitionsSource]) -> Dict[str, Any]: + """Collect the bridge's own handlers for registered semantic tools. + + Dispatch-side counterpart of ``build_semantic_schemas``: returns an empty + dict when the bridge is None or carries no semantic tools (perception + offline). Handlers are the bridge's native callables (the same ones its + ``dispatch`` invokes), so merging them into the unified loop's handler + table executes desktop actions through the SemanticAdapter exactly as the + skill executor would. Only SEMANTIC_TOOL_NAMES are included — bridge + defaults (file_list, shell, launch_app, ...) stay out to avoid shadowing + the gp_* catalog. + """ + if bridge is None: + return {} + + try: + all_handlers = bridge.handlers + except AttributeError: + return {} + + return { + name: handler + for name, handler in all_handlers.items() + if name in SEMANTIC_TOOL_NAMES and handler is not None + } diff --git a/src/leapflow/skills/tool_executor.py b/src/leapflow/skills/tool_executor.py index b83715d..04bc8c7 100644 --- a/src/leapflow/skills/tool_executor.py +++ b/src/leapflow/skills/tool_executor.py @@ -247,6 +247,16 @@ def register( mutates_state=mutates_state, counts_as_progress=counts_as_progress, ) + @property + def handlers(self) -> Dict[str, Any]: + """Snapshot of name → async handler for every registered tool. + + Handlers follow the unified-loop convention ``await handler(args)`` + and are the same callables ``dispatch`` uses, so merging them into an + external handler table preserves bridge behavior exactly. + """ + return {name: entry.handler for name, entry in self._handlers.items()} + def is_mutating(self, name: str) -> bool: """Check if a tool is declared as state-mutating (clears dedup cache).""" entry = self._handlers.get(name) diff --git a/src/leapflow/tools/registry_bootstrap.py b/src/leapflow/tools/registry_bootstrap.py index 81fadac..888eca0 100644 --- a/src/leapflow/tools/registry_bootstrap.py +++ b/src/leapflow/tools/registry_bootstrap.py @@ -6,7 +6,7 @@ from __future__ import annotations -from typing import Any, Dict, List +from typing import Any, Callable, Dict, List, Optional from leapflow.tools.file_operations import ( code_search, @@ -1343,13 +1343,42 @@ async def _delegate_task_handler(params: Dict[str, Any]) -> Dict[str, Any]: # guess a tool name that was never disclosed. # ──────────────────────────────────────────────────────────────── +_capability_catalog_provider: Optional[Callable[[], List[Dict[str, Any]]]] = None + + +def set_capability_catalog_provider(provider: Optional[Callable[[], List[Dict[str, Any]]]]) -> None: + """Install a late-bound provider for the live tool catalog. + + The static TOOL_DEFINITIONS list cannot see tools injected at runtime + (semantic desktop schemas merged by the engine when perception is online), + so capability discovery resolves the catalog through this provider instead. + Falls back to TOOL_DEFINITIONS when no provider is installed or it fails. + """ + global _capability_catalog_provider + _capability_catalog_provider = provider + _patch_capability_expand_categories() + + +def _capability_catalog() -> List[Dict[str, Any]]: + """Resolve the live tool catalog for capability discovery.""" + if _capability_catalog_provider is not None: + try: + catalog = _capability_catalog_provider() + except Exception: + catalog = None + if catalog: + return list(catalog) + return TOOL_DEFINITIONS + + async def _capability_expand_handler(params: Dict[str, Any]) -> Dict[str, Any]: from leapflow.engine.context_disclosure import build_capability_manifests category = str(params.get("category") or "").strip().lower() if not category: return {"ok": False, "error": "category is required"} - manifests = build_capability_manifests(TOOL_DEFINITIONS) + catalog = _capability_catalog() + manifests = build_capability_manifests(catalog) matched_names = {m.name for m in manifests if m.category == category} if not matched_names: available = sorted({m.category for m in manifests if m.category}) @@ -1359,7 +1388,7 @@ async def _capability_expand_handler(params: Dict[str, Any]) -> Dict[str, Any]: "available_categories": available, } expanded_tools = [ - td for td in TOOL_DEFINITIONS + td for td in catalog if td.get("function", {}).get("name") in matched_names ] return {"ok": True, "category": category, "expanded_tools": expanded_tools} @@ -1372,7 +1401,7 @@ def _patch_capability_expand_categories() -> None: """ from leapflow.engine.context_disclosure import build_capability_manifests - manifests = build_capability_manifests(TOOL_DEFINITIONS) + manifests = build_capability_manifests(_capability_catalog()) non_core_categories = sorted({m.category for m in manifests if m.category and not m.is_core}) for td in TOOL_DEFINITIONS: func = td.get("function", {}) @@ -1435,6 +1464,19 @@ def get_file_write_gate() -> Any: return _file_write_gate +_desktop_gate: Any = None + + +def set_desktop_gate(gate: Any) -> None: + """Install an approval gate for mutating semantic desktop tools.""" + global _desktop_gate + _desktop_gate = gate + + +def get_desktop_gate() -> Any: + return _desktop_gate + + def bootstrap_tools(bridge: Any) -> int: """Register all general-purpose tools into a ToolBridge instance. diff --git a/tests/_harness/leapd.py b/tests/_harness/leapd.py index b9c1479..6ca9fd5 100644 --- a/tests/_harness/leapd.py +++ b/tests/_harness/leapd.py @@ -109,6 +109,9 @@ def hermetic_env( "LEAPFLOW_DATA_DIR": str(data_dir), "LEAPFLOW_PROFILE": profile, "LEAPFLOW_MOCK_HOST": "1", + # The mock host reports perception online; keep desktop tools out of + # the prompt so cassette fingerprints stay stable. + "LEAPFLOW_DESKTOP_TOOLS_ENABLED": "0", "LEAPFLOW_LLM_API_KEY": llm_api_key, "LEAPFLOW_LLM_BASE_URL": llm_base_url, "LEAPFLOW_LLM_MODEL": llm_model, diff --git a/tests/test_agent_execution.py b/tests/test_agent_execution.py index 3892790..20f48db 100644 --- a/tests/test_agent_execution.py +++ b/tests/test_agent_execution.py @@ -2128,19 +2128,280 @@ def test_record_tool_call_categories_caches_capability_manifests(monkeypatch) -> import leapflow.engine.engine as engine_module calls = 0 + real_build = engine_module.build_capability_manifests - def fake_build_capability_manifests(tool_definitions): + def counting_build(tool_definitions): nonlocal calls calls += 1 - return [SimpleNamespace(name="text_replace", category="write")] + return real_build(tool_definitions) - monkeypatch.setattr(engine_module, "build_capability_manifests", fake_build_capability_manifests) - engine = object.__new__(AgentEngine) - engine._last_turn_tool_categories = frozenset() - engine._manifests_by_name = None + monkeypatch.setattr(engine_module, "build_capability_manifests", counting_build) + + with tempfile.TemporaryDirectory() as td: + settings = make_settings(td) + from leapflow.platform.mock import MockBridge + + rpc = MockBridge() + llm = StubLLM(["ok"]) + wm = WorkingMemoryProvider(max_tokens=1024) + lt = SemanticMemoryProvider(source=settings.duckdb_path) + imm = EpisodicMemoryProvider() + try: + reg = build_default_registry(rpc, llm, wm, lt) + engine = AgentEngine( + settings, rpc, llm, wm, lt, imm, reg, _FixedClassifier("chat"), + ) + + engine._record_tool_call_categories([SimpleNamespace(name="shell_run")]) + engine._record_tool_call_categories([SimpleNamespace(name="shell_run")]) + + assert calls == 1 + assert engine._last_turn_tool_categories == frozenset({"shell"}) + finally: + lt.close() + + +# ═══════════════════════════════════════════════════════════════════ +# Semantic desktop tool injection (perception online) +# ═══════════════════════════════════════════════════════════════════ + + +def _desktop_bridge(): + """Real ToolBridge carrying semantic tools registered like bridge_factory does.""" + from leapflow.skills.tool_executor import ToolBridge + + bridge = ToolBridge(object()) + calls: list = [] + + async def _observe(params): + calls.append(("observe_ui", dict(params))) + return {"ok": True, "tree": "app:Browser"} + + async def _click(params): + calls.append(("click", dict(params))) + return {"ok": True, "clicked": params.get("selector")} + + bridge.register( + "observe_ui", "Observe the current UI state", + {"app": "string (optional) — application name"}, _observe, + ) + bridge.register( + "click", "Click a UI element", + {"selector": "string (required) — element selector"}, _click, + mutates_state=True, + ) + return bridge, calls + + +def _build_desktop_engine(td: str, bridge, llm=None, **settings_overrides): + from conftest import StubLLM + from leapflow.platform.mock import MockBridge + + settings = make_settings(td) + settings = settings.__class__( + **{**settings.__dict__, "native_tool_calling_enabled": True, **settings_overrides} + ) + rpc = MockBridge() + llm = llm or StubLLM(["ok"]) + wm = WorkingMemoryProvider(max_tokens=1024) + lt = SemanticMemoryProvider(source=settings.duckdb_path) + imm = EpisodicMemoryProvider() + reg = build_default_registry(rpc, llm, wm, lt) + engine = AgentEngine( + settings, rpc, llm, wm, lt, imm, reg, + _FixedClassifier("chat"), tool_bridge=bridge, + ) + return engine, lt + + +@pytest.mark.asyncio +async def test_unified_catalog_merges_semantic_tools_when_bridge_online() -> None: + """Catalog and handler table gain the bridge's semantic tools; static registry untouched.""" + from leapflow.tools.registry_bootstrap import TOOL_DEFINITIONS + + bridge, _ = _desktop_bridge() + with tempfile.TemporaryDirectory() as td: + engine, lt = _build_desktop_engine(td, bridge) + try: + catalog_names = { + item.get("function", {}).get("name") + for item in engine._unified_tool_catalog() + } + assert {"observe_ui", "click"} <= catalog_names + handlers = engine._unified_tool_handlers() + assert "observe_ui" in handlers and "click" in handlers + static_names = { + item.get("function", {}).get("name") for item in TOOL_DEFINITIONS + } + assert "click" not in static_names + finally: + lt.close() + + +@pytest.mark.asyncio +async def test_desktop_tools_flag_disables_semantic_disclosure() -> None: + """desktop_tools_enabled=False hides semantic tools even with an online bridge.""" + bridge, _ = _desktop_bridge() + with tempfile.TemporaryDirectory() as td: + engine, lt = _build_desktop_engine(td, bridge, desktop_tools_enabled=False) + try: + catalog_names = { + item.get("function", {}).get("name") + for item in engine._unified_tool_catalog() + } + assert "observe_ui" not in catalog_names + assert "click" not in catalog_names + handlers = engine._unified_tool_handlers() + assert "observe_ui" not in handlers and "click" not in handlers + finally: + lt.close() - engine._record_tool_call_categories([SimpleNamespace(name="text_replace")]) - engine._record_tool_call_categories([SimpleNamespace(name="text_replace")]) - assert calls == 1 - assert engine._last_turn_tool_categories == frozenset({"write"}) +@pytest.mark.asyncio +async def test_unified_catalog_rebuilds_when_static_registry_grows() -> None: + """Tools appended after engine construction (session_search pattern) are picked up.""" + from leapflow.tools.registry_bootstrap import TOOL_DEFINITIONS + + bridge, _ = _desktop_bridge() + with tempfile.TemporaryDirectory() as td: + engine, lt = _build_desktop_engine(td, bridge) + try: + assert engine._unified_tool_catalog() # prime the cache + TOOL_DEFINITIONS.append( + { + "type": "function", + "function": { + "name": "late_registered_probe", + "description": "probe", + "parameters": {"type": "object", "properties": {}}, + }, + } + ) + try: + names = { + item.get("function", {}).get("name") + for item in engine._unified_tool_catalog() + } + assert "late_registered_probe" in names + finally: + TOOL_DEFINITIONS.pop() + finally: + lt.close() + + +@pytest.mark.asyncio +async def test_core_turn_hides_desktop_schemas_but_lists_them_in_index() -> None: + """CORE keeps desktop out of the native tools kwarg while the index names them.""" + from leapflow.llm.base import LLMChatResponse, LLMProvider + + class CaptureLLM(LLMProvider): + def __init__(self) -> None: + self.messages: list[dict] = [] + self.kwargs: dict = {} + + async def achat(self, messages, *, stream=True, enable_thinking=False, on_chunk=None, **kwargs): + self.messages = list(messages) + self.kwargs = dict(kwargs) + return LLMChatResponse(content="hello") + + async def achat_stream(self, messages, *, enable_thinking=False, **kwargs): + if False: + yield "" + + bridge, _ = _desktop_bridge() + with tempfile.TemporaryDirectory() as td: + llm = CaptureLLM() + engine, lt = _build_desktop_engine(td, bridge, llm=llm) + try: + await engine.run("hello") + native_names = { + tool.get("function", {}).get("name", "") + for tool in llm.kwargs.get("tools", []) + } + assert "click" not in native_names + assert "observe_ui" not in native_names + system_prompt = str(llm.messages[0].get("content", "")) + assert "click" in system_prompt + assert "capability_expand category: desktop" in system_prompt + finally: + lt.close() + + +@pytest.mark.asyncio +async def test_semantic_execution_gate_and_perception_offline() -> None: + """Observation runs ungated; mutating tools fail closed without approval; + offline the tool is unavailable rather than unknown.""" + import types + + from leapflow.tools import registry_bootstrap as rb + + bridge, calls = _desktop_bridge() + with tempfile.TemporaryDirectory() as td: + engine, lt = _build_desktop_engine(td, bridge) + try: + handlers = engine._unified_tool_handlers() + + observed = await engine._execute_general_tool( + {"name": "observe_ui", "arguments": {"app": "Safari"}}, handlers + ) + assert observed.get("ok") is True + assert calls == [("observe_ui", {"app": "Safari"})] + + rb.set_desktop_gate(None) + denied = await engine._execute_general_tool( + {"name": "click", "arguments": {"selector": "#go"}}, handlers + ) + assert denied.get("ok") is False + assert "blocked" in denied["error"] or "approval" in denied["error"] + assert len(calls) == 1 # never executed + + class _Approve: + async def evaluate(self, action): + return types.SimpleNamespace(approved=True, denial_message="") + + rb.set_desktop_gate(_Approve()) + clicked = await engine._execute_general_tool( + {"name": "click", "arguments": {"selector": "#go"}}, handlers + ) + assert clicked.get("ok") is True + assert calls[-1] == ("click", {"selector": "#go"}) + finally: + rb.set_desktop_gate(None) + lt.close() + + # Perception offline: no bridge handlers -> explicit unavailability. + with tempfile.TemporaryDirectory() as td: + engine, lt = _build_desktop_engine(td, None) + try: + result = await engine._execute_general_tool( + {"name": "click", "arguments": {"selector": "#go"}}, + engine._unified_tool_handlers(), + ) + assert result.get("ok") is False + assert "unavailable" in result["error"] + finally: + lt.close() + + +@pytest.mark.asyncio +async def test_reconfigure_host_backend_drops_semantic_tools() -> None: + """Hot-swapping to a bridge without semantic tools removes desktop from the catalog.""" + bridge, _ = _desktop_bridge() + with tempfile.TemporaryDirectory() as td: + engine, lt = _build_desktop_engine(td, bridge) + try: + assert any( + item.get("function", {}).get("name") == "click" + for item in engine._unified_tool_catalog() + ) + engine.reconfigure_host_backend( + rpc=engine._rpc, perception=None, execution=None, tool_bridge=None, + ) + names = { + item.get("function", {}).get("name") + for item in engine._unified_tool_catalog() + } + assert "click" not in names + assert "observe_ui" not in engine._unified_tool_handlers() + finally: + lt.close() diff --git a/tests/test_cli_entrypoint.py b/tests/test_cli_entrypoint.py index 46098e5..8ae85fd 100644 --- a/tests/test_cli_entrypoint.py +++ b/tests/test_cli_entrypoint.py @@ -909,7 +909,7 @@ async def test_host_doctor_stops_client_when_probe_fails(monkeypatch) -> None: calls: list[str] = [] class FakeSession: - available_tools = {"list_apps": set()} + available_tools = {"get_screen_size": set()} capability_version = "test-cap" def call_tool_sync(self, name, args, timeout=5.0): @@ -934,7 +934,7 @@ def stop(self) -> None: result = await host_module._cmd_doctor() assert result == 1 - assert calls == ["start", "probe:list_apps", "stop"] + assert calls == ["start", "probe:get_screen_size", "stop"] @pytest.mark.asyncio diff --git a/tests/test_context_disclosure.py b/tests/test_context_disclosure.py index 2dfe711..9105844 100644 --- a/tests/test_context_disclosure.py +++ b/tests/test_context_disclosure.py @@ -198,3 +198,94 @@ def test_config_tools_are_core_and_writes_are_not() -> None: assert "config_get" in names assert "config_list" in names assert "config_set" not in names + + +# ── Desktop (semantic tool) disclosure ───────────────────────────────── + + +def _desktop_definitions() -> list[dict]: + from leapflow.skills.semantic_schema import semantic_tool_to_openai + from leapflow.skills.tool_executor import ToolDefinition + + defs = [] + for name in ("observe_ui", "click", "list_apps"): + schema = semantic_tool_to_openai( + ToolDefinition(name=name, description=f"test {name}", parameters={}) + ) + assert schema is not None + defs.append(schema) + return defs + + +def test_desktop_tools_are_non_core_and_expandable_by_continuity() -> None: + """Desktop schemas stay out of the CORE floor but reopen via Tier 1 continuity.""" + catalog = list(TOOL_DEFINITIONS) + _desktop_definitions() + planner = DisclosurePlanner() + + core_plan = planner.plan(catalog, DisclosureRuntimeState(native_tools_enabled=True)) + core_names = _tool_names(core_plan) + assert core_plan.level == DisclosureLevel.CORE + assert "click" not in core_names + # Observation tools are read-only but schema_cost=high keeps them non-core. + assert "observe_ui" not in core_names + catalog_names = { + td.get("function", {}).get("name") for td in core_plan.catalog_definitions + } + assert {"click", "observe_ui", "list_apps"} <= catalog_names + + expanded_plan = planner.plan( + catalog, + DisclosureRuntimeState( + native_tools_enabled=True, + last_turn_tool_categories=frozenset({"desktop"}), + ), + ) + assert expanded_plan.level == DisclosureLevel.EXPANDED + assert {"click", "observe_ui", "list_apps"} <= _tool_names(expanded_plan) + assert "desktop" in expanded_plan.expanded_categories + + +def test_desktop_tools_included_in_full_plan() -> None: + catalog = list(TOOL_DEFINITIONS) + _desktop_definitions() + plan = DisclosurePlanner().full_plan( + catalog, DisclosureRuntimeState(native_tools_enabled=True), "test" + ) + assert {"click", "observe_ui", "list_apps"} <= _tool_names(plan) + + +def test_capability_expand_provider_exposes_desktop_category() -> None: + import asyncio + + from leapflow.tools import registry_bootstrap as rb + + desktop_defs = _desktop_definitions() + rb.set_capability_catalog_provider(lambda: list(TOOL_DEFINITIONS) + desktop_defs) + try: + result = asyncio.run(rb._capability_expand_handler({"category": "desktop"})) + assert result["ok"] is True + expanded_names = {td["function"]["name"] for td in result["expanded_tools"]} + assert expanded_names == {"observe_ui", "click", "list_apps"} + + unknown = asyncio.run(rb._capability_expand_handler({"category": "nope"})) + assert unknown["ok"] is False + assert "desktop" in unknown["available_categories"] + + desc = next( + td["function"]["description"] + for td in TOOL_DEFINITIONS + if td["function"]["name"] == "capability_expand" + ) + assert "desktop" in desc + finally: + rb.set_capability_catalog_provider(None) + + +def test_capability_expand_falls_back_to_static_catalog_without_provider() -> None: + import asyncio + + from leapflow.tools import registry_bootstrap as rb + + rb.set_capability_catalog_provider(None) + result = asyncio.run(rb._capability_expand_handler({"category": "file"})) + assert result["ok"] is True + assert result["expanded_tools"] diff --git a/tests/test_cua_client_mapping.py b/tests/test_cua_client_mapping.py new file mode 100644 index 0000000..bba46a8 --- /dev/null +++ b/tests/test_cua_client_mapping.py @@ -0,0 +1,61 @@ +"""CuaDriverClient method→tool mapping and timeout resolution. + +Locks the cua-driver 0.17 wire contract: launch_app accepts name/bundle_id/ +urls (never app_name), and app.list gets the full call budget because +Windows app enumeration is slow. +""" + +from __future__ import annotations + +import pytest + +from leapflow.platform.cua_client import CuaDriverClient +from leapflow.platform.protocol import Methods + + +def _client() -> CuaDriverClient: + return CuaDriverClient() + + +def test_app_launch_maps_to_schema_fields() -> None: + client = _client() + + tool, args = client._map_to_cua_tool(Methods.APP_LAUNCH, {"app_name": "Notepad"}) + assert tool == "launch_app" + assert args == {"name": "Notepad"} + + aumid = "Microsoft.WindowsNotepad_8wekyb3d8bbwe!App" + tool, args = client._map_to_cua_tool(Methods.APP_LAUNCH, {"bundle_id": aumid}) + assert args == {"bundle_id": aumid} + + exe = r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" + tool, args = client._map_to_cua_tool(Methods.APP_LAUNCH, {"bundle_id": exe}) + assert args == {"path": exe} + assert "app_name" not in args + + +def test_app_activate_uses_name_field() -> None: + client = _client() + tool, args = client._map_to_cua_tool(Methods.APP_ACTIVATE, {"name": "Chrome"}) + assert tool == "launch_app" + assert args == {"name": "Chrome"} + + +@pytest.mark.asyncio +async def test_open_url_is_local_dispatch() -> None: + """open_url never round-trips to cua-driver; a missing url errors locally.""" + client = _client() + result = await client.call(Methods.OPEN_URL, {}) + assert result == {"ok": False, "error": "url required"} + + +def test_app_list_timeout_gets_dedicated_budget() -> None: + from leapflow.platform.cua_client import _APP_LIST_TIMEOUT_S + + client = _client() + assert client._resolve_timeout(Methods.APP_LIST) == _APP_LIST_TIMEOUT_S + assert _APP_LIST_TIMEOUT_S > client._call_timeout + # Other app.* methods keep the launch/activate budget. + assert client._resolve_timeout(Methods.APP_LAUNCH) == 30.0 + # Exact entries win over prefixes; unknown prefixes fall back to default. + assert client._resolve_timeout("custom.method") == client._call_timeout diff --git a/tests/test_facade_capability_mapping.py b/tests/test_facade_capability_mapping.py new file mode 100644 index 0000000..b5ee7ad --- /dev/null +++ b/tests/test_facade_capability_mapping.py @@ -0,0 +1,52 @@ +"""Regression tests for cua-driver tool → Capability mapping in the VSI facade. + +The mapping previously used informal strings ("ax_tree", "input", ...) that +matched no Capability enum value, so every derived manifest silently carried +an empty capability set. These tests pin the mapping to real enum members. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +from leapflow.domain.platform import Capability +from leapflow.platform.facade import ( + _CUA_TOOL_TO_CAPABILITIES, + _manifest_from_cua_tools, +) + + +def _fake_rpc(tool_names: list[str]) -> SimpleNamespace: + session = SimpleNamespace( + available_tools={name: set() for name in tool_names}, + capability_version="test", + ) + return SimpleNamespace(_session=session) + + +def test_mapping_values_are_capability_members() -> None: + """Every mapped value must be a real Capability member — a string here + would silently drop out and reintroduce the empty-manifest bug.""" + for tool, caps in _CUA_TOOL_TO_CAPABILITIES.items(): + assert caps, f"{tool} maps to an empty capability list" + for cap in caps: + assert isinstance(cap, Capability), f"{tool} maps to non-enum {cap!r}" + + +def test_manifest_derives_capabilities_from_tools() -> None: + manifest = _manifest_from_cua_tools(_fake_rpc([ + "get_window_state", "click", "screenshot", "launch_app", "list_apps", + ])) + assert manifest.supports(Capability.AX_TREE_READ) + assert manifest.supports(Capability.AX_PERFORM_ACTION) + assert manifest.supports(Capability.SCREEN_CAPTURE) + assert manifest.supports(Capability.APP_LAUNCH) + assert manifest.supports(Capability.APP_ACTIVATE) + assert len(manifest.capabilities) > 0 + + +def test_manifest_with_unknown_tools_only_is_empty() -> None: + manifest = _manifest_from_cua_tools(_fake_rpc(["some_future_tool"])) + assert len(manifest.capabilities) == 0 + # Tools still surface in metadata even without a capability mapping + assert manifest.metadata["tools"] == ["some_future_tool"] diff --git a/tests/test_semantic_schema.py b/tests/test_semantic_schema.py new file mode 100644 index 0000000..54a5b57 --- /dev/null +++ b/tests/test_semantic_schema.py @@ -0,0 +1,182 @@ +"""Tests for the semantic desktop tool schema conversion layer.""" + +from __future__ import annotations + +from leapflow.skills.semantic_schema import ( + DESKTOP_CATEGORY, + SEMANTIC_TOOL_NAMES, + build_semantic_handlers, + build_semantic_schemas, + parse_param_spec, + semantic_requires_approval, + semantic_tool_to_openai, +) +from leapflow.skills.tool_executor import ToolBridge, ToolDefinition + + +def _definition(name: str, parameters: dict[str, str] | None = None) -> ToolDefinition: + return ToolDefinition( + name=name, + description=f"test {name}", + parameters=parameters or {"target": "string (required) — element to act on"}, + ) + + +# ── Parameter spec parsing ───────────────────────────────────────────── + + +def test_parse_param_spec_required_with_description() -> None: + spec = parse_param_spec("string (required) — text to type") + assert spec.type == "string" + assert spec.required is True + assert spec.description == "text to type" + + +def test_parse_param_spec_optional_with_default() -> None: + spec = parse_param_spec("number (optional, default=30) — max seconds to wait") + assert spec.type == "number" + assert spec.required is False + assert spec.description == "max seconds to wait" + + +def test_parse_param_spec_no_flags() -> None: + spec = parse_param_spec("boolean — whether to wait") + assert spec.type == "boolean" + assert spec.required is False + assert spec.description == "whether to wait" + + +def test_parse_param_spec_no_description() -> None: + spec = parse_param_spec("int (required)") + assert spec.type == "integer" + assert spec.required is True + assert spec.description == "" + + +def test_parse_param_spec_empty_and_unknown() -> None: + assert parse_param_spec("").required is False + # Unrecognizable head falls back to string with the whole text as description. + spec = parse_param_spec("??? weird") + assert spec.type == "string" + assert spec.description == "??? weird" + + +# ── Schema conversion ─────────────────────────────────────────────────── + + +def test_semantic_tool_to_openai_mutating_metadata() -> None: + schema = semantic_tool_to_openai(_definition("click")) + assert schema is not None + func = schema["function"] + assert func["name"] == "click" + assert func["parameters"]["properties"]["target"]["type"] == "string" + assert func["parameters"]["required"] == ["target"] + meta = schema["x_leapflow"] + assert meta["category"] == DESKTOP_CATEGORY + assert meta["risk_level"] == "medium" + assert meta["schema_cost"] == "high" + assert meta["requires_approval"] is True + + +def test_semantic_tool_to_openai_observation_metadata() -> None: + for name in ("observe_ui", "list_apps", "screenshot"): + schema = semantic_tool_to_openai(_definition(name)) + assert schema is not None + meta = schema["x_leapflow"] + assert meta["risk_level"] == "read_only" + assert meta["requires_approval"] is False + assert meta["schema_cost"] == "high" + + +def test_semantic_tool_to_openai_rejects_non_semantic() -> None: + assert semantic_tool_to_openai(_definition("file_list")) is None + assert semantic_tool_to_openai(_definition("gp_shell_run")) is None + + +# ── Bridge-driven collection ──────────────────────────────────────────── + + +class _SemanticBridge: + """Minimal bridge double exposing tool_definitions() and handlers.""" + + def __init__(self, definitions: list[ToolDefinition], handler_names: list[str]) -> None: + self._definitions = definitions + self._handlers = {name: object() for name in handler_names} + + def tool_definitions(self) -> list[ToolDefinition]: + return list(self._definitions) + + @property + def handlers(self) -> dict[str, object]: + return dict(self._handlers) + + +def test_build_semantic_schemas_filters_and_sorts() -> None: + bridge = _SemanticBridge( + [ + _definition("click"), + _definition("file_list"), # bridge default — excluded + _definition("observe_ui"), + ], + ["click", "observe_ui"], + ) + schemas = build_semantic_schemas(bridge) + names = [item["function"]["name"] for item in schemas] + assert names == ["click", "observe_ui"] # sorted, non-semantic dropped + + +def test_build_semantic_schemas_empty_when_offline() -> None: + assert build_semantic_schemas(None) == [] + # Bridge without any semantic tool (perception offline / MockBridge). + bridge = _SemanticBridge([_definition("file_list")], ["file_list"]) + assert build_semantic_schemas(bridge) == [] + + +def test_build_semantic_handlers_match_schemas() -> None: + bridge = _SemanticBridge( + [_definition("click"), _definition("list_apps"), _definition("shell")], + ["click", "list_apps", "shell", "file_list"], + ) + handlers = build_semantic_handlers(bridge) + assert set(handlers) == {"click", "list_apps"} + + +def test_build_semantic_handlers_empty_when_offline() -> None: + assert build_semantic_handlers(None) == {} + + +def test_real_tool_bridge_handlers_are_exposed() -> None: + """Lock the ToolBridge.handlers contract the conversion layer relies on.""" + bridge = ToolBridge(object()) + + async def _click(params: dict) -> dict: + return {"ok": True, "clicked": params.get("selector")} + + bridge.register( + "click", "Click a UI element", + {"selector": "string (required) — target selector"}, + _click, mutates_state=True, + ) + assert "click" in bridge.handlers + assert bridge.handlers["click"] is _click + + schemas = build_semantic_schemas(bridge) + assert [item["function"]["name"] for item in schemas] == ["click"] + handlers = build_semantic_handlers(bridge) + assert set(handlers) == {"click"} + + +# ── Approval classification ───────────────────────────────────────────── + + +def test_semantic_requires_approval_split() -> None: + mutating = {"click", "type_text", "shortcut", "switch_app", "open_url", + "set_clipboard", "scroll", "select_text", "right_click"} + passive = SEMANTIC_TOOL_NAMES - mutating + assert all(semantic_requires_approval(name) for name in mutating) + assert all(not semantic_requires_approval(name) for name in passive) + assert not semantic_requires_approval("file_list") + + +def test_semantic_name_set_is_complete() -> None: + assert len(SEMANTIC_TOOL_NAMES) == 17 diff --git a/tests/test_skill_lifecycle.py b/tests/test_skill_lifecycle.py index bd7c570..4704a9d 100644 --- a/tests/test_skill_lifecycle.py +++ b/tests/test_skill_lifecycle.py @@ -286,3 +286,67 @@ async def test_undo_multiple(undo_adapter: DarwinExecutionAdapter) -> None: results = await undo_adapter.undo(2) assert len(results) == 2 assert undo_adapter.undo_depth == 1 + + +# ── Stored-skill fallback registration dedup ── + + +class _FakeSkillLibrary: + """Persistence-boundary fake: returns prebuilt StoredSkills.""" + + def __init__(self, skills: list) -> None: + self._skills = skills + + def load_all_active(self) -> list: + return list(self._skills) + + +def test_fallback_registration_dedups_doc_backed_skill() -> None: + """A DuckDB skill whose SKILL.md counterpart is already registered must + not be registered a second time under a differently derived name.""" + from leapflow.cli.context import _register_stored_skill_fallbacks + from leapflow.learning.document import title_to_kebab + from leapflow.skills.registry import Skill + from leapflow.storage.skill_library import StoredSkill + + title = "移除下载过的安装包" + kebab_name = title_to_kebab(title) + assert kebab_name == "skill-移除下载过的安装包" + + registry = SkillRegistry() + registry.register(Skill( + name=kebab_name, + description=title, + run=lambda **kwargs: "", + )) + + store = _FakeSkillLibrary([StoredSkill( + skill_id="s1", title=title, + trigger_phrases=[title], steps=["step"], + )]) + + registered = _register_stored_skill_fallbacks(store, registry, llm=None) + + assert registered == 0 + assert registry.names().count(kebab_name) == 1 + assert title not in registry.names() + + +def test_fallback_registration_adds_undocced_skill_under_kebab_name() -> None: + """A DuckDB skill with no doc counterpart is registered once, under the + shared kebab naming.""" + from leapflow.cli.context import _register_stored_skill_fallbacks + from leapflow.learning.document import title_to_kebab + from leapflow.storage.skill_library import StoredSkill + + title = "整理桌面文件" + registry = SkillRegistry() + store = _FakeSkillLibrary([StoredSkill( + skill_id="s2", title=title, + trigger_phrases=[title], steps=["step"], + )]) + + registered = _register_stored_skill_fallbacks(store, registry, llm=None) + + assert registered == 1 + assert registry.names() == [title_to_kebab(title)] diff --git a/tests/test_slash_command_router.py b/tests/test_slash_command_router.py index ff0c329..6ead3b5 100644 --- a/tests/test_slash_command_router.py +++ b/tests/test_slash_command_router.py @@ -138,3 +138,38 @@ def test_build_orient_payload_renders_layers_and_guards_missing_engine() -> None assert payload["ok"] is True assert "finding A" in payload["message"] assert payload["orientation"]["total"] == 2 + + +def test_tools_payload_groups_desktop_tools_when_perception_online() -> None: + """/tools must reflect the live catalog, not just the static registry.""" + from types import SimpleNamespace + + from leapflow.cli.commands.slash_handlers import build_tool_payload + from leapflow.skills.semantic_schema import semantic_tool_to_openai + from leapflow.skills.tool_executor import ToolDefinition + from leapflow.tools import registry_bootstrap as rb + + ctx = SimpleNamespace(rpc=SimpleNamespace(connected=False), platform_tools=[]) + + # An earlier test in this worker may have constructed an AgentEngine, which + # installs a catalog provider globally; the offline baseline needs a clean + # slate. + rb.set_capability_catalog_provider(None) + offline = build_tool_payload(ctx) + assert "desktop" not in offline["groups"] + offline_total = offline["total"] + + desktop_defs = [ + semantic_tool_to_openai(ToolDefinition(name=name, description="d", parameters={})) + for name in ("click", "observe_ui", "list_apps") + ] + rb.set_capability_catalog_provider(lambda: list(rb.TOOL_DEFINITIONS) + desktop_defs) + try: + online = build_tool_payload(ctx) + assert set(online["groups"]["desktop"]) == {"click", "list_apps", "observe_ui"} + assert online["total"] == offline_total + 3 + # Existing display categories stay untouched. + assert "shell_run" in online["groups"]["shell"] + assert "file_read" in online["groups"]["file"] + finally: + rb.set_capability_catalog_provider(None) diff --git a/uv.lock b/uv.lock index 1bfdd56..d63f1b9 100644 --- a/uv.lock +++ b/uv.lock @@ -380,84 +380,84 @@ wheels = [ name = "coverage" version = "7.15.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f4/45/78dbf9604ee5b3db24efbf26bed1cb58862fb40480cba821963c69348751/coverage-7.15.3.tar.gz", hash = "sha256:ae7ea5a4614acf399ef0483c4cb34f8f8f01df848d8fcbe7d3ce0865733f1c4d", size = 935592 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/9c/c8a3a923c24f631695cea2d5e2f02e776bc0af6e03800626e13a6c05a615/coverage-7.15.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5f3f854ab4599d98f7799ac9b91e34e8ec9ebc9a6372ee8c1f3413a68cc8b5e9", size = 222328 }, - { url = "https://files.pythonhosted.org/packages/92/51/dda77f34cbd2513d6ffb898c901d19e9ca55f48c0cbc4a1eb173a97d157a/coverage-7.15.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75268348fee1f199653b8a846262aec5581c6bb008c4f58824959fb708cc688f", size = 222832 }, - { url = "https://files.pythonhosted.org/packages/78/59/e0faafc4c6e23bd76c76148875ee9ec5781b8f1cd62cea2bc4ca0f0f0e5d/coverage-7.15.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:21081739f6264cc594cad2d42b62befbd17633824022866c68720eb0c4b8d6b4", size = 253250 }, - { url = "https://files.pythonhosted.org/packages/14/e2/4b1e0eeb727ffb471e411c1bd3402184b5dd54a77a762b0e55e87cdf9ae3/coverage-7.15.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:718d366251b060c10731c7dd359de6caea72250036eb94576aa56dacbf830a11", size = 255160 }, - { url = "https://files.pythonhosted.org/packages/e9/9e/a602d2d48f9db9f795e578a86aa914f7b20008e9330902defcfb73d17b3a/coverage-7.15.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fa1bbaa502a6e877f3ee67cbac3eba2bb637f623e454e6c37b81b38896dbd48f", size = 257269 }, - { url = "https://files.pythonhosted.org/packages/22/fa/bf6db13df2fcee00d2671849fe58c99232ee79a01fec7478c2bf7839b9e1/coverage-7.15.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:494880c9e60782610683f4eb9b65cce4f886673596b8f3cb2dfa079fc551c743", size = 259231 }, - { url = "https://files.pythonhosted.org/packages/89/37/8118f13b17fa7d9a3aa2c301d93f2d5ffeef70fa7e27e639a74bdacd3fea/coverage-7.15.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3db264ea689f9e8f9fa4fb9005fee4048c3bff4a547f4cfa27f5086cb0804ec0", size = 253357 }, - { url = "https://files.pythonhosted.org/packages/97/6d/c7b94fb03962f4d6f0fe13d01c4eb9c4c6e2e714a20d074516ec7582b110/coverage-7.15.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4e869d4799674d67778e76ddbe2e26cf1673369262e231a8ec259421b1015fea", size = 254961 }, - { url = "https://files.pythonhosted.org/packages/87/f9/fe0bd415fa56e36b62b649017c8fc98330858be4c7593789efb78cd24178/coverage-7.15.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:696fc7a28bbf717aba8d2c6963d26702945c7832cb313ba3b323aa5b1afb3156", size = 253024 }, - { url = "https://files.pythonhosted.org/packages/c1/7c/ffa53506d63ba8a77f5b9557dd6f5a5a5ad85adc680d7857410138f82bd9/coverage-7.15.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:3fe9be1c527497d047f770d88a0110189714c36383bb88384508f750c302bffa", size = 256792 }, - { url = "https://files.pythonhosted.org/packages/1f/c6/df42458e72c18a49fe87e40ccd3fb0314210915256cf4a5593e1b3250e04/coverage-7.15.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2400591f4b2e33746c70846388f8bb4c7e33b820e31cb8c6cb2f25305310438b", size = 252744 }, - { url = "https://files.pythonhosted.org/packages/f1/14/8bf18a4b10a44f8ba5f604b00e102f37daf49d581d66a37dc33fa267e1a6/coverage-7.15.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2e557178799282269412a672e5753f2179edfe1b3f0f19b0c98f8e72d482326a", size = 253652 }, - { url = "https://files.pythonhosted.org/packages/27/e6/e530c9bb94e4155817cbd149034105b062a6913bc356ae08f454d155de53/coverage-7.15.3-cp311-cp311-win32.whl", hash = "sha256:68ea6c947375982ae907e19e9d2ef156bd6e68e11f3566dd568d7f4ec974e715", size = 224428 }, - { url = "https://files.pythonhosted.org/packages/b4/98/0050c692d120988f1973a15196f52dee4ae221848b760281461a2005b613/coverage-7.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:28743dad31622e8c474b17446118037361f5b1f4f2ecdf72d4f6fde246d64446", size = 224906 }, - { url = "https://files.pythonhosted.org/packages/b0/ae/c0ef3e2ba3f35fc1c6985811a40edd9331e5b8978c9ecf84699de3edacbe/coverage-7.15.3-cp311-cp311-win_arm64.whl", hash = "sha256:c4398918c4fda32718191239e451fd86ac5ad1e8979b592f1921ee2d1f038965", size = 224448 }, - { url = "https://files.pythonhosted.org/packages/d1/6c/bac99d9d4c6abe856e93bf3f5212982ac0bfac126dd4a042753bd53bc5af/coverage-7.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:79a3e32e83227d83d9684459ed579769b56c369ac2d7313099b2d9e031d2e10f", size = 222499 }, - { url = "https://files.pythonhosted.org/packages/aa/bc/cb9a39b083bc1aa70586482dab25c9be20bab0ec6c155340e50d9066bb1e/coverage-7.15.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:767feb87c5886d781d0a69fafd450a20826ddab7b79bce1665deb64d21441b60", size = 222866 }, - { url = "https://files.pythonhosted.org/packages/58/fb/beaa453d62000a0a5b39838bee2a137afe609a50a71f55e83c73461e513b/coverage-7.15.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:50951e37033c40548d777b8a8454a2cd622dba1136780065678dccaec307c47f", size = 254367 }, - { url = "https://files.pythonhosted.org/packages/66/64/43e72500ed6815cef189f9193f29d7af4b078830337c95ea976cd0c0d427/coverage-7.15.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:63a4ff67364afb2cac826b8bbd78a5c50ce656a7b7137436b44d7b96a9271088", size = 257103 }, - { url = "https://files.pythonhosted.org/packages/66/3a/2893e2937adfe02f45fd38e4a8a0a0d8b7a02ff9e012ac3d009bee3c4f16/coverage-7.15.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e95e42856509675fe26560310313a6117640e96f9a1e19bb3d220116a27c94c", size = 258220 }, - { url = "https://files.pythonhosted.org/packages/30/b4/d5e6e2eb1a62961083734291304b1f85df72e2abe95c76eb88a7f472afd0/coverage-7.15.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:abad631cba27094b4631993f4c72e89ac0ca1b3a0236c7abaf8ca79aea619851", size = 260481 }, - { url = "https://files.pythonhosted.org/packages/dc/c9/9b72c5c6a9798a9a12cf65f66e077cc1fdd396e61915c862688f9afe1cae/coverage-7.15.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2b0807f1f051dd82a234ad6acdb6f1425baede60be1e84e862496c8cc9262ab9", size = 254749 }, - { url = "https://files.pythonhosted.org/packages/92/20/e1c2f759e2dbce559ba85c40c0e4acfecc6cff4b740c294c88e41ccc6111/coverage-7.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8d6df7aeb5bc464040bbc9ae173d875785d3677ebc4307817997d622d74225e", size = 256138 }, - { url = "https://files.pythonhosted.org/packages/a5/ab/48cc7e760f769e86ae290a125ea6e7209dfbdbbbb7ff4f5d9d1ee7a45d57/coverage-7.15.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:974471c506c9f5758808b47c1ebf7949ecd0848f5c1020e78675fefe5ff46866", size = 254283 }, - { url = "https://files.pythonhosted.org/packages/15/26/39529a68154f99b3a1829debd8b25eac384effeec890a293b5bbdcb49186/coverage-7.15.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5cba0c9c13e35c86df7998f1afaf6b1da224a3a39e4da59bdabf60c148046dcb", size = 258352 }, - { url = "https://files.pythonhosted.org/packages/91/2f/55b82aa3d8d7dd8023a56e7c5c2a70e39a3c44b3353c6cf3faec9ad51566/coverage-7.15.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4d608dc36a364dce33acbf4fc3a50f9d2054c945f233bb0a2cdb4b90bfa17646", size = 253852 }, - { url = "https://files.pythonhosted.org/packages/6a/6d/839f4045124cd3518ecf2c58967e58a911202834e7c5a03cfdf2ab0b29f6/coverage-7.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2395869280554a1941da904423c12660c39f721315e1c02d076a7fe0971382f0", size = 255725 }, - { url = "https://files.pythonhosted.org/packages/75/21/d25e3e2a9e327798078c877f469dfb6def860bf6e25036529046227d3e15/coverage-7.15.3-cp312-cp312-win32.whl", hash = "sha256:24f3b21840c3eb76cef3cc70b2bf6649010c64471a84a446538a39306e1ba04d", size = 224566 }, - { url = "https://files.pythonhosted.org/packages/b1/0f/df90cc1e8d095ce263968a93e04829821b2afb31ac2752c06a2e0a8e3c13/coverage-7.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:fa7b17902c3c1dd8a7adb52679b7f6340bba08443d710c8838e04db8cf62be2a", size = 225098 }, - { url = "https://files.pythonhosted.org/packages/65/c7/ec49e43c58967a07163e2d1c6bbd58112b825b2772ab66784afd6a5400ba/coverage-7.15.3-cp312-cp312-win_arm64.whl", hash = "sha256:fcbe83fb7258eacd293bf5322d88807acb35ed12a5cfa99dd8215c083e3b0235", size = 224485 }, - { url = "https://files.pythonhosted.org/packages/68/6e/62ae61e1fc434956bec38ed1d5b1c494f58cf579dbd998e77abffe7b3e6b/coverage-7.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1182eed05674c63d40951fae27c43e822749f04d25f75df64c2e4fa3168678de", size = 222522 }, - { url = "https://files.pythonhosted.org/packages/13/ff/c74c673d81e0e77b6608c3d21331e3db42e30daeb3c8a0a8860d4c9e2e14/coverage-7.15.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c0c4b0d7c4cd56e470d0c9d8441f42e8a96cdfd95050fec027f1d4dd9f11006c", size = 222894 }, - { url = "https://files.pythonhosted.org/packages/a1/91/ccb30f5ffafd7d69d0b18e5162f9b711a5654e807b7b0c13497f0826b33f/coverage-7.15.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5c9fce9f4998b0d50a753da765b9215a14decc7863822c89d72da7a89ca625b3", size = 253890 }, - { url = "https://files.pythonhosted.org/packages/29/c6/e92a66cda49a2751b09826d51258f199b92aa0cb005bc5f34e9729a52a9c/coverage-7.15.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7a47e2a0a0ace9241e70ee00e44520f88b843094603dd54303f1bafecd929c30", size = 256484 }, - { url = "https://files.pythonhosted.org/packages/96/7a/730929164b457cf25cf76c23898b90f9039a104a647890801b6586797b14/coverage-7.15.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:95bad94f83807ae60ed76f3ac012f69b2605ac9ea81bee959a5a483f7fa09c10", size = 257723 }, - { url = "https://files.pythonhosted.org/packages/9e/be/04cb5672cb19f5c389eda81ba22d89807699a949653d3625b0e0fda169da/coverage-7.15.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:228e172a76c428bb17d1ab78a2ff188990b0597e5dbd291f52a4edf7412de049", size = 259854 }, - { url = "https://files.pythonhosted.org/packages/96/25/5e7fd6af39f6507071455944b8906dd1fe5b7b6bffb6a163ceb20afa0d13/coverage-7.15.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cea9fb33887c99349996266f1fd60abe5af3577a90633392001d27ef46b4b66e", size = 254085 }, - { url = "https://files.pythonhosted.org/packages/23/c8/55e58a853f1e61163a6e755897bd14a059d78411e86560f39d9951c019b5/coverage-7.15.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:81760de3155d7f52c21860c4046628dc6bed182f72e3c028e2b4fd46f65aa040", size = 255850 }, - { url = "https://files.pythonhosted.org/packages/be/74/8bcec66dbcf3d22bea2a0b2b77ee2fa6f766a647d0023d4eabbc4f2b2756/coverage-7.15.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b47ea0a1d3a3d089826c6cbfad8429d7d8872e28e86baa95ddef330f6875da21", size = 253818 }, - { url = "https://files.pythonhosted.org/packages/ce/06/450b673fdfece0997b4e16a31d6bde6b18889c578f1013ddd34c962ac6f9/coverage-7.15.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5459ba486b2a5d58a6c05254779ecdf525e7f20174d0210ceda75ba40fdb8f2c", size = 257973 }, - { url = "https://files.pythonhosted.org/packages/56/fd/3ec7409aec0ddc943132452b65672f065f043b844f1830e1fe173c98b3ab/coverage-7.15.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c59209f80a08dbfcdd5109a80dc623cd3b9d22895c85757d34f57a6e6e95570f", size = 253638 }, - { url = "https://files.pythonhosted.org/packages/75/20/30a8dabb194123631c93f860fdd86401ad405d56cfb1841873afbfe4e92b/coverage-7.15.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f863856c1779d4a5bb6a94698a2f9073e09c6706501f76f3e7780e72df97d21c", size = 255407 }, - { url = "https://files.pythonhosted.org/packages/13/4d/e14365b1953b43653341412f9088b0d752614c626a73a705ff9af400f3a3/coverage-7.15.3-cp313-cp313-win32.whl", hash = "sha256:00cbdc5e322927dc30c5e42b863819b1bb867cc66f26ab5372c585850876ab93", size = 224575 }, - { url = "https://files.pythonhosted.org/packages/1c/64/88f762ea80de2070207246faef514513be874486b2773528f2cc2b4b515c/coverage-7.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:835528518a1d823cf336740324b2f335f7c01e609e74abcb5d5163b3e66661e3", size = 225116 }, - { url = "https://files.pythonhosted.org/packages/ab/66/03c34c53a319f522554cd29d4f2e16c5eab61aa4cdcf55753129fd7d926c/coverage-7.15.3-cp313-cp313-win_arm64.whl", hash = "sha256:0d2e1f2cbbf36b842f3e2aff8d118c60d677adb498bc6c7fa9c6838738f82767", size = 224509 }, - { url = "https://files.pythonhosted.org/packages/35/6f/8c2dc014357618b3226c90f731b8282766c3685786f422558991dc49fbf2/coverage-7.15.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1e3bb08ad574bd9fb6a991f645728f70d333c1c1958dd5fcde65e24cb862813d", size = 222571 }, - { url = "https://files.pythonhosted.org/packages/07/50/d867c7ceae9d56b7e74ee61ea834f1aa4f9a1e1c7f0ce39393ba573b1c12/coverage-7.15.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9e5860eaff02a0b7f1b73304bdf846596ee62ab3a78d25c68044ebf684cb1fef", size = 222902 }, - { url = "https://files.pythonhosted.org/packages/62/77/4f6dfc490c5f2bcacb2d296d9aa4d1e128c43b48e94ad313fec7f49f09ad/coverage-7.15.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:60874e5bd67f0b1bdbe42ab42c7bafa66a6fb8de88721af6df3f7a02713960cd", size = 253947 }, - { url = "https://files.pythonhosted.org/packages/16/8a/6777f192af264165103e2a3d3768dbadb9894a0a2359a16877141d9ae8f5/coverage-7.15.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9147be876e9d83765e0b82176674dc248a6b9283e25e01e7462611b97e9b731", size = 256452 }, - { url = "https://files.pythonhosted.org/packages/7d/7b/3d7ac46a0234bc684f41ee42be95e29b2b6525695adb04083609d5ac2149/coverage-7.15.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61a01f8c3804760fcc5a3d31c4f3cab792d660d44e17bf7adeaf0ea51e07821e", size = 257798 }, - { url = "https://files.pythonhosted.org/packages/ff/1e/c6ee59c29afcb5fdb35f936381340d1a06429a07c48f20e809646647acbe/coverage-7.15.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:95bf3e7f26f792e25eb185f85a5a659d48479265176dcfe22b6f334fd0081b5c", size = 260112 }, - { url = "https://files.pythonhosted.org/packages/c1/e1/e8ea39a46e89e3a143312ee5f80336e992e3ae8fe44bf9c76b83fefeed42/coverage-7.15.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:44c41eff9e413fed8740eca75d5438ebeb9d3e45e7cd37c67329213e7a72c764", size = 253944 }, - { url = "https://files.pythonhosted.org/packages/95/67/31ab5f6a37fd887d1386f81f0da9306851ad2264e9baaa9c7f606e0b3e17/coverage-7.15.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54146bafb61f3ba9895b43af0dd17eba01561d586d44ce84ea221b0cbbee5a9e", size = 255805 }, - { url = "https://files.pythonhosted.org/packages/fb/6a/ee505a80c8fd89620fb337c0596daecff87f33171fbb4ee3015fc3d7331f/coverage-7.15.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:af000dd1bb859ff8066fda4c79512ff938c798116540307226b373099c7b151f", size = 253769 }, - { url = "https://files.pythonhosted.org/packages/b0/41/6ab0f81c9e89660230d8f3f581d4732e5ddb75a885b0a5dfc73d315dc94f/coverage-7.15.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a1b82490577f3889950b5a04f18712aef0207243e0749d60fe28c3c73ebfd5fd", size = 258045 }, - { url = "https://files.pythonhosted.org/packages/bc/62/c995e91cae28cf31d6defab3bfb553dda5ac83ac7381b0f2b121264c307a/coverage-7.15.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:c4fc90a60154c3e4b8a2dc206d6dbe852f1c235c249e0dc0cef909d032c9591a", size = 253587 }, - { url = "https://files.pythonhosted.org/packages/84/df/f2049980f82d6890321f2065f9e66216eabbf4b2001815db958bc543f40a/coverage-7.15.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f25bb884814a892948b4c20394db3f2364dd452d9492736479e7a493e63b0eb6", size = 255243 }, - { url = "https://files.pythonhosted.org/packages/1d/82/2c841b67a978c0eb9c3707630b68f93f9e7585d78bb906bc8823ec6b07a5/coverage-7.15.3-cp314-cp314-win32.whl", hash = "sha256:722dbf8e7828fbcfe0dc8586167dc0a5ce85ad6ea171dbb21ed3f8d6581d3cb8", size = 224759 }, - { url = "https://files.pythonhosted.org/packages/b3/78/5c93ec43784fd3e404ca23cd0584ae24bc1732de4a3fc194b68c3be88db0/coverage-7.15.3-cp314-cp314-win_amd64.whl", hash = "sha256:64d0845f9c3ed47302bed265c15ab4dbb64aa4ec1490839b8e328f4e7fa914d2", size = 225246 }, - { url = "https://files.pythonhosted.org/packages/9d/77/813a054371f3b018cc63c6bdb46a3c35d5e95d4e3ed4f1449d4196106db5/coverage-7.15.3-cp314-cp314-win_arm64.whl", hash = "sha256:69bc14684f8fbbee9f9dbaa4fe79719b0da9725fc37956785c06ec365acf6926", size = 224673 }, - { url = "https://files.pythonhosted.org/packages/8f/63/8c9f36cc71178d26db930baa03a4494abcc516d8d41bf820d0d85ef1d80b/coverage-7.15.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f92df943c24b96cb215ca26b4f6a2283e63c5db80f1635aceea7fff11311917b", size = 223298 }, - { url = "https://files.pythonhosted.org/packages/54/66/211f24d058ce9f56ebf1420d55b7574fdae924f6da3836f83c8bd4793e38/coverage-7.15.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:66591c46bdd2971d3ae2bc503a5f0459c2edcaf6b7e045b292000cc95bc6cb95", size = 223568 }, - { url = "https://files.pythonhosted.org/packages/dd/bb/9c2ad5574a0d6420a96c6cade4f8a683931b9e79fe609f8924d7b6964616/coverage-7.15.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa64458b81b18bfc67cdf1f6dc02b23e3edc672f2f8e11771fad75865415a43", size = 264932 }, - { url = "https://files.pythonhosted.org/packages/ba/91/938c39e77bdd5a0a440412f975609ce3702dabbda6ac715719d93ca45a7b/coverage-7.15.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:447f5421ccf5475956cf516d4ca1d575f487947b6f4e11f9d80c6aefe24b3dc8", size = 267052 }, - { url = "https://files.pythonhosted.org/packages/b0/a3/7b431a98af35d9cc6394e54cde9435b33b8591672fbece6a4931267d7a8e/coverage-7.15.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a0c77ef8cd483a4987a5d12d1d9d5f7ee598dfdc6c0844417d847e5768dc779", size = 269473 }, - { url = "https://files.pythonhosted.org/packages/32/58/dbc9951dce46be47a732823a1c571f62bcabdd54a68d8c281489a1a55cfb/coverage-7.15.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0b273f4ff657446a06c2d85bf80e134fa869a92852ba5f87854a70e1fb44da77", size = 270591 }, - { url = "https://files.pythonhosted.org/packages/71/bd/1d610772c7c0889bfe477a59c46ee66ea53e271f3f06951e9d55b317f7c6/coverage-7.15.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daea8c4fafa22488600405be2c2be525a9406fba3fc0a83acc726db3e14e2005", size = 264007 }, - { url = "https://files.pythonhosted.org/packages/69/97/852eb3dcdba156b1a9078503f098499916bf889f964b61ad4a08223ac169/coverage-7.15.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:93ff57c530f3fa7aa69f92fb9b8892b8aa82712aa970842f4abf28657f42fb57", size = 266926 }, - { url = "https://files.pythonhosted.org/packages/52/f8/b72cd238757fba2b587fc7dee047efe6e10b0c18343509faaaf502dd4680/coverage-7.15.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4df21bef8b800eebda9018f53d49c9ace3aeb0090c850139b27923aafcb83e91", size = 264529 }, - { url = "https://files.pythonhosted.org/packages/b8/0a/6c52ec4b7fb007cb6433d1fcfda4080cb15d75ad37ef9c31025f3427293e/coverage-7.15.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:db567b02685f26034adcbd85055f80d12cdf02111b8ed00886093d98b2874ce2", size = 268263 }, - { url = "https://files.pythonhosted.org/packages/c0/4e/f1f9aa3efd109a04353563a43fb5155340c1fdcdeaa6296ebed3b6f510ea/coverage-7.15.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5318dd51b8600b947e058cf5a4fe54d183d9d13c49b97b64ca7be05a34df9bef", size = 263377 }, - { url = "https://files.pythonhosted.org/packages/dd/fb/6b268a0b2728ef1c379ad656b899274477a5f6bed1bf6765b4b387fb0601/coverage-7.15.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c995bfa383c54704839b6c4c2627a1c00895597ada0e5e8190c81d8bd620555c", size = 265688 }, - { url = "https://files.pythonhosted.org/packages/29/54/1a3ea96e5d5e7cd41dc432597bfc60692910e635d05e1cc25a8ccc243581/coverage-7.15.3-cp314-cp314t-win32.whl", hash = "sha256:6433fafb8da0e1d02eb53411e0ecdadb6b88f0224fdc23317e703c0e88937d42", size = 225066 }, - { url = "https://files.pythonhosted.org/packages/31/9d/a7b0d9afd18ed5274dd00651a78e7810a931c70d94b79996f150bec1a30f/coverage-7.15.3-cp314-cp314t-win_amd64.whl", hash = "sha256:fe578952b1b29fe8c777f43f241d49efac4b56724a3434f5d22ebe3c208df429", size = 225897 }, - { url = "https://files.pythonhosted.org/packages/ca/11/34c5ae40b945e69aa72b87dc268135b7049905f3824af573b7073acbb946/coverage-7.15.3-cp314-cp314t-win_arm64.whl", hash = "sha256:d2e1acb7aee29dfa8f3e48c23f36670898baca1209d9bdd3985a50c7f982165e", size = 225212 }, - { url = "https://files.pythonhosted.org/packages/37/e7/7069b3d6c018917f49ba2e1c5fb910e498c7fefa3a1b78cb1b79e61ff45d/coverage-7.15.3-py3-none-any.whl", hash = "sha256:da78fa6fc7dafe4212839173133ee85afcf42c5cd5f3e47fa7c1c210453b445e", size = 214297 }, +sdist = { url = "https://files.pythonhosted.org/packages/f4/45/78dbf9604ee5b3db24efbf26bed1cb58862fb40480cba821963c69348751/coverage-7.15.3.tar.gz", hash = "sha256:ae7ea5a4614acf399ef0483c4cb34f8f8f01df848d8fcbe7d3ce0865733f1c4d", size = 935592, upload-time = "2026-08-02T18:50:17.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/9c/c8a3a923c24f631695cea2d5e2f02e776bc0af6e03800626e13a6c05a615/coverage-7.15.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5f3f854ab4599d98f7799ac9b91e34e8ec9ebc9a6372ee8c1f3413a68cc8b5e9", size = 222328, upload-time = "2026-08-02T18:47:49.228Z" }, + { url = "https://files.pythonhosted.org/packages/92/51/dda77f34cbd2513d6ffb898c901d19e9ca55f48c0cbc4a1eb173a97d157a/coverage-7.15.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75268348fee1f199653b8a846262aec5581c6bb008c4f58824959fb708cc688f", size = 222832, upload-time = "2026-08-02T18:47:51.219Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/e0faafc4c6e23bd76c76148875ee9ec5781b8f1cd62cea2bc4ca0f0f0e5d/coverage-7.15.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:21081739f6264cc594cad2d42b62befbd17633824022866c68720eb0c4b8d6b4", size = 253250, upload-time = "2026-08-02T18:47:52.737Z" }, + { url = "https://files.pythonhosted.org/packages/14/e2/4b1e0eeb727ffb471e411c1bd3402184b5dd54a77a762b0e55e87cdf9ae3/coverage-7.15.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:718d366251b060c10731c7dd359de6caea72250036eb94576aa56dacbf830a11", size = 255160, upload-time = "2026-08-02T18:47:54.404Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/a602d2d48f9db9f795e578a86aa914f7b20008e9330902defcfb73d17b3a/coverage-7.15.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fa1bbaa502a6e877f3ee67cbac3eba2bb637f623e454e6c37b81b38896dbd48f", size = 257269, upload-time = "2026-08-02T18:47:56.157Z" }, + { url = "https://files.pythonhosted.org/packages/22/fa/bf6db13df2fcee00d2671849fe58c99232ee79a01fec7478c2bf7839b9e1/coverage-7.15.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:494880c9e60782610683f4eb9b65cce4f886673596b8f3cb2dfa079fc551c743", size = 259231, upload-time = "2026-08-02T18:47:57.76Z" }, + { url = "https://files.pythonhosted.org/packages/89/37/8118f13b17fa7d9a3aa2c301d93f2d5ffeef70fa7e27e639a74bdacd3fea/coverage-7.15.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3db264ea689f9e8f9fa4fb9005fee4048c3bff4a547f4cfa27f5086cb0804ec0", size = 253357, upload-time = "2026-08-02T18:47:59.261Z" }, + { url = "https://files.pythonhosted.org/packages/97/6d/c7b94fb03962f4d6f0fe13d01c4eb9c4c6e2e714a20d074516ec7582b110/coverage-7.15.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4e869d4799674d67778e76ddbe2e26cf1673369262e231a8ec259421b1015fea", size = 254961, upload-time = "2026-08-02T18:48:00.901Z" }, + { url = "https://files.pythonhosted.org/packages/87/f9/fe0bd415fa56e36b62b649017c8fc98330858be4c7593789efb78cd24178/coverage-7.15.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:696fc7a28bbf717aba8d2c6963d26702945c7832cb313ba3b323aa5b1afb3156", size = 253024, upload-time = "2026-08-02T18:48:02.745Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7c/ffa53506d63ba8a77f5b9557dd6f5a5a5ad85adc680d7857410138f82bd9/coverage-7.15.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:3fe9be1c527497d047f770d88a0110189714c36383bb88384508f750c302bffa", size = 256792, upload-time = "2026-08-02T18:48:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/1f/c6/df42458e72c18a49fe87e40ccd3fb0314210915256cf4a5593e1b3250e04/coverage-7.15.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2400591f4b2e33746c70846388f8bb4c7e33b820e31cb8c6cb2f25305310438b", size = 252744, upload-time = "2026-08-02T18:48:06.154Z" }, + { url = "https://files.pythonhosted.org/packages/f1/14/8bf18a4b10a44f8ba5f604b00e102f37daf49d581d66a37dc33fa267e1a6/coverage-7.15.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2e557178799282269412a672e5753f2179edfe1b3f0f19b0c98f8e72d482326a", size = 253652, upload-time = "2026-08-02T18:48:07.955Z" }, + { url = "https://files.pythonhosted.org/packages/27/e6/e530c9bb94e4155817cbd149034105b062a6913bc356ae08f454d155de53/coverage-7.15.3-cp311-cp311-win32.whl", hash = "sha256:68ea6c947375982ae907e19e9d2ef156bd6e68e11f3566dd568d7f4ec974e715", size = 224428, upload-time = "2026-08-02T18:48:09.845Z" }, + { url = "https://files.pythonhosted.org/packages/b4/98/0050c692d120988f1973a15196f52dee4ae221848b760281461a2005b613/coverage-7.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:28743dad31622e8c474b17446118037361f5b1f4f2ecdf72d4f6fde246d64446", size = 224906, upload-time = "2026-08-02T18:48:11.611Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ae/c0ef3e2ba3f35fc1c6985811a40edd9331e5b8978c9ecf84699de3edacbe/coverage-7.15.3-cp311-cp311-win_arm64.whl", hash = "sha256:c4398918c4fda32718191239e451fd86ac5ad1e8979b592f1921ee2d1f038965", size = 224448, upload-time = "2026-08-02T18:48:13.304Z" }, + { url = "https://files.pythonhosted.org/packages/d1/6c/bac99d9d4c6abe856e93bf3f5212982ac0bfac126dd4a042753bd53bc5af/coverage-7.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:79a3e32e83227d83d9684459ed579769b56c369ac2d7313099b2d9e031d2e10f", size = 222499, upload-time = "2026-08-02T18:48:15.018Z" }, + { url = "https://files.pythonhosted.org/packages/aa/bc/cb9a39b083bc1aa70586482dab25c9be20bab0ec6c155340e50d9066bb1e/coverage-7.15.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:767feb87c5886d781d0a69fafd450a20826ddab7b79bce1665deb64d21441b60", size = 222866, upload-time = "2026-08-02T18:48:16.884Z" }, + { url = "https://files.pythonhosted.org/packages/58/fb/beaa453d62000a0a5b39838bee2a137afe609a50a71f55e83c73461e513b/coverage-7.15.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:50951e37033c40548d777b8a8454a2cd622dba1136780065678dccaec307c47f", size = 254367, upload-time = "2026-08-02T18:48:18.507Z" }, + { url = "https://files.pythonhosted.org/packages/66/64/43e72500ed6815cef189f9193f29d7af4b078830337c95ea976cd0c0d427/coverage-7.15.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:63a4ff67364afb2cac826b8bbd78a5c50ce656a7b7137436b44d7b96a9271088", size = 257103, upload-time = "2026-08-02T18:48:20.172Z" }, + { url = "https://files.pythonhosted.org/packages/66/3a/2893e2937adfe02f45fd38e4a8a0a0d8b7a02ff9e012ac3d009bee3c4f16/coverage-7.15.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e95e42856509675fe26560310313a6117640e96f9a1e19bb3d220116a27c94c", size = 258220, upload-time = "2026-08-02T18:48:21.963Z" }, + { url = "https://files.pythonhosted.org/packages/30/b4/d5e6e2eb1a62961083734291304b1f85df72e2abe95c76eb88a7f472afd0/coverage-7.15.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:abad631cba27094b4631993f4c72e89ac0ca1b3a0236c7abaf8ca79aea619851", size = 260481, upload-time = "2026-08-02T18:48:23.682Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/9b72c5c6a9798a9a12cf65f66e077cc1fdd396e61915c862688f9afe1cae/coverage-7.15.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2b0807f1f051dd82a234ad6acdb6f1425baede60be1e84e862496c8cc9262ab9", size = 254749, upload-time = "2026-08-02T18:48:25.32Z" }, + { url = "https://files.pythonhosted.org/packages/92/20/e1c2f759e2dbce559ba85c40c0e4acfecc6cff4b740c294c88e41ccc6111/coverage-7.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8d6df7aeb5bc464040bbc9ae173d875785d3677ebc4307817997d622d74225e", size = 256138, upload-time = "2026-08-02T18:48:27.064Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ab/48cc7e760f769e86ae290a125ea6e7209dfbdbbbb7ff4f5d9d1ee7a45d57/coverage-7.15.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:974471c506c9f5758808b47c1ebf7949ecd0848f5c1020e78675fefe5ff46866", size = 254283, upload-time = "2026-08-02T18:48:29.082Z" }, + { url = "https://files.pythonhosted.org/packages/15/26/39529a68154f99b3a1829debd8b25eac384effeec890a293b5bbdcb49186/coverage-7.15.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5cba0c9c13e35c86df7998f1afaf6b1da224a3a39e4da59bdabf60c148046dcb", size = 258352, upload-time = "2026-08-02T18:48:30.892Z" }, + { url = "https://files.pythonhosted.org/packages/91/2f/55b82aa3d8d7dd8023a56e7c5c2a70e39a3c44b3353c6cf3faec9ad51566/coverage-7.15.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4d608dc36a364dce33acbf4fc3a50f9d2054c945f233bb0a2cdb4b90bfa17646", size = 253852, upload-time = "2026-08-02T18:48:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6d/839f4045124cd3518ecf2c58967e58a911202834e7c5a03cfdf2ab0b29f6/coverage-7.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2395869280554a1941da904423c12660c39f721315e1c02d076a7fe0971382f0", size = 255725, upload-time = "2026-08-02T18:48:34.848Z" }, + { url = "https://files.pythonhosted.org/packages/75/21/d25e3e2a9e327798078c877f469dfb6def860bf6e25036529046227d3e15/coverage-7.15.3-cp312-cp312-win32.whl", hash = "sha256:24f3b21840c3eb76cef3cc70b2bf6649010c64471a84a446538a39306e1ba04d", size = 224566, upload-time = "2026-08-02T18:48:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/b1/0f/df90cc1e8d095ce263968a93e04829821b2afb31ac2752c06a2e0a8e3c13/coverage-7.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:fa7b17902c3c1dd8a7adb52679b7f6340bba08443d710c8838e04db8cf62be2a", size = 225098, upload-time = "2026-08-02T18:48:38.941Z" }, + { url = "https://files.pythonhosted.org/packages/65/c7/ec49e43c58967a07163e2d1c6bbd58112b825b2772ab66784afd6a5400ba/coverage-7.15.3-cp312-cp312-win_arm64.whl", hash = "sha256:fcbe83fb7258eacd293bf5322d88807acb35ed12a5cfa99dd8215c083e3b0235", size = 224485, upload-time = "2026-08-02T18:48:40.682Z" }, + { url = "https://files.pythonhosted.org/packages/68/6e/62ae61e1fc434956bec38ed1d5b1c494f58cf579dbd998e77abffe7b3e6b/coverage-7.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1182eed05674c63d40951fae27c43e822749f04d25f75df64c2e4fa3168678de", size = 222522, upload-time = "2026-08-02T18:48:42.476Z" }, + { url = "https://files.pythonhosted.org/packages/13/ff/c74c673d81e0e77b6608c3d21331e3db42e30daeb3c8a0a8860d4c9e2e14/coverage-7.15.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c0c4b0d7c4cd56e470d0c9d8441f42e8a96cdfd95050fec027f1d4dd9f11006c", size = 222894, upload-time = "2026-08-02T18:48:44.274Z" }, + { url = "https://files.pythonhosted.org/packages/a1/91/ccb30f5ffafd7d69d0b18e5162f9b711a5654e807b7b0c13497f0826b33f/coverage-7.15.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5c9fce9f4998b0d50a753da765b9215a14decc7863822c89d72da7a89ca625b3", size = 253890, upload-time = "2026-08-02T18:48:46.097Z" }, + { url = "https://files.pythonhosted.org/packages/29/c6/e92a66cda49a2751b09826d51258f199b92aa0cb005bc5f34e9729a52a9c/coverage-7.15.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7a47e2a0a0ace9241e70ee00e44520f88b843094603dd54303f1bafecd929c30", size = 256484, upload-time = "2026-08-02T18:48:47.846Z" }, + { url = "https://files.pythonhosted.org/packages/96/7a/730929164b457cf25cf76c23898b90f9039a104a647890801b6586797b14/coverage-7.15.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:95bad94f83807ae60ed76f3ac012f69b2605ac9ea81bee959a5a483f7fa09c10", size = 257723, upload-time = "2026-08-02T18:48:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/9e/be/04cb5672cb19f5c389eda81ba22d89807699a949653d3625b0e0fda169da/coverage-7.15.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:228e172a76c428bb17d1ab78a2ff188990b0597e5dbd291f52a4edf7412de049", size = 259854, upload-time = "2026-08-02T18:48:51.413Z" }, + { url = "https://files.pythonhosted.org/packages/96/25/5e7fd6af39f6507071455944b8906dd1fe5b7b6bffb6a163ceb20afa0d13/coverage-7.15.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cea9fb33887c99349996266f1fd60abe5af3577a90633392001d27ef46b4b66e", size = 254085, upload-time = "2026-08-02T18:48:53.158Z" }, + { url = "https://files.pythonhosted.org/packages/23/c8/55e58a853f1e61163a6e755897bd14a059d78411e86560f39d9951c019b5/coverage-7.15.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:81760de3155d7f52c21860c4046628dc6bed182f72e3c028e2b4fd46f65aa040", size = 255850, upload-time = "2026-08-02T18:48:55.031Z" }, + { url = "https://files.pythonhosted.org/packages/be/74/8bcec66dbcf3d22bea2a0b2b77ee2fa6f766a647d0023d4eabbc4f2b2756/coverage-7.15.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b47ea0a1d3a3d089826c6cbfad8429d7d8872e28e86baa95ddef330f6875da21", size = 253818, upload-time = "2026-08-02T18:48:57.163Z" }, + { url = "https://files.pythonhosted.org/packages/ce/06/450b673fdfece0997b4e16a31d6bde6b18889c578f1013ddd34c962ac6f9/coverage-7.15.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5459ba486b2a5d58a6c05254779ecdf525e7f20174d0210ceda75ba40fdb8f2c", size = 257973, upload-time = "2026-08-02T18:48:59.098Z" }, + { url = "https://files.pythonhosted.org/packages/56/fd/3ec7409aec0ddc943132452b65672f065f043b844f1830e1fe173c98b3ab/coverage-7.15.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c59209f80a08dbfcdd5109a80dc623cd3b9d22895c85757d34f57a6e6e95570f", size = 253638, upload-time = "2026-08-02T18:49:01.199Z" }, + { url = "https://files.pythonhosted.org/packages/75/20/30a8dabb194123631c93f860fdd86401ad405d56cfb1841873afbfe4e92b/coverage-7.15.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f863856c1779d4a5bb6a94698a2f9073e09c6706501f76f3e7780e72df97d21c", size = 255407, upload-time = "2026-08-02T18:49:03.143Z" }, + { url = "https://files.pythonhosted.org/packages/13/4d/e14365b1953b43653341412f9088b0d752614c626a73a705ff9af400f3a3/coverage-7.15.3-cp313-cp313-win32.whl", hash = "sha256:00cbdc5e322927dc30c5e42b863819b1bb867cc66f26ab5372c585850876ab93", size = 224575, upload-time = "2026-08-02T18:49:05.011Z" }, + { url = "https://files.pythonhosted.org/packages/1c/64/88f762ea80de2070207246faef514513be874486b2773528f2cc2b4b515c/coverage-7.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:835528518a1d823cf336740324b2f335f7c01e609e74abcb5d5163b3e66661e3", size = 225116, upload-time = "2026-08-02T18:49:06.894Z" }, + { url = "https://files.pythonhosted.org/packages/ab/66/03c34c53a319f522554cd29d4f2e16c5eab61aa4cdcf55753129fd7d926c/coverage-7.15.3-cp313-cp313-win_arm64.whl", hash = "sha256:0d2e1f2cbbf36b842f3e2aff8d118c60d677adb498bc6c7fa9c6838738f82767", size = 224509, upload-time = "2026-08-02T18:49:09.129Z" }, + { url = "https://files.pythonhosted.org/packages/35/6f/8c2dc014357618b3226c90f731b8282766c3685786f422558991dc49fbf2/coverage-7.15.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1e3bb08ad574bd9fb6a991f645728f70d333c1c1958dd5fcde65e24cb862813d", size = 222571, upload-time = "2026-08-02T18:49:11.242Z" }, + { url = "https://files.pythonhosted.org/packages/07/50/d867c7ceae9d56b7e74ee61ea834f1aa4f9a1e1c7f0ce39393ba573b1c12/coverage-7.15.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9e5860eaff02a0b7f1b73304bdf846596ee62ab3a78d25c68044ebf684cb1fef", size = 222902, upload-time = "2026-08-02T18:49:13.448Z" }, + { url = "https://files.pythonhosted.org/packages/62/77/4f6dfc490c5f2bcacb2d296d9aa4d1e128c43b48e94ad313fec7f49f09ad/coverage-7.15.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:60874e5bd67f0b1bdbe42ab42c7bafa66a6fb8de88721af6df3f7a02713960cd", size = 253947, upload-time = "2026-08-02T18:49:15.304Z" }, + { url = "https://files.pythonhosted.org/packages/16/8a/6777f192af264165103e2a3d3768dbadb9894a0a2359a16877141d9ae8f5/coverage-7.15.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9147be876e9d83765e0b82176674dc248a6b9283e25e01e7462611b97e9b731", size = 256452, upload-time = "2026-08-02T18:49:17.801Z" }, + { url = "https://files.pythonhosted.org/packages/7d/7b/3d7ac46a0234bc684f41ee42be95e29b2b6525695adb04083609d5ac2149/coverage-7.15.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61a01f8c3804760fcc5a3d31c4f3cab792d660d44e17bf7adeaf0ea51e07821e", size = 257798, upload-time = "2026-08-02T18:49:19.878Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1e/c6ee59c29afcb5fdb35f936381340d1a06429a07c48f20e809646647acbe/coverage-7.15.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:95bf3e7f26f792e25eb185f85a5a659d48479265176dcfe22b6f334fd0081b5c", size = 260112, upload-time = "2026-08-02T18:49:21.858Z" }, + { url = "https://files.pythonhosted.org/packages/c1/e1/e8ea39a46e89e3a143312ee5f80336e992e3ae8fe44bf9c76b83fefeed42/coverage-7.15.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:44c41eff9e413fed8740eca75d5438ebeb9d3e45e7cd37c67329213e7a72c764", size = 253944, upload-time = "2026-08-02T18:49:23.926Z" }, + { url = "https://files.pythonhosted.org/packages/95/67/31ab5f6a37fd887d1386f81f0da9306851ad2264e9baaa9c7f606e0b3e17/coverage-7.15.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54146bafb61f3ba9895b43af0dd17eba01561d586d44ce84ea221b0cbbee5a9e", size = 255805, upload-time = "2026-08-02T18:49:25.973Z" }, + { url = "https://files.pythonhosted.org/packages/fb/6a/ee505a80c8fd89620fb337c0596daecff87f33171fbb4ee3015fc3d7331f/coverage-7.15.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:af000dd1bb859ff8066fda4c79512ff938c798116540307226b373099c7b151f", size = 253769, upload-time = "2026-08-02T18:49:27.883Z" }, + { url = "https://files.pythonhosted.org/packages/b0/41/6ab0f81c9e89660230d8f3f581d4732e5ddb75a885b0a5dfc73d315dc94f/coverage-7.15.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a1b82490577f3889950b5a04f18712aef0207243e0749d60fe28c3c73ebfd5fd", size = 258045, upload-time = "2026-08-02T18:49:30.201Z" }, + { url = "https://files.pythonhosted.org/packages/bc/62/c995e91cae28cf31d6defab3bfb553dda5ac83ac7381b0f2b121264c307a/coverage-7.15.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:c4fc90a60154c3e4b8a2dc206d6dbe852f1c235c249e0dc0cef909d032c9591a", size = 253587, upload-time = "2026-08-02T18:49:32.349Z" }, + { url = "https://files.pythonhosted.org/packages/84/df/f2049980f82d6890321f2065f9e66216eabbf4b2001815db958bc543f40a/coverage-7.15.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f25bb884814a892948b4c20394db3f2364dd452d9492736479e7a493e63b0eb6", size = 255243, upload-time = "2026-08-02T18:49:34.324Z" }, + { url = "https://files.pythonhosted.org/packages/1d/82/2c841b67a978c0eb9c3707630b68f93f9e7585d78bb906bc8823ec6b07a5/coverage-7.15.3-cp314-cp314-win32.whl", hash = "sha256:722dbf8e7828fbcfe0dc8586167dc0a5ce85ad6ea171dbb21ed3f8d6581d3cb8", size = 224759, upload-time = "2026-08-02T18:49:36.326Z" }, + { url = "https://files.pythonhosted.org/packages/b3/78/5c93ec43784fd3e404ca23cd0584ae24bc1732de4a3fc194b68c3be88db0/coverage-7.15.3-cp314-cp314-win_amd64.whl", hash = "sha256:64d0845f9c3ed47302bed265c15ab4dbb64aa4ec1490839b8e328f4e7fa914d2", size = 225246, upload-time = "2026-08-02T18:49:38.366Z" }, + { url = "https://files.pythonhosted.org/packages/9d/77/813a054371f3b018cc63c6bdb46a3c35d5e95d4e3ed4f1449d4196106db5/coverage-7.15.3-cp314-cp314-win_arm64.whl", hash = "sha256:69bc14684f8fbbee9f9dbaa4fe79719b0da9725fc37956785c06ec365acf6926", size = 224673, upload-time = "2026-08-02T18:49:40.552Z" }, + { url = "https://files.pythonhosted.org/packages/8f/63/8c9f36cc71178d26db930baa03a4494abcc516d8d41bf820d0d85ef1d80b/coverage-7.15.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f92df943c24b96cb215ca26b4f6a2283e63c5db80f1635aceea7fff11311917b", size = 223298, upload-time = "2026-08-02T18:49:42.634Z" }, + { url = "https://files.pythonhosted.org/packages/54/66/211f24d058ce9f56ebf1420d55b7574fdae924f6da3836f83c8bd4793e38/coverage-7.15.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:66591c46bdd2971d3ae2bc503a5f0459c2edcaf6b7e045b292000cc95bc6cb95", size = 223568, upload-time = "2026-08-02T18:49:44.706Z" }, + { url = "https://files.pythonhosted.org/packages/dd/bb/9c2ad5574a0d6420a96c6cade4f8a683931b9e79fe609f8924d7b6964616/coverage-7.15.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa64458b81b18bfc67cdf1f6dc02b23e3edc672f2f8e11771fad75865415a43", size = 264932, upload-time = "2026-08-02T18:49:47.153Z" }, + { url = "https://files.pythonhosted.org/packages/ba/91/938c39e77bdd5a0a440412f975609ce3702dabbda6ac715719d93ca45a7b/coverage-7.15.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:447f5421ccf5475956cf516d4ca1d575f487947b6f4e11f9d80c6aefe24b3dc8", size = 267052, upload-time = "2026-08-02T18:49:49.324Z" }, + { url = "https://files.pythonhosted.org/packages/b0/a3/7b431a98af35d9cc6394e54cde9435b33b8591672fbece6a4931267d7a8e/coverage-7.15.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a0c77ef8cd483a4987a5d12d1d9d5f7ee598dfdc6c0844417d847e5768dc779", size = 269473, upload-time = "2026-08-02T18:49:51.599Z" }, + { url = "https://files.pythonhosted.org/packages/32/58/dbc9951dce46be47a732823a1c571f62bcabdd54a68d8c281489a1a55cfb/coverage-7.15.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0b273f4ff657446a06c2d85bf80e134fa869a92852ba5f87854a70e1fb44da77", size = 270591, upload-time = "2026-08-02T18:49:53.865Z" }, + { url = "https://files.pythonhosted.org/packages/71/bd/1d610772c7c0889bfe477a59c46ee66ea53e271f3f06951e9d55b317f7c6/coverage-7.15.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daea8c4fafa22488600405be2c2be525a9406fba3fc0a83acc726db3e14e2005", size = 264007, upload-time = "2026-08-02T18:49:55.875Z" }, + { url = "https://files.pythonhosted.org/packages/69/97/852eb3dcdba156b1a9078503f098499916bf889f964b61ad4a08223ac169/coverage-7.15.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:93ff57c530f3fa7aa69f92fb9b8892b8aa82712aa970842f4abf28657f42fb57", size = 266926, upload-time = "2026-08-02T18:49:57.944Z" }, + { url = "https://files.pythonhosted.org/packages/52/f8/b72cd238757fba2b587fc7dee047efe6e10b0c18343509faaaf502dd4680/coverage-7.15.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4df21bef8b800eebda9018f53d49c9ace3aeb0090c850139b27923aafcb83e91", size = 264529, upload-time = "2026-08-02T18:50:00.035Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0a/6c52ec4b7fb007cb6433d1fcfda4080cb15d75ad37ef9c31025f3427293e/coverage-7.15.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:db567b02685f26034adcbd85055f80d12cdf02111b8ed00886093d98b2874ce2", size = 268263, upload-time = "2026-08-02T18:50:02.161Z" }, + { url = "https://files.pythonhosted.org/packages/c0/4e/f1f9aa3efd109a04353563a43fb5155340c1fdcdeaa6296ebed3b6f510ea/coverage-7.15.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5318dd51b8600b947e058cf5a4fe54d183d9d13c49b97b64ca7be05a34df9bef", size = 263377, upload-time = "2026-08-02T18:50:04.243Z" }, + { url = "https://files.pythonhosted.org/packages/dd/fb/6b268a0b2728ef1c379ad656b899274477a5f6bed1bf6765b4b387fb0601/coverage-7.15.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c995bfa383c54704839b6c4c2627a1c00895597ada0e5e8190c81d8bd620555c", size = 265688, upload-time = "2026-08-02T18:50:06.428Z" }, + { url = "https://files.pythonhosted.org/packages/29/54/1a3ea96e5d5e7cd41dc432597bfc60692910e635d05e1cc25a8ccc243581/coverage-7.15.3-cp314-cp314t-win32.whl", hash = "sha256:6433fafb8da0e1d02eb53411e0ecdadb6b88f0224fdc23317e703c0e88937d42", size = 225066, upload-time = "2026-08-02T18:50:08.533Z" }, + { url = "https://files.pythonhosted.org/packages/31/9d/a7b0d9afd18ed5274dd00651a78e7810a931c70d94b79996f150bec1a30f/coverage-7.15.3-cp314-cp314t-win_amd64.whl", hash = "sha256:fe578952b1b29fe8c777f43f241d49efac4b56724a3434f5d22ebe3c208df429", size = 225897, upload-time = "2026-08-02T18:50:10.572Z" }, + { url = "https://files.pythonhosted.org/packages/ca/11/34c5ae40b945e69aa72b87dc268135b7049905f3824af573b7073acbb946/coverage-7.15.3-cp314-cp314t-win_arm64.whl", hash = "sha256:d2e1acb7aee29dfa8f3e48c23f36670898baca1209d9bdd3985a50c7f982165e", size = 225212, upload-time = "2026-08-02T18:50:12.63Z" }, + { url = "https://files.pythonhosted.org/packages/37/e7/7069b3d6c018917f49ba2e1c5fb910e498c7fefa3a1b78cb1b79e61ff45d/coverage-7.15.3-py3-none-any.whl", hash = "sha256:da78fa6fc7dafe4212839173133ee85afcf42c5cd5f3e47fa7c1c210453b445e", size = 214297, upload-time = "2026-08-02T18:50:14.709Z" }, ] [package.optional-dependencies] @@ -581,13 +581,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7a/e3/9d34173ec068631faea3ea6e73050700729363e7e33306a9a3218e5cdc61/duckdb-1.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:c9f3e0b71b8a50fccfb42794899285d9d318ce2503782b9dd54868e5ecd0ad31", size = 14402513, upload-time = "2026-04-13T11:30:06.609Z" }, ] +[[package]] +name = "evdev" +version = "1.9.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/f5/397b61091120a9ca5001041dd7bf76c385b3bfd67a0e5bcb74b852bd22a4/evdev-1.9.3.tar.gz", hash = "sha256:2c140e01ac8437758fa23fe5c871397412461f42d421aa20241dc8fe8cfccbc9", size = 32723, upload-time = "2026-02-05T21:54:24.987Z" } + [[package]] name = "execnet" version = "2.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622 } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708 }, + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, ] [[package]] @@ -946,6 +952,8 @@ dependencies = [ { name = "openai" }, { name = "pillow" }, { name = "prompt-toolkit" }, + { name = "pynput" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" }, { name = "pyreadline3", marker = "sys_platform == 'win32'" }, { name = "pyyaml" }, { name = "rich" }, @@ -983,6 +991,8 @@ requires-dist = [ { name = "openai", specifier = ">=1.40" }, { name = "pillow", specifier = ">=10.0" }, { name = "prompt-toolkit", specifier = ">=3.0.40" }, + { name = "pynput", specifier = ">=1.8.0" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'", specifier = ">=12.2" }, { name = "pyreadline3", marker = "sys_platform == 'win32'", specifier = ">=3.5" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" }, @@ -1756,6 +1766,120 @@ crypto = [ { name = "cryptography" }, ] +[[package]] +name = "pynput" +version = "1.8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "evdev", marker = "'linux' in sys_platform" }, + { name = "pyobjc-framework-applicationservices", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" }, + { name = "python-xlib", marker = "'linux' in sys_platform" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/c6/e2d415610cfbc78308bee44218a46124aaa3301b1df08814df819b2254a1/pynput-1.8.2.tar.gz", hash = "sha256:f493c87157cd3861b4468f7f896857051762f44ed26f1b641e7cc5840a457087", size = 82818, upload-time = "2026-05-12T19:11:39.464Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/98/bbeb760852adb27f166ce1617f0e51aabb15f21b1e60ea703f2aed3c78ac/pynput-1.8.2-py2.py3-none-any.whl", hash = "sha256:8cc38cf13a6ab2749cb375678be8a0fd705d7ce49c8001ff5db4007a723bbef1", size = 92028, upload-time = "2026-05-12T19:11:37.89Z" }, +] + +[[package]] +name = "pyobjc-core" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/b1/729f7458a63758bd21716648a8abcd9a0c8f2d2e9897763c8a1a1c7fd31b/pyobjc_core-12.2.1.tar.gz", hash = "sha256:7a7b9b018402342cf32bf1956366896350fbe5c0478cb3ef59778f77abed7f07", size = 1063383, upload-time = "2026-06-19T16:19:39.357Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/87/16564ef5e4568ee0edd9e712d8111dc8b67621d6bb6ff430646ee2d637dd/pyobjc_core-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:24b76a63caf0b5369d4a377c7c0438cd70df81539057af3db839bfaa3579e04a", size = 6484662, upload-time = "2026-06-19T16:04:44.979Z" }, + { url = "https://files.pythonhosted.org/packages/8c/88/300ad283bed0c971c52dcac6f70113e138169d4ce6d856ddd03d16081e51/pyobjc_core-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a64232bb27ed101d4adc7d42b0e64a6d3331aac7bee7861c037a6777a163f10b", size = 6433347, upload-time = "2026-06-19T16:04:49.341Z" }, + { url = "https://files.pythonhosted.org/packages/3e/1e/b9b0ddffae66996b8779f1f7958adc9f21c13a0448cd3be8d7fe589b5b0f/pyobjc_core-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:af101222762665a4125157906cb4b23f5d5a63d3851d5e0504f72a1eaaa2cfd2", size = 6436004, upload-time = "2026-06-19T16:04:53.257Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/bd309ede07784c6e5fac4b440c90a5f72a66da7859ed303a9392fe8a5f3f/pyobjc_core-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:efe465e3ecc6fc73f7c7622620345d134a8d34564ab1c29d8247e45f4ed55071", size = 6687044, upload-time = "2026-06-19T16:04:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/bd/8a/cfa4f56939d554dbb342ec6e5226a441e2f552bc2002a0ddf7705bb11bef/pyobjc_core-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2b8fc0531c27277325e113ac00b8a72a82e6145f0a88175b9425d8de814ff69a", size = 6429289, upload-time = "2026-06-19T16:05:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/42/74/446c89bc18103aaa4a00d1fb85ff8acace9a0dc3f362d9678ebf7571e275/pyobjc_core-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9bef500f979e22d54f9da3aaebf6a48f873234b324858bd69256055a318955c7", size = 6690181, upload-time = "2026-06-19T16:05:06.201Z" }, + { url = "https://files.pythonhosted.org/packages/99/c7/0121ee4c616af07ad2de8cd1a286f6978dc9a227eb58b7c2e875cb68a1df/pyobjc_core-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:047c226eeb58a2993ace5e8904e71cc9426ee20d064c617f8fbf32717d37093e", size = 6487078, upload-time = "2026-06-19T16:05:10.093Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a8/cb9fcc150f97d0bf22a2028f88b24cc35949beb1bcc7b8bc5c17d4401677/pyobjc_core-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1188613805336270279570467e4455b74cb6c0f60913ac74c917ee1c37cfaecb", size = 6733064, upload-time = "2026-06-19T16:05:14.313Z" }, +] + +[[package]] +name = "pyobjc-framework-applicationservices" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-framework-coretext" }, + { name = "pyobjc-framework-quartz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/4d/0ebdd8144aba94b8fe9828ccee5616a4bf53d1f8bc51cff55f3cce86d695/pyobjc_framework_applicationservices-12.2.1.tar.gz", hash = "sha256:048ea663c9ac75c44a15dc7d5b8d78cbb4c97bf1c76e83835e8d5498e184001f", size = 109342, upload-time = "2026-06-19T16:19:46.149Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/8a/5a9310929bb303c31c04a1c6dc7b3213c9bd964a692d024a00c0643af2c8/pyobjc_framework_applicationservices-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dabec481217b0d0c1ea835e9fef6b0681381b14b3f16a30d4a9d801ee3852dd2", size = 32715, upload-time = "2026-06-19T16:05:38.448Z" }, + { url = "https://files.pythonhosted.org/packages/bf/89/39a7462006afbc06c69029fe4181b7359a9da25ae7864ef75f9d3ffb9272/pyobjc_framework_applicationservices-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f519ced13888d03410cd7da1f08fc56ee2944099e607216cef7ca26ecfdef61b", size = 32764, upload-time = "2026-06-19T16:05:39.26Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6e/8e928d5e3025529ed92c6eb5fd88a5e6e485cc6df945c541f29b4af7f2c6/pyobjc_framework_applicationservices-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8749290f796e6cca341d443769b79329dde5d157bcc4413c1f7fdb68ea4a8e48", size = 32782, upload-time = "2026-06-19T16:05:40.284Z" }, + { url = "https://files.pythonhosted.org/packages/50/a5/c7b5a31777fe2ce7c07b9c16941ff4fbf0a150bf755164d96228d32ccb4f/pyobjc_framework_applicationservices-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9ee11677fbd6a0987234814c7dde88ffd11242e8c1f76952e6654ea07f2370ac", size = 33048, upload-time = "2026-06-19T16:05:41.308Z" }, + { url = "https://files.pythonhosted.org/packages/8b/47/cd2bd76b862686c0aa78568ed9dff175764353c209a3096c72d6e2a9b151/pyobjc_framework_applicationservices-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:a1c0ee536cb8bd7f5a811165ec323a9207b1e8dad9534fe2081f767fb90b0411", size = 32921, upload-time = "2026-06-19T16:05:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/6989a96f8501aa3a16513d08b2c4c78ca906a10b6a4e5a33c4c59fd1bea9/pyobjc_framework_applicationservices-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0d55dd5be19e4a1363662bc8b48894d45714d07f0ee3958665fc9ea7df0f61b7", size = 33163, upload-time = "2026-06-19T16:05:43.256Z" }, + { url = "https://files.pythonhosted.org/packages/5c/41/66f2bcd12454a85f0479393f5825021332ee84b49583c7954cd20310cab5/pyobjc_framework_applicationservices-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:e91c84238b2f68f608473854bcabb8770f15ad837e7561d217f24a1e482e42be", size = 32915, upload-time = "2026-06-19T16:05:44.151Z" }, + { url = "https://files.pythonhosted.org/packages/81/cf/04b6b1eb181fa3071e9743bab7551f5d2ec3f650ac74bab790f21717ba51/pyobjc_framework_applicationservices-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:09bfa27765d4c74155323fd8185b7aba6d639ce06c0a9f00ee2d9e7dce3d7800", size = 33158, upload-time = "2026-06-19T16:05:45.017Z" }, +] + +[[package]] +name = "pyobjc-framework-cocoa" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/34/fbe38a204643aa4e1b91391cdce07a34da565a69171ebcad08de7438a556/pyobjc_framework_cocoa-12.2.1.tar.gz", hash = "sha256:b94b37fe5730e5ae1fb0052912cd174e6ec329b0bfba4a012ae5db1014b5864b", size = 3125751, upload-time = "2026-06-19T16:20:05.159Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/d6/dc66ea8519a0475efbccf73f82cc28066339bb300a27f5e1bf91ab1d7002/pyobjc_framework_cocoa-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dc6da84f4fc62cc25463bbb85e77a57b8d5ac6caf9a60702daf2edb601332f15", size = 387298, upload-time = "2026-06-19T16:07:37.412Z" }, + { url = "https://files.pythonhosted.org/packages/f7/cf/1b3b32b2f28f66cc053c3438ef4e6df36a1591945bf05e7399da18d74553/pyobjc_framework_cocoa-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:28b9b8bab1c36efb94744786918752d0c1842f5fbb67e7d5ca97b5f736512080", size = 388113, upload-time = "2026-06-19T16:07:38.9Z" }, + { url = "https://files.pythonhosted.org/packages/cc/46/68e8e4d926a2f70fed0437047bc3f9fe08af8fe620d94d80656ebc3cfa9b/pyobjc_framework_cocoa-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3b74a78fa7803e547b32e5e8ec1b49987b52fe318383e793bc6cd49b80efbd9f", size = 388183, upload-time = "2026-06-19T16:07:40.483Z" }, + { url = "https://files.pythonhosted.org/packages/2e/f3/dfc9af4c9eb2e5389c860ad5ef252be9fe456db09f39d537555dc5057aa1/pyobjc_framework_cocoa-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:dc2eaca2f13c7bcd8e41e51a372e47825dea9dd3126108760eed7ba883d2945c", size = 392275, upload-time = "2026-06-19T16:07:42.078Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c8/b90baa8f3592eded79b4be98fb59d2b8dc16b62361e34292bd95806ebd9f/pyobjc_framework_cocoa-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b386c324d64ae565c1f6b7dfb77be68f640a1c7c23caa6966ab661131f519561", size = 388357, upload-time = "2026-06-19T16:07:43.364Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/64a94651b9294702d55e748d94de30e25bc59d0784526be7643f4467eccd/pyobjc_framework_cocoa-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a6c584e2af0813cb2f6103b184e632665a26f58c1bd5b08ffd6e95a19c617f7b", size = 392404, upload-time = "2026-06-19T16:07:44.955Z" }, + { url = "https://files.pythonhosted.org/packages/5c/cc/26e8a7bf1f5e8caa38b7f80d486296f9fd3c97e71ad7e5444ef22e802758/pyobjc_framework_cocoa-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:b6023657b8d6cc049a21bd6b4752425f2f53c42f9f0b02d64c7608cc484bf103", size = 388589, upload-time = "2026-06-19T16:07:46.276Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/eedf743a303ea742b8e082afe3613fb4d6618bc1a48cf2568b004ce906f7/pyobjc_framework_cocoa-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:c685ccd8e266a07cf912a2c5a13b1f2eff2a868a1aff163b4801b4687bd425e1", size = 392691, upload-time = "2026-06-19T16:07:47.477Z" }, +] + +[[package]] +name = "pyobjc-framework-coretext" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-framework-quartz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/9c/4c7f452059dc1d3845b8e627b9113c247a997b9b07518e848c2ab7ff3149/pyobjc_framework_coretext-12.2.1.tar.gz", hash = "sha256:af740e784d7c592c34025ec7165f4f6c1a69b5a2d9075f06e41e4f77c212aed2", size = 97349, upload-time = "2026-06-19T16:20:22.508Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/53/c262cf5052c648c48b3f7562fcce188fb78ff94e44cf1c48fdfc62fdfcce/pyobjc_framework_coretext-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:02a448675d28005fdb88fb3c585572b02871e9e5d4d08f88334ec937ea5de6e7", size = 30022, upload-time = "2026-06-19T16:09:50.37Z" }, + { url = "https://files.pythonhosted.org/packages/c5/11/c1298c2ec3b0cd19a457a1fd0da47898f894a13df5516f80dc04d1a7a4d9/pyobjc_framework_coretext-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ac2ead13dfa4379a1566129d0e8a8ea778a2bcac9ac360a583360fd4f1ba39c6", size = 30123, upload-time = "2026-06-19T16:09:51.183Z" }, + { url = "https://files.pythonhosted.org/packages/05/8c/154e8f34923b24aade64a20eca2b759f8f67e109654308103080751f246f/pyobjc_framework_coretext-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5c5a3c6e2d905a17efb15572dad97ce582feeab5c3b92537015445e0e0bb46de", size = 30116, upload-time = "2026-06-19T16:09:52.219Z" }, + { url = "https://files.pythonhosted.org/packages/01/61/f53458c8f7fe74008e342946eca1fa82b777b284d4e13d8bd2e3e5724cab/pyobjc_framework_coretext-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5c979058c77df8cd3dac5fd7db4c484f9886fbe09e2687bfaf269a856f631f78", size = 30659, upload-time = "2026-06-19T16:09:53.034Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d8/d1178bb1ba3bb7a0d7a55db460aa89f2a8b232ed7eaf76cb402923cacf2d/pyobjc_framework_coretext-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:d0b3b0467a23dbc2a39d0839e7100cc98b429fb7d52a471bd65477f46bb4c9e5", size = 30100, upload-time = "2026-06-19T16:09:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/12/c3/780d739909e6d8ddd9e8786fd19e8ea10ccfcc7275df987e349af0fb33b1/pyobjc_framework_coretext-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:3d4a92fa657180cd0e900b98535da4f0f4d8c76a7077730a507e50d52d2853e3", size = 30642, upload-time = "2026-06-19T16:09:54.736Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e9/867197c6cf2396b33e95d6175d7fdc6f789314859fb107560ae9b19c7b14/pyobjc_framework_coretext-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:7a17034fd0a08cf58323b7234a385ce0ec355509d414df69e3f8f88df26de1fc", size = 30098, upload-time = "2026-06-19T16:09:55.679Z" }, + { url = "https://files.pythonhosted.org/packages/10/a3/f4e6d1a38cd4db8a1275eddb287f3cdc2c01c48f80b30e89cc58cfd92156/pyobjc_framework_coretext-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:28980144af75598654f6997b2bbb427885f4f5df90aa4d840f452ba5778ab155", size = 30669, upload-time = "2026-06-19T16:09:56.521Z" }, +] + +[[package]] +name = "pyobjc-framework-quartz" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/f6/2a8b84dbf1fe7c04dd96ea73d991678d4e09a909f51971ecc51629bb2ab4/pyobjc_framework_quartz-12.2.1.tar.gz", hash = "sha256:b3b8b6f71e66147f8ff9e6213864cc8527e3a0b1ee90835b93ce221f4802d9b0", size = 3215521, upload-time = "2026-06-19T16:21:30.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/08/527d1ff856e2f2446b5887be01989cc08f9adaf3de7d4eb13d07826c362f/pyobjc_framework_quartz-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60f29408b4f9ed5391a29c6b63e2aa56ddfb8b66b3fb47962930427981e14462", size = 217998, upload-time = "2026-06-19T16:16:02.978Z" }, + { url = "https://files.pythonhosted.org/packages/14/fc/d7c7b3134cdbd1a487f3f77b5be125d87a6c9e7d9411035739d99335cc0c/pyobjc_framework_quartz-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:de9c8cca7e95290c8d540466af11c7cdfe3a5458e6f56c34006d5b45243f9ed9", size = 219000, upload-time = "2026-06-19T16:16:04.29Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4b/861f91a1565d3189ee899e177b915551fb9a7e2ca25414025a8974f04e74/pyobjc_framework_quartz-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:54c9bc7f507192691841ee4eba5bf36990b259df83ac728efed2d7ea1cd021e4", size = 219403, upload-time = "2026-06-19T16:16:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b5/b27010d2f288737f627f74be6d5549f49c841542365c84b9a3011fe39ce7/pyobjc_framework_quartz-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:bfc0d2badd819823d21df8069dcf9544ce360ed747a8895c51bdb25d8d125f45", size = 224458, upload-time = "2026-06-19T16:16:07.252Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5d/85ffd9d433989205d572a50d625c63b29c05e0c5235a725f15ae1023672c/pyobjc_framework_quartz-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ceb56939c337b36d9d81185ade31f77dc52c85cf79bb16e53e9b32f54b6bb3f5", size = 219769, upload-time = "2026-06-19T16:16:08.814Z" }, + { url = "https://files.pythonhosted.org/packages/e2/d6/b917e4b63d72ea84a27121076f3033f23f6497c0e6ce8d304766c899897f/pyobjc_framework_quartz-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8105c98b798f2bf81c05c54bddeeadbf62f0b5dfec13bd6e719dd2cdf7e1cddf", size = 224717, upload-time = "2026-06-19T16:16:10.215Z" }, + { url = "https://files.pythonhosted.org/packages/04/e2/f3c1ed3228f7430ef5ade23db6f1fcbae99290f177ce5653348fd9e05f4d/pyobjc_framework_quartz-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:bbc214f1a216b5d3651bc832d0ac4589f029f3f37cd6cbb370aac12a7c77942c", size = 219825, upload-time = "2026-06-19T16:16:11.433Z" }, + { url = "https://files.pythonhosted.org/packages/66/2a/2c99a5ad2fe0a11600ea123b8e9a08ff138fcb2ad1e13e376f4bd4aa1d96/pyobjc_framework_quartz-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:ca61624a0b0e6286d8a0f97f47eb9011e4e81e9a339db436d48af527e7065bb1", size = 224770, upload-time = "2026-06-19T16:16:13.035Z" }, +] + [[package]] name = "pyreadline3" version = "3.5.6" @@ -1803,9 +1927,9 @@ dependencies = [ { name = "pluggy" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592 } +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876 }, + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] [[package]] @@ -1816,9 +1940,9 @@ dependencies = [ { name = "execnet" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069 } +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396 }, + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, ] [[package]] @@ -1851,6 +1975,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, ] +[[package]] +name = "python-xlib" +version = "0.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/f5/8c0653e5bb54e0cbdfe27bf32d41f27bc4e12faa8742778c17f2a71be2c0/python-xlib-0.33.tar.gz", hash = "sha256:55af7906a2c75ce6cb280a584776080602444f75815a7aff4d287bb2d7018b32", size = 269068, upload-time = "2022-12-25T18:53:00.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/b8/ff33610932e0ee81ae7f1269c890f697d56ff74b9f5b2ee5d9b7fa2c5355/python_xlib-0.33-py2.py3-none-any.whl", hash = "sha256:c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398", size = 182185, upload-time = "2022-12-25T18:52:58.662Z" }, +] + [[package]] name = "pytz" version = "2026.3.post1" @@ -2288,54 +2424,54 @@ wheels = [ name = "tomli" version = "2.4.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704 }, - { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454 }, - { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561 }, - { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824 }, - { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227 }, - { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859 }, - { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204 }, - { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084 }, - { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285 }, - { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924 }, - { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018 }, - { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948 }, - { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341 }, - { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159 }, - { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290 }, - { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141 }, - { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847 }, - { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088 }, - { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866 }, - { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887 }, - { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704 }, - { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628 }, - { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180 }, - { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674 }, - { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976 }, - { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755 }, - { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265 }, - { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726 }, - { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859 }, - { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713 }, - { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084 }, - { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973 }, - { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223 }, - { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973 }, - { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082 }, - { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490 }, - { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263 }, - { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736 }, - { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717 }, - { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461 }, - { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855 }, - { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144 }, - { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683 }, - { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196 }, - { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393 }, - { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583 }, +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] [[package]]