add moveing of tabs and groups, multi browser support, auto complite into terminal, extract html and adding testing
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|||||||
+99
-13
@@ -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."""
|
||||||
|
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:
|
try:
|
||||||
clients = send_command("clients.list")
|
profiles = _json.loads(REGISTRY_PATH.read_text())
|
||||||
except BrowserNotConnected as e:
|
except Exception:
|
||||||
console.print(f"[red]Error:[/red] {e}")
|
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
@@ -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)
|
||||||
|
|||||||
@@ -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 "")
|
||||||
|
|||||||
@@ -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]")
|
||||||
|
|||||||
@@ -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)}")
|
||||||
|
|||||||
@@ -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
@@ -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__":
|
||||||
|
|||||||
+100
-9
@@ -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);
|
||||||
@@ -121,6 +135,7 @@ async function dispatch(command, 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 }) {
|
||||||
|
// 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({});
|
const all = await chrome.tabs.query({});
|
||||||
const match = all.find(t => t.url && t.url.includes(pattern));
|
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 }) {
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||||
const results = await chrome.scripting.executeScript({
|
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 },
|
target: { tabId: tab.id },
|
||||||
func: () => document.documentElement.outerHTML,
|
func: () => document.documentElement.outerHTML,
|
||||||
});
|
});
|
||||||
return results[0]?.result || "";
|
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
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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})
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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})
|
||||||
@@ -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" },
|
||||||
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user