add moveing of tabs and groups, multi browser support, auto complite into terminal, extract html and adding testing

This commit is contained in:
2026-04-09 01:41:01 +02:00
parent 0cb2f1cb3f
commit ab4ba97886
19 changed files with 1069 additions and 57 deletions
+1
View File
@@ -1,3 +1,4 @@
__pycache__/ __pycache__/
.vscode/
*.pyc *.pyc
+100 -14
View File
@@ -59,22 +59,58 @@ main.add_command(session_group)
@main.command("clients") @main.command("clients")
def cmd_clients(): def cmd_clients():
"""Show connected browser clients.""" """Show connected browser clients."""
try: import json as _json
clients = send_command("clients.list") from browser_cli.client import REGISTRY_PATH, DEFAULT_SOCKET
except BrowserNotConnected as e:
console.print(f"[red]Error:[/red] {e}") # 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) sys.exit(1)
from rich.table import Table from rich.table import Table
table = Table(show_header=True, header_style="bold cyan") table = Table(show_header=True, header_style="bold cyan")
table.add_column("Profile")
table.add_column("Browser") table.add_column("Browser")
table.add_column("Version") table.add_column("Version")
table.add_column("Platform") table.add_column("Platform")
for c in (clients or []): for c in all_clients:
table.add_row(c.get("name", ""), c.get("version", ""), c.get("platform", "")) table.add_row(c.get("profile", "default"), c.get("name", ""), c.get("version", ""), c.get("platform", ""))
console.print(table) 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 ──────────────────────────────────────────────────────────────────── # ── install ────────────────────────────────────────────────────────────────────
@main.command("install") @main.command("install")
@@ -82,17 +118,19 @@ def cmd_clients():
def cmd_install(browser): def cmd_install(browser):
"""Register the native messaging host and print extension load instructions.""" """Register the native messaging host and print extension load instructions."""
# Find the native_host.py path # Find the venv entry point for the native host (stable regardless of project location)
native_host_script = Path(__file__).parent / "native_host.py" venv_script = Path(sys.executable).parent / "browser-cli-native-host"
if not native_host_script.exists(): if not venv_script.exists():
console.print(f"[red]Cannot find native_host.py at {native_host_script}[/red]") 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) sys.exit(1)
# Build a wrapper shell script so it's executable by Chrome # Install wrapper to ~/.local/bin so the manifest path never changes
wrapper_path = Path(__file__).parent.parent / "browser-cli-native-host" local_bin = Path.home() / ".local" / "bin"
python_exe = sys.executable local_bin.mkdir(parents=True, exist_ok=True)
wrapper_path = local_bin / "browser-cli-native-host"
wrapper_content = f"""#!/bin/sh wrapper_content = f"""#!/bin/sh
exec "{python_exe}" "{native_host_script}" "$@" exec "{venv_script}" "$@"
""" """
wrapper_path.write_text(wrapper_content) wrapper_path.write_text(wrapper_content)
wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) 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]") 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__": if __name__ == "__main__":
main() main()
+44 -5
View File
@@ -1,22 +1,59 @@
""" """
Unix socket client — sends commands to the native host relay socket. Unix socket client — sends commands to the native host relay socket.
Used by both the CLI and the public Python API. 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 json
import os
import socket import socket
import struct import struct
import uuid import uuid
from pathlib import Path
from typing import Any 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): class BrowserNotConnected(Exception):
"""Raised when the native host socket is not available.""" """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.""" """Send a command to the browser and return the response data."""
sock_path = _resolve_socket(profile)
msg = { msg = {
"id": str(uuid.uuid4()), "id": str(uuid.uuid4()),
"command": command, "command": command,
@@ -27,16 +64,18 @@ def send_command(command: str, args: dict | None = None) -> Any:
try: try:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock: with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
sock.connect(SOCKET_PATH) sock.connect(sock_path)
sock.sendall(framed) sock.sendall(framed)
response = _recv_all(sock) response = _recv_all(sock)
except (FileNotFoundError, ConnectionRefusedError, OSError): except (FileNotFoundError, ConnectionRefusedError, OSError):
profile_hint = f" (profile: {profile})" if profile else ""
raise BrowserNotConnected( raise BrowserNotConnected(
"Cannot connect to browser.\n" f"Cannot connect to browser{profile_hint}.\n"
"Make sure:\n" "Make sure:\n"
" 1. The browser-cli extension is installed and enabled\n" " 1. The browser-cli extension is installed and enabled\n"
" 2. The native host is registered: uv run browser-cli install chrome\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=<name> to select a specific profile"
) )
result = json.loads(response) result = json.loads(response)
+7
View File
@@ -66,3 +66,10 @@ def extract_json(selector):
"""Parse and pretty-print JSON content inside SELECTOR.""" """Parse and pretty-print JSON content inside SELECTOR."""
data = _handle("extract.json", {"selector": selector}) data = _handle("extract.json", {"selector": selector})
console.print_json(json.dumps(data)) 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 "")
+17
View File
@@ -100,3 +100,20 @@ def group_add_tab(group, url):
tab_id = result.get("tabId") if isinstance(result, dict) else result tab_id = result.get("tabId") if isinstance(result, dict) else result
label = url or "new tab" label = url or "new tab"
console.print(f"[green]Opened {label}[/green] in group '{group}' (tab id: {tab_id})") 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]")
+1 -1
View File
@@ -72,7 +72,7 @@ def cmd_forward(tab_id):
@nav_group.command("focus") @nav_group.command("focus")
@click.argument("pattern") @click.argument("pattern")
def cmd_focus(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}) result = _handle("navigate.focus", {"pattern": pattern})
if result: if result:
console.print(f"[green]Focused:[/green] {result.get('url', result)}") console.print(f"[green]Focused:[/green] {result.get('url', result)}")
+9 -4
View File
@@ -64,12 +64,17 @@ def tabs_close(tab_id, inactive, duplicates):
@tabs_group.command("move") @tabs_group.command("move")
@click.argument("tab_id", type=int) @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("--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("--window", "window_id", type=int, default=None, help="Move to window ID")
@click.option("--index", type=int, default=None, help="Position index in target") @click.option("--index", type=int, default=None, help="Absolute position index in target")
def tabs_move(tab_id, group_id, window_id, index): def tabs_move(tab_id, forward, backward, group_id, window_id, index):
"""Move a tab to a different window or group.""" """Move a tab. Use --forward/--backward for relative movement."""
_handle("tabs.move", {"tabId": tab_id, "groupId": group_id, "windowId": window_id, "index": index}) _handle("tabs.move", {
"tabId": tab_id, "forward": forward, "backward": backward,
"groupId": group_id, "windowId": window_id, "index": index,
})
console.print("[green]Tab moved[/green]") console.print("[green]Tab moved[/green]")
+71 -15
View File
@@ -5,6 +5,10 @@ Native Messaging Host for browser-cli.
Chrome launches this process when the extension calls connectNative(). Chrome launches this process when the extension calls connectNative().
It relays messages between the Chrome extension (via stdin/stdout using the It relays messages between the Chrome extension (via stdin/stdout using the
Native Messaging protocol) and the CLI (via a Unix domain socket). 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 json
import os import os
@@ -16,7 +20,10 @@ import threading
import uuid import uuid
from pathlib import Path 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: dict[str, queue.Queue] = {}
PENDING_LOCK = threading.Lock() PENDING_LOCK = threading.Lock()
@@ -40,16 +47,48 @@ def write_native_message(stream, msg: dict) -> None:
stream.flush() 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) --- # --- Thread A: read messages from extension (stdin) ---
def stdin_reader(): def stdin_reader(alias: str):
stdin = sys.stdin.buffer stdin = sys.stdin.buffer
while True: while True:
msg = read_native_message(stdin) msg = read_native_message(stdin)
if msg is None: if msg is None:
# Extension disconnected — clean up socket and exit # Extension disconnected — clean up and exit
_cleanup() _cleanup(alias)
os._exit(0) os._exit(0)
# Profile alias handshake
if msg.get("type") == "hello":
continue # already handled during startup
msg_id = msg.get("id") msg_id = msg.get("id")
if msg_id: if msg_id:
with PENDING_LOCK: with PENDING_LOCK:
@@ -60,13 +99,13 @@ def stdin_reader():
# --- Thread B: accept CLI socket connections --- # --- Thread B: accept CLI socket connections ---
def socket_server(): def socket_server(sock_path: str):
path = Path(SOCKET_PATH) path = Path(sock_path)
if path.exists(): if path.exists():
path.unlink() path.unlink()
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.bind(SOCKET_PATH) sock.bind(sock_path)
sock.listen(16) sock.listen(16)
while True: while True:
@@ -92,10 +131,8 @@ def handle_cli_connection(conn: socket.socket) -> None:
with PENDING_LOCK: with PENDING_LOCK:
PENDING[msg_id] = response_queue PENDING[msg_id] = response_queue
# Forward command to extension via stdout
write_native_message(sys.stdout.buffer, cmd) write_native_message(sys.stdout.buffer, cmd)
# Wait for extension's response (30 s timeout)
try: try:
result = response_queue.get(timeout=30) result = response_queue.get(timeout=30)
except queue.Empty: except queue.Empty:
@@ -139,20 +176,39 @@ def _recv_exact(conn: socket.socket, n: int) -> bytes | None:
return buf return buf
def _cleanup(): def _cleanup(alias: str):
try: try:
Path(SOCKET_PATH).unlink(missing_ok=True) Path(_socket_path_for(alias)).unlink(missing_ok=True)
except Exception: except Exception:
pass pass
_registry_remove(alias)
def main(): def main():
# Start socket server thread stdin = sys.stdin.buffer
t = threading.Thread(target=socket_server, daemon=True)
# 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() t.start()
# Read extension messages on main thread (blocks until extension disconnects) stdin_reader(alias)
stdin_reader()
if __name__ == "__main__": if __name__ == "__main__":
+107 -16
View File
@@ -11,7 +11,12 @@ let port = null;
// ── Connection management ───────────────────────────────────────────────────── // ── Connection management ─────────────────────────────────────────────────────
function connect() { async function getProfileAlias() {
const { profileAlias } = await chrome.storage.local.get("profileAlias");
return profileAlias || "default";
}
async function connect() {
if (port) return; if (port) return;
try { try {
port = chrome.runtime.connectNative(NATIVE_HOST); port = chrome.runtime.connectNative(NATIVE_HOST);
@@ -21,7 +26,10 @@ function connect() {
const err = chrome.runtime.lastError; const err = chrome.runtime.lastError;
if (err) console.warn("[browser-cli] Native host disconnected:", err.message); 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) { } catch (e) {
port = null; port = null;
console.error("[browser-cli] Failed to connect:", e); console.error("[browser-cli] Failed to connect:", e);
@@ -45,6 +53,8 @@ async function onMessage(msg) {
const { id, command, args } = msg; const { id, command, args } = msg;
if (!id || !command) return; if (!id || !command) return;
console.log("[browser-cli] ←", command, args);
let data, error; let data, error;
try { try {
data = await dispatch(command, args || {}); data = await dispatch(command, args || {});
@@ -53,8 +63,10 @@ async function onMessage(msg) {
} }
if (error !== undefined) { if (error !== undefined) {
console.log("[browser-cli] → ERROR", command, error);
port.postMessage({ id, success: false, error }); port.postMessage({ id, success: false, error });
} else { } else {
console.log("[browser-cli] →", command, data);
port.postMessage({ id, success: true, data }); port.postMessage({ id, success: true, data });
} }
} }
@@ -90,6 +102,7 @@ async function dispatch(command, args) {
case "group.close": return groupClose(args); case "group.close": return groupClose(args);
case "group.open": return groupOpen(args); case "group.open": return groupOpen(args);
case "group.add_tab": return groupAddTab(args); case "group.add_tab": return groupAddTab(args);
case "group.move": return groupMove(args);
// ── Windows ─────────────────────────────────────────────────────────── // ── Windows ───────────────────────────────────────────────────────────
case "windows.list": return windowsList(); case "windows.list": return windowsList();
@@ -110,6 +123,7 @@ async function dispatch(command, args) {
case "extract.images": return domOp("extractImages", args); case "extract.images": return domOp("extractImages", args);
case "extract.text": return domOp("extractText", args); case "extract.text": return domOp("extractText", args);
case "extract.json": return domOp("extractJson", args); case "extract.json": return domOp("extractJson", args);
case "extract.html": return tabsHtml({});
// ── Session ─────────────────────────────────────────────────────────── // ── Session ───────────────────────────────────────────────────────────
case "session.save": return sessionSave(args); case "session.save": return sessionSave(args);
@@ -120,7 +134,8 @@ async function dispatch(command, args) {
case "session.auto_save": return sessionAutoSave(args); case "session.auto_save": return sessionAutoSave(args);
// ── Misc ────────────────────────────────────────────────────────────── // ── Misc ──────────────────────────────────────────────────────────────
case "clients.list": return clientsList(); case "clients.list": return clientsList();
case "clients.rename_profile": return clientsRenameProfile(args);
default: default:
throw new Error(`Unknown command: ${command}`); throw new Error(`Unknown command: ${command}`);
@@ -170,12 +185,19 @@ async function navForward({ tabId }) {
} }
async function navFocus({ pattern }) { async function navFocus({ pattern }) {
const all = await chrome.tabs.query({}); // If pattern is a plain integer, treat it as a tab ID
const match = all.find(t => t.url && t.url.includes(pattern)); 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; if (!match) return null;
await chrome.windows.update(match.windowId, { focused: true }); await chrome.windows.update(match.windowId, { focused: true });
await chrome.tabs.update(match.id, { active: 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 ────────────────────────────────────────────────────────────────────── // ── Tabs ──────────────────────────────────────────────────────────────────────
@@ -221,11 +243,20 @@ async function tabsClose({ tabId, inactive, duplicates }) {
return { closed: toClose.length }; return { closed: toClose.length };
} }
async function tabsMove({ tabId, groupId, windowId, index }) { async function tabsMove({ tabId, groupId, windowId, index, forward, backward }) {
const moveProps = {}; const moveProps = {};
if (windowId != null) moveProps.windowId = windowId; 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); await chrome.tabs.move(tabId, moveProps);
if (groupId != null) { if (groupId != null) {
await chrome.tabs.group({ tabIds: [tabId], groupId }); await chrome.tabs.group({ tabIds: [tabId], groupId });
@@ -260,13 +291,41 @@ async function tabsQuery({ search }) {
).map(tabInfo); ).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 }) { async function tabsHtml({ tabId }) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); for (let i = 0; i < 3; i++) {
const results = await chrome.scripting.executeScript({ const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
target: { tabId: tab.id }, if (!isScriptableUrl(tab.url || tab.pendingUrl || "")) {
func: () => document.documentElement.outerHTML, throw new Error(`Cannot get HTML of ${tab.url || tab.pendingUrl} — navigate to a regular web page first`);
}); }
return results[0]?.result || ""; 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() { async function tabsDedupe() {
@@ -371,6 +430,31 @@ async function groupAddTab({ group, url }) {
return { tabId: tab.id, groupId }; 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 ─────────────────────────────────────────────────────────────────── // ── Windows ───────────────────────────────────────────────────────────────────
async function windowsList() { async function windowsList() {
@@ -420,7 +504,7 @@ async function domOp(funcName, args) {
if (!isScriptableUrl(tab.url)) { if (!isScriptableUrl(tab.url)) {
throw new Error(`Cannot run DOM commands on ${tab.url} — navigate to a regular web page first`); 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 }, target: { tabId: tab.id },
func: contentDispatch, func: contentDispatch,
args: [funcName, args], args: [funcName, args],
@@ -568,14 +652,21 @@ async function autoSaveHandler() {
async function clientsList() { async function clientsList() {
const manifest = chrome.runtime.getManifest(); const manifest = chrome.runtime.getManifest();
const alias = await getProfileAlias();
return [{ return [{
name: "Chrome", name: "Chrome",
version: navigator.userAgent.match(/Chrome\/([\d.]+)/)?.[1] || "unknown", version: navigator.userAgent.match(/Chrome\/([\d.]+)/)?.[1] || "unknown",
platform: navigator.platform, platform: navigator.platform,
extensionVersion: manifest.version, extensionVersion: manifest.version,
profile: alias,
}]; }];
} }
async function clientsRenameProfile({ alias }) {
await chrome.storage.local.set({ profileAlias: alias });
return { alias };
}
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
async function getActiveTab() { async function getActiveTab() {
+9 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "browser-cli" name = "browser-cli"
version = "0.2.1" version = "0.3.1"
description = "Control your real running browser from the terminal via a Chrome extension" description = "Control your real running browser from the terminal via a Chrome extension"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
@@ -10,6 +10,10 @@ dependencies = [
[project.scripts] [project.scripts]
browser-cli = "browser_cli.cli:main" browser-cli = "browser_cli.cli:main"
browser-cli-native-host = "browser_cli.native_host:main"
[dependency-groups]
dev = ["pytest>=8"]
[build-system] [build-system]
requires = ["hatchling"] requires = ["hatchling"]
@@ -17,3 +21,7 @@ build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["browser_cli"] packages = ["browser_cli"]
[tool.pytest.ini_options]
testpaths = ["tests"]
log_level = "INFO"
+30
View File
@@ -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
+62
View File
@@ -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
+49
View File
@@ -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"
+133
View File
@@ -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
+83
View File
@@ -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})
+47
View File
@@ -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
+109
View File
@@ -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 "<html" in html.lower() or "<!doctype" in html.lower()
def test_tabs_close_by_id(browser):
result = browser("navigate.open", {"url": "https://example.com", "background": True})
tab_id = result["id"]
browser("tabs.close", {"tabId": tab_id})
tabs = browser("tabs.list")
assert tab_id not in [t["id"] for t in tabs]
def test_tabs_dedupe(browser):
# Open the same URL twice
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:
result = browser("tabs.dedupe")
assert isinstance(result, dict)
assert result.get("closed", 0) >= 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
+61
View File
@@ -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})
Generated
+129 -1
View File
@@ -4,19 +4,27 @@ requires-python = ">=3.10"
[[package]] [[package]]
name = "browser-cli" name = "browser-cli"
version = "0.1.0" version = "0.3.1"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },
{ name = "rich" }, { name = "rich" },
] ]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "click", specifier = ">=8" }, { name = "click", specifier = ">=8" },
{ name = "rich", specifier = ">=13" }, { name = "rich", specifier = ">=13" },
] ]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=8" }]
[[package]] [[package]]
name = "click" name = "click"
version = "8.3.2" 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" }, { 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]] [[package]]
name = "markdown-it-py" name = "markdown-it-py"
version = "4.0.0" 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" }, { 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]] [[package]]
name = "pygments" name = "pygments"
version = "2.20.0" 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" }, { 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]] [[package]]
name = "rich" name = "rich"
version = "14.3.3" version = "14.3.3"
@@ -80,3 +145,66 @@ sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f4
wheels = [ 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" }, { 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" },
]