From ab4ba978869f824e5ba1b46a622cc532aa7b6447 Mon Sep 17 00:00:00 2001 From: Daniel Dolezal Date: Thu, 9 Apr 2026 01:41:01 +0200 Subject: [PATCH] add moveing of tabs and groups, multi browser support, auto complite into terminal, extract html and adding testing --- .gitignore | 1 + browser_cli/cli.py | 114 ++++++++++++++++++++++---- browser_cli/client.py | 49 ++++++++++-- browser_cli/commands/extract.py | 7 ++ browser_cli/commands/groups.py | 17 ++++ browser_cli/commands/navigate.py | 2 +- browser_cli/commands/tabs.py | 13 ++- browser_cli/native_host.py | 86 ++++++++++++++++---- extension/background.js | 123 ++++++++++++++++++++++++---- pyproject.toml | 10 ++- tests/conftest.py | 30 +++++++ tests/test_dom.py | 62 ++++++++++++++ tests/test_extract.py | 49 ++++++++++++ tests/test_groups.py | 133 +++++++++++++++++++++++++++++++ tests/test_nav.py | 83 +++++++++++++++++++ tests/test_session.py | 47 +++++++++++ tests/test_tabs.py | 109 +++++++++++++++++++++++++ tests/test_windows.py | 61 ++++++++++++++ uv.lock | 130 +++++++++++++++++++++++++++++- 19 files changed, 1069 insertions(+), 57 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_dom.py create mode 100644 tests/test_extract.py create mode 100644 tests/test_groups.py create mode 100644 tests/test_nav.py create mode 100644 tests/test_session.py create mode 100644 tests/test_tabs.py create mode 100644 tests/test_windows.py diff --git a/.gitignore b/.gitignore index e689877..01aba14 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ __pycache__/ +.vscode/ *.pyc diff --git a/browser_cli/cli.py b/browser_cli/cli.py index 1b28b08..8e3f967 100755 --- a/browser_cli/cli.py +++ b/browser_cli/cli.py @@ -59,22 +59,58 @@ main.add_command(session_group) @main.command("clients") def cmd_clients(): """Show connected browser clients.""" - try: - clients = send_command("clients.list") - except BrowserNotConnected as e: - console.print(f"[red]Error:[/red] {e}") + import json as _json + from browser_cli.client import REGISTRY_PATH, DEFAULT_SOCKET + + # Build a map of profile → socket path from the registry + profiles: dict[str, str] = {} + if REGISTRY_PATH.exists(): + try: + profiles = _json.loads(REGISTRY_PATH.read_text()) + except Exception: + pass + if not profiles: + profiles = {"default": DEFAULT_SOCKET} + + all_clients = [] + for profile_name, sock_path in profiles.items(): + try: + result = send_command("clients.list", profile=profile_name) + for c in (result or []): + c.setdefault("profile", profile_name) + all_clients.append(c) + except (BrowserNotConnected, RuntimeError): + # Socket registered but browser no longer connected + all_clients.append({"profile": profile_name, "name": "—", "version": "—", "platform": "disconnected"}) + + if not all_clients: + console.print("[yellow]No browser clients found[/yellow]") sys.exit(1) from rich.table import Table table = Table(show_header=True, header_style="bold cyan") + table.add_column("Profile") table.add_column("Browser") table.add_column("Version") table.add_column("Platform") - for c in (clients or []): - table.add_row(c.get("name", ""), c.get("version", ""), c.get("platform", "")) + for c in all_clients: + table.add_row(c.get("profile", "default"), c.get("name", ""), c.get("version", ""), c.get("platform", "")) console.print(table) +@main.command("rename-profile") +@click.argument("alias") +def cmd_rename_profile(alias): + """Set the profile alias used to identify this browser instance.""" + try: + send_command("clients.rename_profile", {"alias": alias}) + except BrowserNotConnected as e: + console.print(f"[red]Error:[/red] {e}") + sys.exit(1) + console.print(f"[green]Profile renamed to '{alias}'[/green]") + console.print(" Restart the browser for the change to take effect.") + + # ── install ──────────────────────────────────────────────────────────────────── @main.command("install") @@ -82,17 +118,19 @@ def cmd_clients(): def cmd_install(browser): """Register the native messaging host and print extension load instructions.""" - # Find the native_host.py path - native_host_script = Path(__file__).parent / "native_host.py" - if not native_host_script.exists(): - console.print(f"[red]Cannot find native_host.py at {native_host_script}[/red]") + # Find the venv entry point for the native host (stable regardless of project location) + venv_script = Path(sys.executable).parent / "browser-cli-native-host" + if not venv_script.exists(): + console.print(f"[red]Cannot find browser-cli-native-host in venv ({venv_script})[/red]") + console.print(" Run [cyan]uv sync[/cyan] first to install entry points.") sys.exit(1) - # Build a wrapper shell script so it's executable by Chrome - wrapper_path = Path(__file__).parent.parent / "browser-cli-native-host" - python_exe = sys.executable + # Install wrapper to ~/.local/bin so the manifest path never changes + local_bin = Path.home() / ".local" / "bin" + local_bin.mkdir(parents=True, exist_ok=True) + wrapper_path = local_bin / "browser-cli-native-host" wrapper_content = f"""#!/bin/sh -exec "{python_exe}" "{native_host_script}" "$@" +exec "{venv_script}" "$@" """ wrapper_path.write_text(wrapper_content) wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) @@ -143,5 +181,53 @@ exec "{python_exe}" "{native_host_script}" "$@" console.print(" After restarting Chrome, try: [cyan]browser-cli tabs list[/cyan]") +# ── completion ───────────────────────────────────────────────────────────────── + +@main.command("completion") +@click.argument("shell", type=click.Choice(["zsh", "bash", "fish"])) +@click.option("--script", is_flag=True, help="Output the raw completion script instead of instructions") +def cmd_completion(shell, script): + """Print shell completion setup instructions (or output the script with --script).""" + if script: + from click.shell_completion import BashComplete, ZshComplete, FishComplete + cls = {"zsh": ZshComplete, "bash": BashComplete, "fish": FishComplete}[shell] + comp = cls(main, {}, "browser-cli", "_BROWSER_CLI_COMPLETE") + click.echo(comp.source()) + return + + exe = sys.executable.replace("/python", "/browser-cli").replace("/python3", "/browser-cli") + if not Path(exe).exists(): + exe = "browser-cli" + + env_var = "_BROWSER_CLI_COMPLETE" + + if shell == "zsh": + console.print("[bold]Quickest setup — generate the file once:[/bold]") + console.print() + console.print(f" [cyan]uv run browser-cli completion zsh --script > ~/.zfunc/_browser-cli[/cyan]") + console.print() + console.print(" Then add these lines to [bold]~/.zshrc[/bold] (before any compinit call):") + console.print(" [cyan]fpath=(~/.zfunc $fpath)[/cyan]") + console.print(" [cyan]autoload -Uz compinit && compinit[/cyan]") + console.print() + console.print(" Reload: [cyan]exec zsh[/cyan]") + console.print() + console.print("[bold]Alternative — eval on every shell start (simpler but slower):[/bold]") + console.print(f' [cyan]eval "$({env_var}=zsh_source {exe})"[/cyan]') + elif shell == "bash": + console.print("[bold]Quickest setup — generate the file once:[/bold]") + console.print() + console.print(f" [cyan]uv run browser-cli completion bash --script > ~/.bash_completion.d/browser-cli[/cyan]") + console.print() + console.print(" Reload: [cyan]source ~/.bashrc[/cyan]") + console.print() + console.print("[bold]Alternative — eval on every shell start:[/bold]") + console.print(f' [cyan]eval "$({env_var}=bash_source {exe})"[/cyan]') + elif shell == "fish": + console.print("[bold]Setup:[/bold]") + console.print() + console.print(f" [cyan]uv run browser-cli completion fish --script > ~/.config/fish/completions/browser-cli.fish[/cyan]") + + if __name__ == "__main__": main() diff --git a/browser_cli/client.py b/browser_cli/client.py index ee4135d..2c7912f 100644 --- a/browser_cli/client.py +++ b/browser_cli/client.py @@ -1,22 +1,59 @@ """ Unix socket client — sends commands to the native host relay socket. Used by both the CLI and the public Python API. + +Profile selection order: + 1. Explicit `profile` argument to send_command() + 2. BROWSER_CLI_PROFILE environment variable + 3. First entry in /tmp/browser-cli-registry.json + 4. Fallback: /tmp/browser-cli-default.sock """ import json +import os import socket import struct import uuid +from pathlib import Path from typing import Any -SOCKET_PATH = "/tmp/browser-cli.sock" +REGISTRY_PATH = Path("/tmp/browser-cli-registry.json") +DEFAULT_SOCKET = "/tmp/browser-cli-default.sock" class BrowserNotConnected(Exception): """Raised when the native host socket is not available.""" -def send_command(command: str, args: dict | None = None) -> Any: +def _resolve_socket(profile: str | None = None) -> str: + """Return the socket path for the given profile (or auto-detect).""" + target = profile or os.environ.get("BROWSER_CLI_PROFILE") + + if target: + if REGISTRY_PATH.exists(): + try: + reg = json.loads(REGISTRY_PATH.read_text()) + if target in reg: + return reg[target] + except Exception: + pass + safe = target.replace(" ", "_").replace("/", "_") + return f"/tmp/browser-cli-{safe}.sock" + + # Auto-detect: use first registered entry + if REGISTRY_PATH.exists(): + try: + reg = json.loads(REGISTRY_PATH.read_text()) + if reg: + return next(iter(reg.values())) + except Exception: + pass + + return DEFAULT_SOCKET + + +def send_command(command: str, args: dict | None = None, profile: str | None = None) -> Any: """Send a command to the browser and return the response data.""" + sock_path = _resolve_socket(profile) msg = { "id": str(uuid.uuid4()), "command": command, @@ -27,16 +64,18 @@ def send_command(command: str, args: dict | None = None) -> Any: try: with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock: - sock.connect(SOCKET_PATH) + sock.connect(sock_path) sock.sendall(framed) response = _recv_all(sock) except (FileNotFoundError, ConnectionRefusedError, OSError): + profile_hint = f" (profile: {profile})" if profile else "" raise BrowserNotConnected( - "Cannot connect to browser.\n" + f"Cannot connect to browser{profile_hint}.\n" "Make sure:\n" " 1. The browser-cli extension is installed and enabled\n" " 2. The native host is registered: uv run browser-cli install chrome\n" - " 3. Your browser is running" + " 3. Your browser is running\n" + " Tip: use BROWSER_CLI_PROFILE= to select a specific profile" ) result = json.loads(response) diff --git a/browser_cli/commands/extract.py b/browser_cli/commands/extract.py index 66e7d65..3c2c89f 100644 --- a/browser_cli/commands/extract.py +++ b/browser_cli/commands/extract.py @@ -66,3 +66,10 @@ def extract_json(selector): """Parse and pretty-print JSON content inside SELECTOR.""" data = _handle("extract.json", {"selector": selector}) console.print_json(json.dumps(data)) + + +@extract_group.command("html") +def extract_html(): + """Print the full HTML of the active tab to stdout.""" + html = _handle("extract.html") + click.echo(html or "") diff --git a/browser_cli/commands/groups.py b/browser_cli/commands/groups.py index e00b15b..9166d40 100644 --- a/browser_cli/commands/groups.py +++ b/browser_cli/commands/groups.py @@ -100,3 +100,20 @@ def group_add_tab(group, url): tab_id = result.get("tabId") if isinstance(result, dict) else result label = url or "new tab" console.print(f"[green]Opened {label}[/green] in group '{group}' (tab id: {tab_id})") + + +@group_group.command("move") +@click.argument("group") +@click.option("--forward", is_flag=True, help="Move group one position to the right") +@click.option("--backward", is_flag=True, help="Move group one position to the left") +def group_move(group, forward, backward): + """Move a tab group forward or backward (name or ID).""" + if not forward and not backward: + console.print("[red]Specify --forward or --backward[/red]") + raise SystemExit(1) + result = _handle("group.move", {"group": group, "forward": forward, "backward": backward}) + if isinstance(result, dict) and not result.get("moved"): + console.print(f"[yellow]Group '{group}' is already at the {'end' if forward else 'start'}[/yellow]") + else: + direction = "forward" if forward else "backward" + console.print(f"[green]Group '{group}' moved {direction}[/green]") diff --git a/browser_cli/commands/navigate.py b/browser_cli/commands/navigate.py index 17dab65..d1e4ab3 100644 --- a/browser_cli/commands/navigate.py +++ b/browser_cli/commands/navigate.py @@ -72,7 +72,7 @@ def cmd_forward(tab_id): @nav_group.command("focus") @click.argument("pattern") def cmd_focus(pattern): - """Jump to the first tab whose URL matches PATTERN.""" + """Jump to a tab by URL pattern or tab ID.""" result = _handle("navigate.focus", {"pattern": pattern}) if result: console.print(f"[green]Focused:[/green] {result.get('url', result)}") diff --git a/browser_cli/commands/tabs.py b/browser_cli/commands/tabs.py index 3f29c8e..ac75f10 100644 --- a/browser_cli/commands/tabs.py +++ b/browser_cli/commands/tabs.py @@ -64,12 +64,17 @@ def tabs_close(tab_id, inactive, duplicates): @tabs_group.command("move") @click.argument("tab_id", type=int) +@click.option("--forward", is_flag=True, help="Move one position to the right") +@click.option("--backward", is_flag=True, help="Move one position to the left") @click.option("--group", "group_id", type=int, default=None, help="Move to tab group ID") @click.option("--window", "window_id", type=int, default=None, help="Move to window ID") -@click.option("--index", type=int, default=None, help="Position index in target") -def tabs_move(tab_id, group_id, window_id, index): - """Move a tab to a different window or group.""" - _handle("tabs.move", {"tabId": tab_id, "groupId": group_id, "windowId": window_id, "index": index}) +@click.option("--index", type=int, default=None, help="Absolute position index in target") +def tabs_move(tab_id, forward, backward, group_id, window_id, index): + """Move a tab. Use --forward/--backward for relative movement.""" + _handle("tabs.move", { + "tabId": tab_id, "forward": forward, "backward": backward, + "groupId": group_id, "windowId": window_id, "index": index, + }) console.print("[green]Tab moved[/green]") diff --git a/browser_cli/native_host.py b/browser_cli/native_host.py index 6f73af5..999c2cb 100644 --- a/browser_cli/native_host.py +++ b/browser_cli/native_host.py @@ -5,6 +5,10 @@ Native Messaging Host for browser-cli. Chrome launches this process when the extension calls connectNative(). It relays messages between the Chrome extension (via stdin/stdout using the Native Messaging protocol) and the CLI (via a Unix domain socket). + +Multi-browser support: the extension sends a "hello" message on startup +with a profile alias. The host uses that alias to create a unique socket +path and registers it in a shared registry file. """ import json import os @@ -16,7 +20,10 @@ import threading import uuid from pathlib import Path -SOCKET_PATH = "/tmp/browser-cli.sock" +REGISTRY_PATH = Path("/tmp/browser-cli-registry.json") +DEFAULT_ALIAS = "default" + +SOCKET_PATH: str = "" # set after hello handshake PENDING: dict[str, queue.Queue] = {} PENDING_LOCK = threading.Lock() @@ -40,16 +47,48 @@ def write_native_message(stream, msg: dict) -> None: stream.flush() +# --- Registry helpers --- + +def _registry_add(alias: str, sock_path: str) -> None: + try: + reg = json.loads(REGISTRY_PATH.read_text()) if REGISTRY_PATH.exists() else {} + reg[alias] = sock_path + REGISTRY_PATH.write_text(json.dumps(reg)) + except Exception: + pass + + +def _registry_remove(alias: str) -> None: + try: + if not REGISTRY_PATH.exists(): + return + reg = json.loads(REGISTRY_PATH.read_text()) + reg.pop(alias, None) + REGISTRY_PATH.write_text(json.dumps(reg)) + except Exception: + pass + + +def _socket_path_for(alias: str) -> str: + safe = alias.replace(" ", "_").replace("/", "_") + return f"/tmp/browser-cli-{safe}.sock" + + # --- Thread A: read messages from extension (stdin) --- -def stdin_reader(): +def stdin_reader(alias: str): stdin = sys.stdin.buffer while True: msg = read_native_message(stdin) if msg is None: - # Extension disconnected — clean up socket and exit - _cleanup() + # Extension disconnected — clean up and exit + _cleanup(alias) os._exit(0) + + # Profile alias handshake + if msg.get("type") == "hello": + continue # already handled during startup + msg_id = msg.get("id") if msg_id: with PENDING_LOCK: @@ -60,13 +99,13 @@ def stdin_reader(): # --- Thread B: accept CLI socket connections --- -def socket_server(): - path = Path(SOCKET_PATH) +def socket_server(sock_path: str): + path = Path(sock_path) if path.exists(): path.unlink() sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - sock.bind(SOCKET_PATH) + sock.bind(sock_path) sock.listen(16) while True: @@ -92,10 +131,8 @@ def handle_cli_connection(conn: socket.socket) -> None: with PENDING_LOCK: PENDING[msg_id] = response_queue - # Forward command to extension via stdout write_native_message(sys.stdout.buffer, cmd) - # Wait for extension's response (30 s timeout) try: result = response_queue.get(timeout=30) except queue.Empty: @@ -139,20 +176,39 @@ def _recv_exact(conn: socket.socket, n: int) -> bytes | None: return buf -def _cleanup(): +def _cleanup(alias: str): try: - Path(SOCKET_PATH).unlink(missing_ok=True) + Path(_socket_path_for(alias)).unlink(missing_ok=True) except Exception: pass + _registry_remove(alias) def main(): - # Start socket server thread - t = threading.Thread(target=socket_server, daemon=True) + stdin = sys.stdin.buffer + + # Wait for the hello handshake to learn the profile alias + first_msg = read_native_message(stdin) + if first_msg and first_msg.get("type") == "hello": + alias = first_msg.get("alias") or DEFAULT_ALIAS + else: + # No hello — fall back to default, re-queue message if it was a command + alias = DEFAULT_ALIAS + if first_msg: + msg_id = first_msg.get("id") + if msg_id: + q: queue.Queue = queue.Queue() + with PENDING_LOCK: + PENDING[msg_id] = q + write_native_message(sys.stdout.buffer, first_msg) + + sock_path = _socket_path_for(alias) + _registry_add(alias, sock_path) + + t = threading.Thread(target=socket_server, args=(sock_path,), daemon=True) t.start() - # Read extension messages on main thread (blocks until extension disconnects) - stdin_reader() + stdin_reader(alias) if __name__ == "__main__": diff --git a/extension/background.js b/extension/background.js index df9db22..5345cb5 100644 --- a/extension/background.js +++ b/extension/background.js @@ -11,7 +11,12 @@ let port = null; // ── Connection management ───────────────────────────────────────────────────── -function connect() { +async function getProfileAlias() { + const { profileAlias } = await chrome.storage.local.get("profileAlias"); + return profileAlias || "default"; +} + +async function connect() { if (port) return; try { port = chrome.runtime.connectNative(NATIVE_HOST); @@ -21,7 +26,10 @@ function connect() { const err = chrome.runtime.lastError; if (err) console.warn("[browser-cli] Native host disconnected:", err.message); }); - console.log("[browser-cli] Connected to native host"); + // Send hello so native host knows which profile/alias this is + const alias = await getProfileAlias(); + port.postMessage({ type: "hello", alias }); + console.log("[browser-cli] Connected to native host as profile:", alias); } catch (e) { port = null; console.error("[browser-cli] Failed to connect:", e); @@ -45,6 +53,8 @@ async function onMessage(msg) { const { id, command, args } = msg; if (!id || !command) return; + console.log("[browser-cli] ←", command, args); + let data, error; try { data = await dispatch(command, args || {}); @@ -53,8 +63,10 @@ async function onMessage(msg) { } if (error !== undefined) { + console.log("[browser-cli] → ERROR", command, error); port.postMessage({ id, success: false, error }); } else { + console.log("[browser-cli] →", command, data); port.postMessage({ id, success: true, data }); } } @@ -90,6 +102,7 @@ async function dispatch(command, args) { case "group.close": return groupClose(args); case "group.open": return groupOpen(args); case "group.add_tab": return groupAddTab(args); + case "group.move": return groupMove(args); // ── Windows ─────────────────────────────────────────────────────────── case "windows.list": return windowsList(); @@ -110,6 +123,7 @@ async function dispatch(command, args) { case "extract.images": return domOp("extractImages", args); case "extract.text": return domOp("extractText", args); case "extract.json": return domOp("extractJson", args); + case "extract.html": return tabsHtml({}); // ── Session ─────────────────────────────────────────────────────────── case "session.save": return sessionSave(args); @@ -120,7 +134,8 @@ async function dispatch(command, args) { case "session.auto_save": return sessionAutoSave(args); // ── Misc ────────────────────────────────────────────────────────────── - case "clients.list": return clientsList(); + case "clients.list": return clientsList(); + case "clients.rename_profile": return clientsRenameProfile(args); default: throw new Error(`Unknown command: ${command}`); @@ -170,12 +185,19 @@ async function navForward({ tabId }) { } async function navFocus({ pattern }) { - const all = await chrome.tabs.query({}); - const match = all.find(t => t.url && t.url.includes(pattern)); + // If pattern is a plain integer, treat it as a tab ID + const asInt = parseInt(pattern); + let match; + if (!isNaN(asInt) && String(asInt) === String(pattern)) { + match = await chrome.tabs.get(asInt); + } else { + const all = await chrome.tabs.query({}); + match = all.find(t => (t.url && t.url.includes(pattern)) || (t.pendingUrl && t.pendingUrl.includes(pattern))); + } if (!match) return null; await chrome.windows.update(match.windowId, { focused: true }); await chrome.tabs.update(match.id, { active: true }); - return { id: match.id, url: match.url, title: match.title }; + return { id: match.id, url: match.url || match.pendingUrl, title: match.title }; } // ── Tabs ────────────────────────────────────────────────────────────────────── @@ -221,11 +243,20 @@ async function tabsClose({ tabId, inactive, duplicates }) { return { closed: toClose.length }; } -async function tabsMove({ tabId, groupId, windowId, index }) { +async function tabsMove({ tabId, groupId, windowId, index, forward, backward }) { const moveProps = {}; if (windowId != null) moveProps.windowId = windowId; - if (index != null) moveProps.index = index; - else moveProps.index = -1; + + if (forward || backward) { + const tab = await chrome.tabs.get(tabId); + if (forward) moveProps.index = tab.index + 2; // +2 because Chrome shifts after removal + else moveProps.index = Math.max(0, tab.index - 1); + } else if (index != null) { + moveProps.index = index; + } else { + moveProps.index = -1; + } + await chrome.tabs.move(tabId, moveProps); if (groupId != null) { await chrome.tabs.group({ tabIds: [tabId], groupId }); @@ -260,13 +291,41 @@ async function tabsQuery({ search }) { ).map(tabInfo); } +async function executeScript(options, retries = 3) { + for (let i = 0; i < retries; i++) { + try { + return await chrome.scripting.executeScript(options); + } catch (e) { + if (i < retries - 1 && e.message && (e.message.includes("Frame with ID") || e.message.includes("No tab with id")) && !e.message.includes("error page")) { + await new Promise(r => setTimeout(r, 300)); + continue; + } + throw e; + } + } +} + async function tabsHtml({ tabId }) { - const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); - const results = await chrome.scripting.executeScript({ - target: { tabId: tab.id }, - func: () => document.documentElement.outerHTML, - }); - return results[0]?.result || ""; + for (let i = 0; i < 3; i++) { + const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); + if (!isScriptableUrl(tab.url || tab.pendingUrl || "")) { + throw new Error(`Cannot get HTML of ${tab.url || tab.pendingUrl} — navigate to a regular web page first`); + } + try { + const results = await executeScript({ + target: { tabId: tab.id }, + func: () => document.documentElement.outerHTML, + }); + return results[0]?.result || ""; + } catch (e) { + const transient = e.message && (e.message.includes("Frame with ID") || e.message.includes("No tab with id")) && !e.message.includes("error page"); + if (i < 2 && transient) { + await new Promise(r => setTimeout(r, 300)); + continue; + } + throw e; + } + } } async function tabsDedupe() { @@ -371,6 +430,31 @@ async function groupAddTab({ group, url }) { return { tabId: tab.id, groupId }; } +async function groupMove({ group, forward, backward }) { + const groupId = await resolveGroupId(group); + const groupInfo = await chrome.tabGroups.get(groupId); + const allTabs = await chrome.tabs.query({ windowId: groupInfo.windowId }); + allTabs.sort((a, b) => a.index - b.index); + + const groupTabs = allTabs.filter(t => t.groupId === groupId); + if (!groupTabs.length) throw new Error(`No tabs found in group '${group}'`); + + const firstIdx = groupTabs[0].index; + const lastIdx = groupTabs[groupTabs.length - 1].index; + + if (forward) { + const tabAfter = allTabs.find(t => t.index > lastIdx && t.groupId !== groupId); + if (!tabAfter) return { groupId, moved: false }; + await chrome.tabs.move(groupTabs.map(t => t.id), { index: tabAfter.index + 1 }); + } else if (backward) { + const tabsBefore = allTabs.filter(t => t.index < firstIdx && t.groupId !== groupId); + const tabBefore = tabsBefore[tabsBefore.length - 1]; + if (!tabBefore) return { groupId, moved: false }; + await chrome.tabs.move(groupTabs.map(t => t.id), { index: tabBefore.index }); + } + return { groupId, moved: true }; +} + // ── Windows ─────────────────────────────────────────────────────────────────── async function windowsList() { @@ -420,7 +504,7 @@ async function domOp(funcName, args) { if (!isScriptableUrl(tab.url)) { throw new Error(`Cannot run DOM commands on ${tab.url} — navigate to a regular web page first`); } - const results = await chrome.scripting.executeScript({ + const results = await executeScript({ target: { tabId: tab.id }, func: contentDispatch, args: [funcName, args], @@ -568,14 +652,21 @@ async function autoSaveHandler() { async function clientsList() { const manifest = chrome.runtime.getManifest(); + const alias = await getProfileAlias(); return [{ name: "Chrome", version: navigator.userAgent.match(/Chrome\/([\d.]+)/)?.[1] || "unknown", platform: navigator.platform, extensionVersion: manifest.version, + profile: alias, }]; } +async function clientsRenameProfile({ alias }) { + await chrome.storage.local.set({ profileAlias: alias }); + return { alias }; +} + // ── Helpers ─────────────────────────────────────────────────────────────────── async function getActiveTab() { diff --git a/pyproject.toml b/pyproject.toml index f20aabc..2b5b95f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "browser-cli" -version = "0.2.1" +version = "0.3.1" description = "Control your real running browser from the terminal via a Chrome extension" requires-python = ">=3.10" dependencies = [ @@ -10,6 +10,10 @@ dependencies = [ [project.scripts] browser-cli = "browser_cli.cli:main" +browser-cli-native-host = "browser_cli.native_host:main" + +[dependency-groups] +dev = ["pytest>=8"] [build-system] requires = ["hatchling"] @@ -17,3 +21,7 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["browser_cli"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +log_level = "INFO" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4425c72 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,30 @@ +""" +Shared pytest fixtures for browser-cli integration tests. + +Tests that require a live browser connection use the `browser` fixture. +They are automatically skipped if the native host socket is not reachable. +""" +import pytest +from browser_cli.client import send_command, BrowserNotConnected + + +@pytest.fixture(scope="session") +def browser(): + """Returns a connected send_command callable, or skips the test.""" + try: + send_command("tabs.list") + except BrowserNotConnected: + pytest.skip("Browser not connected — start Brave/Chrome with the extension loaded") + return send_command + + +@pytest.fixture(scope="session") +def http_tab(browser): + """Ensures at least one http/https tab is open; returns its tab info.""" + tabs = browser("tabs.list") + http_tab = next( + (t for t in tabs if t.get("url", "").startswith("http")), None + ) + if http_tab is None: + pytest.skip("No http/https tab open — open a web page first") + return http_tab diff --git a/tests/test_dom.py b/tests/test_dom.py new file mode 100644 index 0000000..83d586f --- /dev/null +++ b/tests/test_dom.py @@ -0,0 +1,62 @@ +"""Tests for dom.* commands (require an http/https active tab).""" +import pytest +from browser_cli.client import send_command + + +def test_dom_query_body(browser, http_tab): + browser("tabs.active", {"tabId": http_tab["id"]}) + elements = browser("dom.query", {"selector": "body"}) + assert isinstance(elements, list) + assert len(elements) == 1 + assert elements[0]["tag"] == "body" + + +def test_dom_query_multiple(browser, http_tab): + browser("tabs.active", {"tabId": http_tab["id"]}) + # Every HTML page has at least one element + elements = browser("dom.query", {"selector": "*"}) + assert isinstance(elements, list) + assert len(elements) > 1 + + +def test_dom_query_no_match(browser, http_tab): + browser("tabs.active", {"tabId": http_tab["id"]}) + elements = browser("dom.query", {"selector": "#zzz_no_such_element_zzz"}) + assert isinstance(elements, list) + assert len(elements) == 0 + + +def test_dom_exists_true(browser, http_tab): + browser("tabs.active", {"tabId": http_tab["id"]}) + result = browser("dom.exists", {"selector": "html"}) + assert result is True + + +def test_dom_exists_false(browser, http_tab): + browser("tabs.active", {"tabId": http_tab["id"]}) + result = browser("dom.exists", {"selector": "#zzz_no_such_element_zzz"}) + assert result is False + + +def test_dom_text_body(browser, http_tab): + browser("tabs.active", {"tabId": http_tab["id"]}) + texts = browser("dom.text", {"selector": "body"}) + assert isinstance(texts, list) + assert len(texts) > 0 + assert isinstance(texts[0], str) + assert len(texts[0]) > 0 + + +def test_dom_attr_returns_list(browser, http_tab): + browser("tabs.active", {"tabId": http_tab["id"]}) + # Get href of all anchor tags — page may or may not have any + hrefs = browser("dom.attr", {"selector": "a", "attr": "href"}) + assert isinstance(hrefs, list) + + +def test_dom_attr_html_lang(browser, http_tab): + browser("tabs.active", {"tabId": http_tab["id"]}) + langs = browser("dom.attr", {"selector": "html", "attr": "lang"}) + assert isinstance(langs, list) + # html element exists so we get exactly one entry (may be empty string if no lang attr) + assert len(langs) <= 1 diff --git a/tests/test_extract.py b/tests/test_extract.py new file mode 100644 index 0000000..b1e26ee --- /dev/null +++ b/tests/test_extract.py @@ -0,0 +1,49 @@ +"""Tests for extract.* commands (require an http/https active tab).""" +import pytest +from browser_cli.client import send_command + + +def test_extract_links(browser, http_tab): + browser("tabs.active", {"tabId": http_tab["id"]}) + links = browser("extract.links") + assert isinstance(links, list) + for lnk in links: + assert "href" in lnk + assert "text" in lnk + + +def test_extract_images(browser, http_tab): + browser("tabs.active", {"tabId": http_tab["id"]}) + images = browser("extract.images") + assert isinstance(images, list) + for img in images: + assert "src" in img + assert img["src"] != "" + + +def test_extract_text(browser, http_tab): + browser("tabs.active", {"tabId": http_tab["id"]}) + text = browser("extract.text") + assert isinstance(text, str) + assert len(text) > 0 + + +def test_extract_html(browser, http_tab): + browser("tabs.active", {"tabId": http_tab["id"]}) + html = browser("extract.html") + assert isinstance(html, str) + assert "<" in html + + +def test_dom_exists(browser, http_tab): + browser("tabs.active", {"tabId": http_tab["id"]}) + result = browser("dom.exists", {"selector": "body"}) + assert result is True + + +def test_dom_query(browser, http_tab): + browser("tabs.active", {"tabId": http_tab["id"]}) + elements = browser("dom.query", {"selector": "body"}) + assert isinstance(elements, list) + assert len(elements) > 0 + assert elements[0].get("tag") == "body" diff --git a/tests/test_groups.py b/tests/test_groups.py new file mode 100644 index 0000000..7a3acd6 --- /dev/null +++ b/tests/test_groups.py @@ -0,0 +1,133 @@ +"""Tests for group.* commands.""" +import pytest +from browser_cli.client import send_command + + +def test_group_list(browser): + groups = browser("group.list") + assert isinstance(groups, list) + + +def test_group_count_is_int(browser): + count = browser("group.count") + assert isinstance(count, int) + assert count >= 0 + + +def test_group_count_matches_list(browser): + groups = browser("group.list") + count = browser("group.count") + assert count == len(groups) + + +def test_group_create_and_close(browser): + result = browser("group.open", {"name": "__test_group__"}) + assert isinstance(result, dict) + gid = result["id"] + + # Verify it appears in the list + groups = browser("group.list") + assert any(g["id"] == gid for g in groups) + + # Get tabs inside so we can clean them up after ungrouping + tabs_in_group = browser("group.tabs", {"groupId": gid}) + + # Close (ungroup) the group + browser("group.close", {"groupId": gid}) + + # Group should be gone + groups_after = browser("group.list") + assert gid not in [g["id"] for g in groups_after] + + # Clean up the ungrouped tabs + for t in tabs_in_group: + try: + browser("tabs.close", {"tabId": t["id"]}) + except Exception: + pass + + +def test_group_create_has_title(browser): + result = browser("group.open", {"name": "__titled_group__"}) + gid = result["id"] + tabs_in_group = browser("group.tabs", {"groupId": gid}) + + try: + groups = browser("group.list") + match = next((g for g in groups if g["id"] == gid), None) + assert match is not None + assert match.get("title") == "__titled_group__" + assert match.get("tabCount", 0) >= 1 + finally: + browser("group.close", {"groupId": gid}) + for t in tabs_in_group: + try: + browser("tabs.close", {"tabId": t["id"]}) + except Exception: + pass + + +def test_group_query(browser): + result = browser("group.open", {"name": "__query_test__"}) + gid = result["id"] + tabs_in_group = browser("group.tabs", {"groupId": gid}) + + try: + found = browser("group.query", {"search": "__query_test__"}) + assert isinstance(found, list) + assert any(g["id"] == gid for g in found) + finally: + browser("group.close", {"groupId": gid}) + for t in tabs_in_group: + try: + browser("tabs.close", {"tabId": t["id"]}) + except Exception: + pass + + +def test_group_query_no_match(browser): + result = browser("group.query", {"search": "zzz_no_such_group_zzz"}) + assert isinstance(result, list) + assert len(result) == 0 + + +def test_group_add_tab(browser): + grp = browser("group.open", {"name": "__add_tab_test__"}) + gid = grp["id"] + initial_tabs = browser("group.tabs", {"groupId": gid}) + + try: + tab_result = browser("group.add_tab", {"group": str(gid), "url": "https://example.com"}) + assert isinstance(tab_result, dict) + new_tab_id = tab_result["tabId"] + + tabs = browser("group.tabs", {"groupId": gid}) + assert any(t["id"] == new_tab_id for t in tabs) + finally: + all_tabs = browser("group.tabs", {"groupId": gid}) + browser("group.close", {"groupId": gid}) + for t in all_tabs + initial_tabs: + try: + browser("tabs.close", {"tabId": t["id"]}) + except Exception: + pass + + +def test_group_tabs_returns_list(browser): + grp = browser("group.open", {"name": "__tabs_list_test__"}) + gid = grp["id"] + tabs_in_group = browser("group.tabs", {"groupId": gid}) + + try: + assert isinstance(tabs_in_group, list) + assert len(tabs_in_group) >= 1 + for t in tabs_in_group: + assert "id" in t + assert "url" in t + finally: + browser("group.close", {"groupId": gid}) + for t in tabs_in_group: + try: + browser("tabs.close", {"tabId": t["id"]}) + except Exception: + pass diff --git a/tests/test_nav.py b/tests/test_nav.py new file mode 100644 index 0000000..db37caa --- /dev/null +++ b/tests/test_nav.py @@ -0,0 +1,83 @@ +"""Tests for navigate.* commands.""" +import pytest +from browser_cli.client import send_command + + +def test_nav_open_and_close(browser): + """Open a tab, verify it appears in the list, then close it.""" + result = browser("navigate.open", {"url": "https://example.com", "background": True}) + assert "id" in result + new_id = result["id"] + + tabs = browser("tabs.list") + ids = [t["id"] for t in tabs] + assert new_id in ids + + # Clean up + browser("tabs.close", {"tabId": new_id}) + tabs_after = browser("tabs.list") + assert new_id not in [t["id"] for t in tabs_after] + + +def test_nav_focus_by_pattern(browser): + # Open a known URL in background first + result = browser("navigate.open", {"url": "https://example.com", "background": True}) + tab_id = result["id"] + + focus_result = browser("navigate.focus", {"pattern": "example.com"}) + assert focus_result is not None + assert "example.com" in (focus_result.get("url") or "") + + browser("tabs.close", {"tabId": tab_id}) + + +def test_nav_focus_by_tab_id(browser): + result = browser("navigate.open", {"url": "https://example.com", "background": True}) + tab_id = result["id"] + + focus_result = browser("navigate.focus", {"pattern": str(tab_id)}) + assert focus_result is not None + assert focus_result.get("id") == tab_id + + browser("tabs.close", {"tabId": tab_id}) + + +def test_nav_reload(browser): + result = browser("navigate.open", {"url": "https://example.com", "background": True}) + tab_id = result["id"] + + try: + reload_result = browser("navigate.reload", {"tabId": tab_id}) + assert reload_result is None or isinstance(reload_result, dict) + finally: + browser("tabs.close", {"tabId": tab_id}) + + +def test_nav_hard_reload(browser): + result = browser("navigate.open", {"url": "https://example.com", "background": True}) + tab_id = result["id"] + + try: + result = browser("navigate.hard_reload", {"tabId": tab_id}) + assert result is None or isinstance(result, dict) + finally: + try: + browser("tabs.close", {"tabId": tab_id}) + except Exception: + pass # tab ID may change after hard reload + + +def test_nav_open_in_background(browser): + """Tab opened with background=True should not be the active tab.""" + active_before = next( + t for t in browser("tabs.list") if t.get("active") + ) + result = browser("navigate.open", {"url": "https://example.com", "background": True}) + new_id = result["id"] + + try: + tabs = browser("tabs.list") + new_tab = next(t for t in tabs if t["id"] == new_id) + assert not new_tab.get("active"), "background tab should not be active" + finally: + browser("tabs.close", {"tabId": new_id}) diff --git a/tests/test_session.py b/tests/test_session.py new file mode 100644 index 0000000..88cc243 --- /dev/null +++ b/tests/test_session.py @@ -0,0 +1,47 @@ +"""Tests for session.* commands.""" +import pytest +from browser_cli.client import send_command + +SESSION_NAME = "_pytest_session" + + +def test_session_save_and_list(browser): + browser("session.save", {"name": SESSION_NAME}) + + sessions = browser("session.list") + assert isinstance(sessions, list) + names = [s["name"] for s in sessions] + assert SESSION_NAME in names + + +def test_session_list_has_tab_count(browser): + sessions = browser("session.list") + for s in sessions: + assert "name" in s + assert "tabs" in s + assert isinstance(s["tabs"], int) + + +def test_session_diff(browser): + browser("session.save", {"name": SESSION_NAME + "_a"}) + browser("session.save", {"name": SESSION_NAME + "_b"}) + diff = browser("session.diff", {"nameA": SESSION_NAME + "_a", "nameB": SESSION_NAME + "_b"}) + assert "added" in diff + assert "removed" in diff + + +def test_session_remove(browser): + browser("session.save", {"name": SESSION_NAME + "_remove"}) + browser("session.remove", {"name": SESSION_NAME + "_remove"}) + sessions = browser("session.list") + names = [s["name"] for s in sessions] + assert SESSION_NAME + "_remove" not in names + + +def teardown_module(module): + """Clean up test sessions after all tests run.""" + for name in [SESSION_NAME, SESSION_NAME + "_a", SESSION_NAME + "_b"]: + try: + send_command("session.remove", {"name": name}) + except Exception: + pass diff --git a/tests/test_tabs.py b/tests/test_tabs.py new file mode 100644 index 0000000..9264c27 --- /dev/null +++ b/tests/test_tabs.py @@ -0,0 +1,109 @@ +"""Tests for tabs.* commands.""" +import pytest +from browser_cli.client import send_command + + +def test_tabs_list(browser): + tabs = browser("tabs.list") + assert isinstance(tabs, list) + assert len(tabs) > 0 + first = tabs[0] + assert "id" in first + assert "windowId" in first + assert "url" in first + assert "title" in first + + +def test_tabs_count(browser): + count = browser("tabs.count", {}) + tabs = browser("tabs.list") + assert count == len(tabs) + + +def test_tabs_count_with_pattern(browser): + count = browser("tabs.count", {"pattern": "http"}) + assert isinstance(count, int) + assert count >= 0 + + +def test_tabs_filter(browser): + result = browser("tabs.filter", {"pattern": "http"}) + assert isinstance(result, list) + for tab in result: + assert "http" in tab.get("url", "") + + +def test_tabs_query(browser): + result = browser("tabs.query", {"search": "a"}) + assert isinstance(result, list) + + +def test_tabs_active_exists(browser): + tabs = browser("tabs.list") + active = [t for t in tabs if t.get("active")] + assert len(active) >= 1, "Expected at least one active tab" + + +def test_tabs_html(browser, http_tab): + html = browser("tabs.html", {"tabId": http_tab["id"]}) + assert isinstance(html, str) + assert len(html) > 0 + assert "= 0 + # At least one of the two duplicates should be gone + remaining = browser("tabs.list") + remaining_ids = {t["id"] for t in remaining} + assert not (id1 in remaining_ids and id2 in remaining_ids), \ + "Both duplicate tabs still open after dedupe" + finally: + for tid in (id1, id2): + try: + browser("tabs.close", {"tabId": tid}) + except Exception: + pass + + +def test_tabs_sort(browser): + result = browser("tabs.sort", {"by": "domain"}) + # No error and at least returns something (None or dict) + assert result is None or isinstance(result, dict) + + +def test_tabs_move_forward(browser): + r1 = browser("navigate.open", {"url": "https://example.com", "background": True}) + r2 = browser("navigate.open", {"url": "https://example.com", "background": True}) + id1, id2 = r1["id"], r2["id"] + + try: + # Move id1 forward — just verify no error is raised + browser("tabs.move", {"tabId": id1, "forward": True}) + finally: + browser("tabs.close", {"tabId": id1}) + browser("tabs.close", {"tabId": id2}) + + +def test_tabs_merge_windows_no_crash(browser): + result = browser("tabs.merge_windows") + assert isinstance(result, dict) + assert "moved" in result diff --git a/tests/test_windows.py b/tests/test_windows.py new file mode 100644 index 0000000..73a679f --- /dev/null +++ b/tests/test_windows.py @@ -0,0 +1,61 @@ +"""Tests for windows.* commands.""" +import pytest +from browser_cli.client import send_command + + +def test_windows_list(browser): + windows = browser("windows.list") + assert isinstance(windows, list) + assert len(windows) >= 1 + + +def test_windows_each_has_required_fields(browser): + windows = browser("windows.list") + for w in windows: + assert "id" in w + assert isinstance(w["id"], int) + assert "tabCount" in w + assert isinstance(w["tabCount"], int) + + +def test_windows_has_state(browser): + windows = browser("windows.list") + # Every window should report a state (normal, minimized, maximized, fullscreen) + for w in windows: + assert "state" in w + + +def test_windows_open_and_close(browser): + result = browser("windows.open", {}) + assert isinstance(result, dict) + new_id = result["id"] + + windows = browser("windows.list") + assert any(w["id"] == new_id for w in windows) + + browser("windows.close", {"windowId": new_id}) + + windows_after = browser("windows.list") + assert new_id not in [w["id"] for w in windows_after] + + +def test_windows_tab_count_positive(browser): + windows = browser("windows.list") + for w in windows: + assert w["tabCount"] >= 0 + + +def test_windows_rename(browser): + result = browser("windows.open", {}) + new_id = result["id"] + + try: + rename_result = browser("windows.rename", {"windowId": new_id, "name": "__test_alias__"}) + assert isinstance(rename_result, dict) + + windows = browser("windows.list") + match = next((w for w in windows if w["id"] == new_id), None) + assert match is not None + assert match.get("alias") == "__test_alias__" + finally: + browser("windows.close", {"windowId": new_id}) diff --git a/uv.lock b/uv.lock index afd4892..2e28b91 100644 --- a/uv.lock +++ b/uv.lock @@ -4,19 +4,27 @@ requires-python = ">=3.10" [[package]] name = "browser-cli" -version = "0.1.0" +version = "0.3.1" source = { editable = "." } dependencies = [ { name = "click" }, { name = "rich" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + [package.metadata] requires-dist = [ { name = "click", specifier = ">=8" }, { name = "rich", specifier = ">=13" }, ] +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8" }] + [[package]] name = "click" version = "8.3.2" @@ -38,6 +46,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -59,6 +88,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -68,6 +115,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + [[package]] name = "rich" version = "14.3.3" @@ -80,3 +145,66 @@ sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f4 wheels = [ { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, ] + +[[package]] +name = "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, 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]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +]