feat: add performance controls for large browser ops

- Add throttled large-operation handling for tab, group, and session commands.
- Introduce performance profiles, audible-tab aware gentle mode, and job progress tracking.
- Support background session restores with status/cancel commands and lazy placeholders.
- Expose new perf and extension CLI groups plus matching Python SDK methods.
- Preserve pinned tabs during session snapshots and debounce auto-save updates.
- Bump browser-cli and extension versions to 0.10.0 and add pytest-cov to dev deps.
- Add coverage for performance controls, background jobs, lazy restores, and tab metadata.
This commit is contained in:
2026-05-20 22:13:57 +02:00
parent e1e4adbb25
commit 545abeb515
18 changed files with 1054 additions and 148 deletions
+55 -2
View File
@@ -758,8 +758,61 @@ class BrowserCLI:
def session_save(self, name: str) -> None: def session_save(self, name: str) -> None:
self._cmd("session.save", {"name": name}) self._cmd("session.save", {"name": name})
def session_load(self, name: str) -> None: def session_load(
self._cmd("session.load", {"name": name}) self,
name: str,
*,
gentle_mode: str = "auto",
discard_background_tabs: bool = False,
lazy: bool = False,
eager_tabs: int = 10,
) -> None:
self._cmd("session.load", {
"name": name,
"gentleMode": gentle_mode,
"discardBackgroundTabs": discard_background_tabs,
"lazy": lazy,
"eagerTabs": eager_tabs,
})
def session_load_background(
self,
name: str,
*,
gentle_mode: str = "auto",
discard_background_tabs: bool = False,
lazy: bool = False,
eager_tabs: int = 10,
) -> dict:
return self._cmd("session.load", {
"name": name,
"gentleMode": gentle_mode,
"discardBackgroundTabs": discard_background_tabs,
"lazy": lazy,
"eagerTabs": eager_tabs,
"__background": True,
}) or {}
def job_status(self, job_id: str) -> dict:
return self._cmd("jobs.status", {"jobId": job_id}) or {}
def job_cancel(self, job_id: str) -> dict:
return self._cmd("jobs.cancel", {"jobId": job_id}) or {}
def reload_extension(self) -> None:
"""Reload the browser-cli extension service worker.
Schedules a ``chrome.runtime.reload()`` inside the extension and returns
immediately. The extension restarts ~200 ms later and reconnects via the
keepalive alarm within ~25 seconds.
"""
self._cmd("extension.reload", {})
def perf_status(self) -> dict:
return self._cmd("perf.status", {}) or {}
def set_performance_profile(self, profile: str) -> dict:
return self._cmd("perf.set_profile", {"profile": profile}) or {}
def session_diff(self, name_a: str, name_b: str) -> dict: def session_diff(self, name_a: str, name_b: str) -> dict:
return self._cmd("session.diff", {"nameA": name_a, "nameB": name_b}) or {} return self._cmd("session.diff", {"nameA": name_a, "nameB": name_b}) or {}
+4
View File
@@ -23,6 +23,8 @@ from browser_cli.commands.search import search_group
from browser_cli.commands.page import page_group from browser_cli.commands.page import page_group
from browser_cli.commands.storage import storage_group from browser_cli.commands.storage import storage_group
from browser_cli.commands.cookies import cookies_group from browser_cli.commands.cookies import cookies_group
from browser_cli.commands.perf import perf_group
from browser_cli.commands.extension import extension_group
from browser_cli.commands.serve import cmd_serve from browser_cli.commands.serve import cmd_serve
from browser_cli.client import ( from browser_cli.client import (
send_command, send_command,
@@ -375,6 +377,8 @@ main.add_command(search_group)
main.add_command(page_group) main.add_command(page_group)
main.add_command(storage_group) main.add_command(storage_group)
main.add_command(cookies_group) main.add_command(cookies_group)
main.add_command(perf_group)
main.add_command(extension_group)
main.add_command(cmd_serve) main.add_command(cmd_serve)
+21
View File
@@ -0,0 +1,21 @@
import time
import click
from rich.console import Console
from browser_cli.commands import _handle
console = Console()
@click.group("extension")
def extension_group():
"""Manage the browser-cli browser extension."""
@extension_group.command("reload")
def extension_reload():
"""Reload the browser-cli extension service worker.
Useful after updating background.js without restarting the browser.
The command returns immediately; the extension restarts ~200 ms later.
Re-connects automatically via the keepalive alarm within ~25 seconds.
"""
_handle("extension.reload")
console.print("[green]Extension reloading…[/green] reconnects automatically")
+3 -2
View File
@@ -103,9 +103,10 @@ def group_query(search):
@group_group.command("close") @group_group.command("close")
@click.argument("group_id", type=int) @click.argument("group_id", type=int)
def group_close(group_id): @click.option("--gentle-mode", type=click.Choice(["auto", "normal", "gentle", "ultra"]), default="auto", show_default=True, help="Throttle mode for large group operations.")
def group_close(group_id, gentle_mode):
"""Close (ungroup and optionally close) a tab group.""" """Close (ungroup and optionally close) a tab group."""
_handle("group.close", {"groupId": group_id}) _handle("group.close", {"groupId": group_id, "gentleMode": gentle_mode})
console.print(f"[green]Group {group_id} closed[/green]") console.print(f"[green]Group {group_id} closed[/green]")
+45
View File
@@ -0,0 +1,45 @@
import click
from rich.console import Console
from rich.table import Table
from browser_cli.commands import _handle
console = Console()
@click.group("perf")
def perf_group():
"""Inspect and tune browser-cli performance behavior."""
@perf_group.command("status")
def perf_status():
"""Show performance profile, throttle and running jobs."""
result = _handle("perf.status") or {}
console.print(f"Profile: [bold]{result.get('performanceProfile', 'auto')}[/bold]")
console.print(f"Audible tabs: {'yes' if result.get('audible') else 'no'}")
throttle = result.get("throttle") or {}
console.print(f"Throttle: batch={throttle.get('batchSize')} pause={throttle.get('pauseMs')}ms mode={throttle.get('mode')}")
jobs = result.get("jobs") or []
if not jobs:
console.print("[yellow]No running jobs[/yellow]")
return
table = Table(show_header=True, header_style="bold cyan")
table.add_column("Job")
table.add_column("Command")
table.add_column("Status")
table.add_column("Progress", justify="right")
table.add_column("Phase")
for job in jobs:
total = job.get("total")
current = job.get("current") or 0
percent = job.get("percent") or 0
progress = f"{current}/{total} ({percent}%)" if total else f"{percent}%"
table.add_row(job.get("id", ""), job.get("command", ""), job.get("status", ""), progress, job.get("phase", ""))
console.print(table)
@perf_group.command("profile")
@click.argument("profile", type=click.Choice(["auto", "normal", "gentle", "ultra"]))
def perf_profile(profile):
"""Set global performance profile."""
result = _handle("perf.set_profile", {"profile": profile}) or {}
console.print(f"[green]Performance profile set to {result.get('performanceProfile', profile)}[/green]")
+35 -8
View File
@@ -4,12 +4,10 @@ from rich.console import Console
console = Console() console = Console()
@click.group("session") @click.group("session")
def session_group(): def session_group():
"""Save and restore browser sessions.""" """Save and restore browser sessions."""
@session_group.command("save") @session_group.command("save")
@click.argument("name") @click.argument("name")
def session_save(name): def session_save(name):
@@ -18,16 +16,29 @@ def session_save(name):
count = result.get("tabs", 0) if isinstance(result, dict) else 0 count = result.get("tabs", 0) if isinstance(result, dict) else 0
console.print(f"[green]Session '{name}' saved[/green] ({count} tabs)") console.print(f"[green]Session '{name}' saved[/green] ({count} tabs)")
@session_group.command("load") @session_group.command("load")
@click.argument("name") @click.argument("name")
def session_load(name): @click.option("--gentle-mode", type=click.Choice(["auto", "normal", "gentle", "ultra"]), default="auto", show_default=True, help="Throttle mode for large restores.")
@click.option("--discard-background-tabs", is_flag=True, help="Discard restored background tabs after opening to reduce load.")
@click.option("--lazy", is_flag=True, help="Create lightweight placeholder tabs after --eager-tabs; placeholders load when selected.")
@click.option("--eager-tabs", type=int, default=10, show_default=True, help="Number of real tabs to open before lazy placeholders.")
@click.option("--background", "background_job", is_flag=True, help="Start restore as a background job and return immediately.")
def session_load(name, gentle_mode, discard_background_tabs, lazy, eager_tabs, background_job):
"""Restore session NAME (opens all saved tabs).""" """Restore session NAME (opens all saved tabs)."""
result = _handle("session.load", {"name": name}) result = _handle("session.load", {
"name": name,
"gentleMode": gentle_mode,
"discardBackgroundTabs": discard_background_tabs,
"lazy": lazy,
"eagerTabs": eager_tabs,
"__background": background_job,
})
if background_job and isinstance(result, dict) and result.get("jobId"):
console.print(f"[green]Session restore started[/green] job={result['jobId']}")
return
count = result.get("tabs", 0) if isinstance(result, dict) else 0 count = result.get("tabs", 0) if isinstance(result, dict) else 0
console.print(f"[green]Session '{name}' loaded[/green] ({count} tabs opened)") console.print(f"[green]Session '{name}' loaded[/green] ({count} tabs opened)")
@session_group.command("diff") @session_group.command("diff")
@click.argument("name_a") @click.argument("name_a")
@click.argument("name_b") @click.argument("name_b")
@@ -54,7 +65,6 @@ def session_diff(name_a, name_b):
if not added and not removed: if not added and not removed:
console.print("[green]Sessions are identical[/green]") console.print("[green]Sessions are identical[/green]")
@session_group.command("list") @session_group.command("list")
def session_list(): def session_list():
"""List all saved sessions.""" """List all saved sessions."""
@@ -90,7 +100,6 @@ def session_list():
table.add_row(*row) table.add_row(*row)
console.print(table) console.print(table)
@session_group.command("remove") @session_group.command("remove")
@click.argument("name") @click.argument("name")
def session_remove(name): def session_remove(name):
@@ -98,6 +107,24 @@ def session_remove(name):
_handle("session.remove", {"name": name}) _handle("session.remove", {"name": name})
console.print(f"[green]Session '{name}' removed[/green]") console.print(f"[green]Session '{name}' removed[/green]")
@session_group.command("job-status")
@click.argument("job_id")
def session_job_status(job_id):
"""Show status for a background session job."""
result = _handle("jobs.status", {"jobId": job_id}) or {}
status = result.get("status", "unknown")
console.print(f"[bold]{job_id}[/bold]: {status}")
if result.get("error"):
console.print(f"[red]{result['error']}[/red]")
elif result.get("result"):
console.print(result["result"])
@session_group.command("job-cancel")
@click.argument("job_id")
def session_job_cancel(job_id):
"""Cancel a running background job."""
_handle("jobs.cancel", {"jobId": job_id})
console.print(f"[green]Cancel requested for {job_id}[/green]")
@session_group.command("auto-save") @session_group.command("auto-save")
@click.argument("state", type=click.Choice(["on", "off"])) @click.argument("state", type=click.Choice(["on", "off"]))
+12 -8
View File
@@ -66,9 +66,10 @@ def tabs_list():
@click.argument("tab_id", type=int, required=False) @click.argument("tab_id", type=int, required=False)
@click.option("--inactive", is_flag=True, help="Close all inactive tabs") @click.option("--inactive", is_flag=True, help="Close all inactive tabs")
@click.option("--duplicates", is_flag=True, help="Close duplicate tabs (keep first)") @click.option("--duplicates", is_flag=True, help="Close duplicate tabs (keep first)")
def tabs_close(tab_id, inactive, duplicates): @click.option("--gentle-mode", type=click.Choice(["auto", "normal", "gentle", "ultra"]), default="auto", show_default=True, help="Throttle mode for large close operations.")
def tabs_close(tab_id, inactive, duplicates, gentle_mode):
"""Close a tab, all inactive tabs, or all duplicate tabs.""" """Close a tab, all inactive tabs, or all duplicate tabs."""
result = _handle("tabs.close", {"tabId": tab_id, "inactive": inactive, "duplicates": duplicates}) result = _handle("tabs.close", {"tabId": tab_id, "inactive": inactive, "duplicates": duplicates, "gentleMode": gentle_mode})
count = result.get("closed", 0) if isinstance(result, dict) else 1 count = result.get("closed", 0) if isinstance(result, dict) else 1
console.print(f"[green]Closed {count} tab(s)[/green]") console.print(f"[green]Closed {count} tab(s)[/green]")
@@ -171,25 +172,28 @@ def tabs_html(tab_id):
@tabs_group.command("dedupe") @tabs_group.command("dedupe")
def tabs_dedupe(): @click.option("--gentle-mode", type=click.Choice(["auto", "normal", "gentle", "ultra"]), default="auto", show_default=True, help="Throttle mode for large dedupe operations.")
def tabs_dedupe(gentle_mode):
"""Close duplicate tabs (keep the first occurrence of each URL).""" """Close duplicate tabs (keep the first occurrence of each URL)."""
result = _handle("tabs.dedupe") result = _handle("tabs.dedupe", {"gentleMode": gentle_mode})
count = result.get("closed", 0) if isinstance(result, dict) else 0 count = result.get("closed", 0) if isinstance(result, dict) else 0
console.print(f"[green]Closed {count} duplicate tab(s)[/green]") console.print(f"[green]Closed {count} duplicate tab(s)[/green]")
@tabs_group.command("sort") @tabs_group.command("sort")
@click.option("--by", type=click.Choice(["domain", "title", "time"]), default="domain", show_default=True) @click.option("--by", type=click.Choice(["domain", "title", "time"]), default="domain", show_default=True)
def tabs_sort(by): @click.option("--gentle-mode", type=click.Choice(["auto", "normal", "gentle", "ultra"]), default="auto", show_default=True, help="Throttle mode for large sort operations.")
def tabs_sort(by, gentle_mode):
"""Sort tabs within each window.""" """Sort tabs within each window."""
_handle("tabs.sort", {"by": by}) _handle("tabs.sort", {"by": by, "gentleMode": gentle_mode})
console.print(f"[green]Tabs sorted by {by}[/green]") console.print(f"[green]Tabs sorted by {by}[/green]")
@tabs_group.command("merge-windows") @tabs_group.command("merge-windows")
def tabs_merge_windows(): @click.option("--gentle-mode", type=click.Choice(["auto", "normal", "gentle", "ultra"]), default="auto", show_default=True, help="Throttle mode for large merge operations.")
def tabs_merge_windows(gentle_mode):
"""Move all tabs into the focused window.""" """Move all tabs into the focused window."""
result = _handle("tabs.merge_windows") result = _handle("tabs.merge_windows", {"gentleMode": gentle_mode})
count = result.get("moved", 0) if isinstance(result, dict) else 0 count = result.get("moved", 0) if isinstance(result, dict) else 0
console.print(f"[green]Merged — moved {count} tab(s) into current window[/green]") console.print(f"[green]Merged — moved {count} tab(s) into current window[/green]")
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "browser-cli", "name": "browser-cli",
"version": "0.9.9", "version": "0.10.0",
"description": "Control your browser from the terminal or Python SDK", "description": "Control your browser from the terminal or Python SDK",
"permissions": [ "permissions": [
"tabs", "tabs",
+14 -4
View File
@@ -1,5 +1,5 @@
// @ts-nocheck // @ts-nocheck
import { buildTabBlocks, resolveGroupId, tabInfo } from '../core'; import { buildTabBlocks, getLargeOperationThrottle, resolveGroupId, runLargeOperation, tabInfo, throwIfJobCancelled, updateJobProgress, yieldForLargeOperation } from '../core';
export async function groupList() { export async function groupList() {
const groups = await chrome.tabGroups.query({}); const groups = await chrome.tabGroups.query({});
const all = await chrome.tabs.query({}); const all = await chrome.tabs.query({});
@@ -29,11 +29,21 @@ export async function groupQuery({ search }) {
return groups.filter(g => g.title && g.title.toLowerCase().includes(q)); return groups.filter(g => g.title && g.title.toLowerCase().includes(q));
} }
export async function groupClose({ groupId }) { export async function groupClose({ groupId, gentleMode, __job } = {}) {
return runLargeOperation("group.close", async () => {
const tabs = await chrome.tabs.query({}); const tabs = await chrome.tabs.query({});
const groupTabs = tabs.filter(t => t.groupId === groupId); const groupTabs = tabs.filter(t => t.groupId === groupId);
await chrome.tabs.ungroup(groupTabs.map(t => t.id)); const tabIds = groupTabs.map(t => t.id);
return { groupId }; const throttle = await getLargeOperationThrottle(tabIds.length, gentleMode);
updateJobProgress(__job, { phase: "ungrouping tabs", current: 0, total: tabIds.length });
for (let i = 0; i < tabIds.length; i += throttle.batchSize) {
throwIfJobCancelled(__job);
await chrome.tabs.ungroup(tabIds.slice(i, i + throttle.batchSize));
updateJobProgress(__job, { phase: "ungrouping tabs", current: Math.min(i + throttle.batchSize, tabIds.length), total: tabIds.length });
await yieldForLargeOperation(i + throttle.batchSize, throttle.batchSize, throttle.pauseMs);
}
return { groupId, gentle: throttle.gentle, audible: throttle.audible };
});
} }
export async function groupOpen({ name }) { export async function groupOpen({ name }) {
+142 -16
View File
@@ -1,14 +1,14 @@
// @ts-nocheck // @ts-nocheck
import { getProfileAlias, getSessionTabs, getSessions, normalizeGroupColor } from '../core'; import { getLargeOperationThrottle, getProfileAlias, getSessionTabs, getSessions, normalizeGroupColor, runLargeOperation, throwIfJobCancelled, updateJobProgress, yieldForLargeOperation } from '../core';
export async function sessionSave({ name }) {
const tabs = await chrome.tabs.query({}); function buildSessionSnapshot(tabs, groups) {
const groups = await chrome.tabGroups.query({});
const groupById = new Map(groups.map(group => [group.id, group])); const groupById = new Map(groups.map(group => [group.id, group]));
const sessionTabs = tabs return tabs
.filter(tab => Boolean(tab.url)) .filter(tab => Boolean(tab.url || tab.pendingUrl))
.sort((a, b) => (a.windowId - b.windowId) || (a.index - b.index)) .sort((a, b) => (a.windowId - b.windowId) || (a.index - b.index))
.map(tab => { .map(tab => {
const entry = { url: tab.url }; const entry = { url: tab.url || tab.pendingUrl };
if (tab.pinned) entry.pinned = true;
if (tab.groupId >= 0) { if (tab.groupId >= 0) {
const group = groupById.get(tab.groupId); const group = groupById.get(tab.groupId);
entry.group = { entry.group = {
@@ -20,28 +20,82 @@ export async function sessionSave({ name }) {
} }
return entry; return entry;
}); });
}
function sessionSignature(sessionTabs) {
return JSON.stringify(sessionTabs.map(tab => ({
url: tab.url,
pinned: Boolean(tab.pinned),
group: tab.group ? {
key: tab.group.key || "",
title: tab.group.title || "",
color: normalizeGroupColor(tab.group.color),
collapsed: Boolean(tab.group.collapsed),
} : null,
})));
}
export async function sessionSave({ name }) {
const tabs = await chrome.tabs.query({});
const groups = await chrome.tabGroups.query({});
const sessionTabs = buildSessionSnapshot(tabs, groups);
const signature = sessionSignature(sessionTabs);
const sessions = await getSessions(); const sessions = await getSessions();
sessions[name] = { sessions[name] = {
tabs: sessionTabs, tabs: sessionTabs,
urls: sessionTabs.map(tab => tab.url), urls: sessionTabs.map(tab => tab.url),
savedAt: Date.now(), savedAt: Date.now(),
signature,
}; };
await chrome.storage.local.set({ sessions }); await chrome.storage.local.set({ sessions });
return { name, tabs: sessionTabs.length }; return { name, tabs: sessionTabs.length };
} }
export async function sessionLoad({ name }) { function lazyPlaceholderUrl(url) {
const escaped = String(url).replace(/[&<>"']/g, ch => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" }[ch]));
const html = `<!doctype html><title>Lazy tab</title><body style="font-family:sans-serif;padding:2rem"><h1>Lazy tab</h1><p>This tab will load when selected.</p><p><a href="${escaped}">${escaped}</a></p></body>`;
return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
}
export async function activateLazyTab(tabId) {
const { lazySessionTabs } = await chrome.storage.local.get("lazySessionTabs");
const entry = lazySessionTabs?.[tabId];
if (!entry?.url) return false;
delete lazySessionTabs[tabId];
await chrome.storage.local.set({ lazySessionTabs });
await chrome.tabs.update(Number(tabId), { url: entry.url });
return true;
}
export async function sessionLoad({ name, gentleMode, discardBackgroundTabs = false, lazy = false, eagerTabs = 10, __job } = {}) {
return runLargeOperation("session.load", async () => {
const sessions = await getSessions(); const sessions = await getSessions();
const session = sessions[name]; const session = sessions[name];
if (!session) throw new Error(`Session '${name}' not found`); if (!session) throw new Error(`Session '${name}' not found`);
const sessionTabs = getSessionTabs(session); const sessionTabs = getSessionTabs(session).sort((a, b) => Number(Boolean(b.pinned)) - Number(Boolean(a.pinned)));
const createdTabs = []; const createdTabs = [];
const throttle = await getLargeOperationThrottle(sessionTabs.length, gentleMode);
const createBatchSize = Math.max(1, Math.min(10, throttle.batchSize));
const eagerLimit = lazy ? Math.max(0, Number(eagerTabs) || 0) : sessionTabs.length;
const { lazySessionTabs } = await chrome.storage.local.get("lazySessionTabs");
const lazyMap = lazySessionTabs || {};
updateJobProgress(__job, { phase: "opening", current: 0, total: sessionTabs.length });
for (const entry of sessionTabs) { for (const [idx, entry] of sessionTabs.entries()) {
const tab = await chrome.tabs.create({ url: entry.url, active: false }); throwIfJobCancelled(__job);
const shouldLazy = lazy && idx >= eagerLimit;
const tab = await chrome.tabs.create({ url: shouldLazy ? lazyPlaceholderUrl(entry.url) : entry.url, active: false, pinned: Boolean(entry.pinned) });
createdTabs.push({ tabId: tab.id, entry }); createdTabs.push({ tabId: tab.id, entry });
if (shouldLazy) {
lazyMap[String(tab.id)] = { url: entry.url, createdAt: Date.now() };
} else if (discardBackgroundTabs && !entry.pinned && chrome.tabs.discard) {
try { await chrome.tabs.discard(tab.id); } catch (_) {}
} }
updateJobProgress(__job, { phase: shouldLazy ? "creating lazy placeholders" : "opening", current: createdTabs.length, total: sessionTabs.length });
await yieldForLargeOperation(createdTabs.length, createBatchSize, Math.max(50, throttle.pauseMs));
}
if (lazy) await chrome.storage.local.set({ lazySessionTabs: lazyMap });
const groups = new Map(); const groups = new Map();
for (const { tabId, entry } of createdTabs) { for (const { tabId, entry } of createdTabs) {
@@ -53,16 +107,23 @@ export async function sessionLoad({ name }) {
groups.get(key).tabIds.push(tabId); groups.get(key).tabIds.push(tabId);
} }
let restoredGroups = 0;
updateJobProgress(__job, { phase: "restoring groups", current: 0, total: groups.size });
for (const { meta, tabIds } of groups.values()) { for (const { meta, tabIds } of groups.values()) {
throwIfJobCancelled(__job);
const restoredGroupId = await chrome.tabs.group({ tabIds }); const restoredGroupId = await chrome.tabs.group({ tabIds });
await chrome.tabGroups.update(restoredGroupId, { await chrome.tabGroups.update(restoredGroupId, {
title: meta.title || "", title: meta.title || "",
color: normalizeGroupColor(meta.color), color: normalizeGroupColor(meta.color),
collapsed: Boolean(meta.collapsed), collapsed: Boolean(meta.collapsed),
}); });
restoredGroups++;
updateJobProgress(__job, { phase: "restoring groups", current: restoredGroups, total: groups.size });
await yieldForLargeOperation(restoredGroups, 5, Math.max(50, throttle.pauseMs));
} }
return { name, tabs: sessionTabs.length }; return { name, tabs: sessionTabs.length, gentle: throttle.gentle, audible: throttle.audible, discarded: Boolean(discardBackgroundTabs), lazy: Boolean(lazy), eagerTabs: eagerLimit };
});
} }
export async function sessionList() { export async function sessionList() {
@@ -92,21 +153,86 @@ export async function sessionDiff({ nameA, nameB }) {
}; };
} }
let autoSaveTimer = null;
let autoSaveInFlight = false;
let autoSavePending = false;
export async function sessionAutoSave({ enabled }) { export async function sessionAutoSave({ enabled }) {
await chrome.storage.local.set({ autoSave: enabled }); await chrome.storage.local.set({ autoSave: enabled });
chrome.tabs.onUpdated.removeListener(autoSaveHandler); chrome.tabs.onCreated.removeListener(autoSaveHandler);
chrome.tabs.onRemoved.removeListener(autoSaveHandler); chrome.tabs.onRemoved.removeListener(autoSaveHandler);
chrome.tabs.onMoved.removeListener(autoSaveHandler);
chrome.tabs.onAttached.removeListener(autoSaveHandler);
chrome.tabs.onDetached.removeListener(autoSaveHandler);
chrome.tabs.onUpdated.removeListener(autoSaveUpdatedHandler);
if (chrome.tabGroups?.onUpdated) chrome.tabGroups.onUpdated.removeListener(autoSaveHandler);
if (autoSaveTimer) clearTimeout(autoSaveTimer);
autoSaveTimer = null;
autoSavePending = false;
if (enabled) { if (enabled) {
chrome.tabs.onUpdated.addListener(autoSaveHandler); chrome.tabs.onCreated.addListener(autoSaveHandler);
chrome.tabs.onRemoved.addListener(autoSaveHandler); chrome.tabs.onRemoved.addListener(autoSaveHandler);
chrome.tabs.onMoved.addListener(autoSaveHandler);
chrome.tabs.onAttached.addListener(autoSaveHandler);
chrome.tabs.onDetached.addListener(autoSaveHandler);
chrome.tabs.onUpdated.addListener(autoSaveUpdatedHandler);
if (chrome.tabGroups?.onUpdated) chrome.tabGroups.onUpdated.addListener(autoSaveHandler);
} }
return { enabled }; return { enabled };
} }
export async function autoSaveHandler() { async function saveAutoSessionIfChanged() {
const tabs = await chrome.tabs.query({});
const groups = await chrome.tabGroups.query({});
const sessionTabs = buildSessionSnapshot(tabs, groups);
const signature = sessionSignature(sessionTabs);
const { autoSaveSignature } = await chrome.storage.local.get("autoSaveSignature");
if (autoSaveSignature === signature) return { skipped: true, tabs: sessionTabs.length };
const sessions = await getSessions();
sessions.__auto__ = {
tabs: sessionTabs,
urls: sessionTabs.map(tab => tab.url),
savedAt: Date.now(),
signature,
};
await chrome.storage.local.set({ sessions, autoSaveSignature: signature });
return { skipped: false, tabs: sessionTabs.length };
}
async function runAutoSave() {
if (autoSaveInFlight) {
autoSavePending = true;
return;
}
autoSaveInFlight = true;
try {
const { autoSave } = await chrome.storage.local.get("autoSave");
if (autoSave) await runLargeOperation("session.auto_save", saveAutoSessionIfChanged);
} finally {
autoSaveInFlight = false;
if (autoSavePending) {
autoSavePending = false;
autoSaveTimer = setTimeout(runAutoSave, 1000);
}
}
}
async function scheduleAutoSave(delayMs = 1000) {
const { autoSave } = await chrome.storage.local.get("autoSave"); const { autoSave } = await chrome.storage.local.get("autoSave");
if (!autoSave) return; if (!autoSave) return;
await sessionSave({ name: "__auto__" }); if (autoSaveTimer) clearTimeout(autoSaveTimer);
autoSaveTimer = setTimeout(runAutoSave, delayMs);
}
export async function autoSaveHandler() {
await scheduleAutoSave();
}
export async function autoSaveUpdatedHandler(_tabId, changeInfo = {}) {
// Ignore noisy media/title/favicon/loading updates. Sessions only store URL and group/window structure.
if (!("url" in changeInfo)) return;
await scheduleAutoSave();
} }
// ── Misc ────────────────────────────────────────────────────────────────────── // ── Misc ──────────────────────────────────────────────────────────────────────
+40 -10
View File
@@ -1,5 +1,5 @@
// @ts-nocheck // @ts-nocheck
import { executeScript, getActiveTab, getAliases, isBrowserErrorUrl, isErrorPageScriptError, isScriptableUrl, resolveTabForDirectAction, tabInfo } from '../core'; import { executeScript, getActiveTab, getAliases, getLargeOperationThrottle, isBrowserErrorUrl, isErrorPageScriptError, isScriptableUrl, resolveTabForDirectAction, runLargeOperation, tabInfo, throwIfJobCancelled, updateJobProgress, yieldForLargeOperation } from '../core';
export async function tabsList() { export async function tabsList() {
const windows = await chrome.windows.getAll({ populate: true }); const windows = await chrome.windows.getAll({ populate: true });
const aliases = await getAliases(); const aliases = await getAliases();
@@ -17,7 +17,8 @@ export async function tabsList() {
return tabs; return tabs;
} }
export async function tabsClose({ tabId, inactive, duplicates }) { export async function tabsClose({ tabId, inactive, duplicates, gentleMode, __job } = {}) {
return runLargeOperation("tabs.close", async () => {
let toClose = []; let toClose = [];
if (duplicates) { if (duplicates) {
const all = await chrome.tabs.query({}); const all = await chrome.tabs.query({});
@@ -33,8 +34,16 @@ export async function tabsClose({ tabId, inactive, duplicates }) {
} else if (tabId) { } else if (tabId) {
toClose = [tabId]; toClose = [tabId];
} }
if (toClose.length) await chrome.tabs.remove(toClose); const throttle = await getLargeOperationThrottle(toClose.length, gentleMode);
return { closed: toClose.length }; updateJobProgress(__job, { phase: "closing tabs", current: 0, total: toClose.length });
for (let i = 0; i < toClose.length; i += throttle.batchSize) {
throwIfJobCancelled(__job);
await chrome.tabs.remove(toClose.slice(i, i + throttle.batchSize));
updateJobProgress(__job, { phase: "closing tabs", current: Math.min(i + throttle.batchSize, toClose.length), total: toClose.length });
await yieldForLargeOperation(i + throttle.batchSize, throttle.batchSize, throttle.pauseMs);
}
return { closed: toClose.length, gentle: throttle.gentle, audible: throttle.audible };
});
} }
export async function tabsMove({ tabId, groupId, windowId, index, forward, backward }) { export async function tabsMove({ tabId, groupId, windowId, index, forward, backward }) {
@@ -127,13 +136,16 @@ export async function tabsHtml({ tabId }) {
} }
} }
export async function tabsDedupe() { export async function tabsDedupe(args = {}) {
return tabsClose({ duplicates: true }); return tabsClose({ ...args, duplicates: true });
} }
export async function tabsSort({ by }) { export async function tabsSort({ by, gentleMode, __job } = {}) {
return runLargeOperation("tabs.sort", async () => {
const windows = await chrome.windows.getAll({ populate: true }); const windows = await chrome.windows.getAll({ populate: true });
let moved = 0; let moved = 0;
const totalTabs = windows.reduce((sum, w) => sum + (w.tabs?.length || 0), 0);
updateJobProgress(__job, { phase: "sorting tabs", current: 0, total: totalTabs });
for (const w of windows) { for (const w of windows) {
const sorted = [...w.tabs].sort((a, b) => { const sorted = [...w.tabs].sort((a, b) => {
if (by === "title") return (a.title || "").localeCompare(b.title || ""); if (by === "title") return (a.title || "").localeCompare(b.title || "");
@@ -143,25 +155,43 @@ export async function tabsSort({ by }) {
const db = new URL(b.url || b.pendingUrl || "about:blank").hostname; const db = new URL(b.url || b.pendingUrl || "about:blank").hostname;
return da.localeCompare(db); return da.localeCompare(db);
}); });
if (w.tabs.every((tab, index) => tab.id === sorted[index]?.id)) continue;
const throttle = await getLargeOperationThrottle(sorted.length, gentleMode);
const moveBatchSize = Math.max(1, Math.min(10, throttle.batchSize));
for (let i = 0; i < sorted.length; i++) { for (let i = 0; i < sorted.length; i++) {
throwIfJobCancelled(__job);
await chrome.tabs.move(sorted[i].id, { index: i }); await chrome.tabs.move(sorted[i].id, { index: i });
moved++; moved++;
updateJobProgress(__job, { phase: "sorting tabs", current: moved, total: totalTabs });
await yieldForLargeOperation(moved, moveBatchSize, throttle.pauseMs);
} }
} }
return { moved }; return { moved };
});
} }
export async function tabsMergeWindows() { export async function tabsMergeWindows({ gentleMode, __job } = {}) {
return runLargeOperation("tabs.merge_windows", async () => {
const current = await chrome.windows.getCurrent(); const current = await chrome.windows.getCurrent();
const all = await chrome.windows.getAll({ populate: true }); const all = await chrome.windows.getAll({ populate: true });
let moved = 0; let moved = 0;
const totalTabs = all.filter(w => w.id !== current.id).reduce((sum, w) => sum + (w.tabs?.length || 0), 0);
updateJobProgress(__job, { phase: "merging windows", current: 0, total: totalTabs });
for (const w of all) { for (const w of all) {
if (w.id === current.id) continue; if (w.id === current.id) continue;
const ids = w.tabs.map(t => t.id); const ids = w.tabs.map(t => t.id);
await chrome.tabs.move(ids, { windowId: current.id, index: -1 }); const throttle = await getLargeOperationThrottle(ids.length, gentleMode);
moved += ids.length; for (let i = 0; i < ids.length; i += throttle.batchSize) {
throwIfJobCancelled(__job);
const chunk = ids.slice(i, i + throttle.batchSize);
await chrome.tabs.move(chunk, { windowId: current.id, index: -1 });
moved += chunk.length;
updateJobProgress(__job, { phase: "merging windows", current: moved, total: totalTabs });
await yieldForLargeOperation(moved, throttle.batchSize, throttle.pauseMs);
}
} }
return { moved }; return { moved };
});
} }
export async function tabsPin({ tabId }) { export async function tabsPin({ tabId }) {
+84 -2
View File
@@ -32,13 +32,94 @@ export function isTransientScriptError(error) {
return message.includes("Frame with ID") || message.includes("No tab with id") || isErrorPageScriptError(error); return message.includes("Frame with ID") || message.includes("No tab with id") || isErrorPageScriptError(error);
} }
export function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
export const LARGE_OPERATION_BATCH_SIZE = 25;
export const LARGE_OPERATION_PAUSE_MS = 25;
export const GENTLE_OPERATION_BATCH_SIZE = 8;
export const GENTLE_OPERATION_PAUSE_MS = 100;
export async function hasAudibleTabs() {
const audibleTabs = await chrome.tabs.query({ audible: true });
return audibleTabs.some(tab => !(tab.mutedInfo && tab.mutedInfo.muted));
}
let largeOperationQueue = Promise.resolve();
export async function runLargeOperation(name, fn) {
const run = largeOperationQueue.then(async () => {
console.log(`[browser-cli] large operation start: ${name}`);
try {
return await fn();
} finally {
console.log(`[browser-cli] large operation done: ${name}`);
}
});
largeOperationQueue = run.catch(() => {});
return run;
}
export async function getPerformanceProfile() {
const { performanceProfile } = await chrome.storage.local.get("performanceProfile");
return performanceProfile || "auto";
}
export async function setPerformanceProfile(profile) {
const allowed = new Set(["auto", "normal", "gentle", "ultra"]);
const performanceProfile = allowed.has(profile) ? profile : "auto";
await chrome.storage.local.set({ performanceProfile });
return { performanceProfile };
}
export async function getLargeOperationThrottle(itemCount = 0, mode = "auto") {
const audible = await hasAudibleTabs();
const storedProfile = await getPerformanceProfile();
const configuredMode = mode && mode !== "auto" ? mode : storedProfile;
const gentle = configuredMode === "gentle" || configuredMode === "ultra" || (configuredMode === "auto" && audible);
let batchSize = gentle ? GENTLE_OPERATION_BATCH_SIZE : LARGE_OPERATION_BATCH_SIZE;
let pauseMs = gentle ? GENTLE_OPERATION_PAUSE_MS : LARGE_OPERATION_PAUSE_MS;
if (configuredMode === "ultra" || itemCount >= 300) {
batchSize = Math.max(3, Math.floor(batchSize / 2));
pauseMs *= 2;
} else if (itemCount >= 100) {
batchSize = Math.max(5, Math.floor(batchSize * 0.75));
pauseMs = Math.max(pauseMs, 75);
}
return { batchSize, pauseMs, gentle, audible, itemCount, mode: configuredMode };
}
export function updateJobProgress(job, { phase, current, total } = {}) {
if (!job) return;
if (phase) job.phase = phase;
if (total != null) job.total = total;
if (current != null) job.current = current;
if (job.total) job.percent = Math.min(100, Math.round((job.current || 0) * 100 / job.total));
job.updatedAt = Date.now();
}
export function throwIfJobCancelled(job) {
if (job?.cancelRequested) {
throw new Error(`Job '${job.id}' cancelled`);
}
}
export async function yieldForLargeOperation(processed, batchSize = LARGE_OPERATION_BATCH_SIZE, pauseMs = LARGE_OPERATION_PAUSE_MS) {
if (processed > 0 && processed % batchSize === 0) {
await sleep(pauseMs);
}
}
export async function executeScript(options, retries = 3) { export async function executeScript(options, retries = 3) {
for (let i = 0; i < retries; i++) { for (let i = 0; i < retries; i++) {
try { try {
return await chrome.scripting.executeScript(options); return await chrome.scripting.executeScript(options);
} catch (e) { } catch (e) {
if (i < retries - 1 && isTransientScriptError(e)) { if (i < retries - 1 && isTransientScriptError(e)) {
await new Promise(r => setTimeout(r, 300)); await sleep(300);
continue; continue;
} }
throw e; throw e;
@@ -52,8 +133,9 @@ export function tabInfo(t) {
windowId: t.windowId, windowId: t.windowId,
active: t.active, active: t.active,
muted: Boolean(t.mutedInfo && t.mutedInfo.muted), muted: Boolean(t.mutedInfo && t.mutedInfo.muted),
groupId: t.groupId >= 0 ? t.groupId : null,
title: t.title, title: t.title,
url: t.url, url: t.url || t.pendingUrl || "",
}; };
} }
+123 -4
View File
@@ -5,7 +5,7 @@
* Connects to the native host (com.browsercli.host) via Native Messaging. * Connects to the native host (com.browsercli.host) via Native Messaging.
*/ */
import { getProfileAlias } from './core'; import { getLargeOperationThrottle, getPerformanceProfile, hasAudibleTabs, setPerformanceProfile, getProfileAlias } from './core';
import * as nav from './commands/navigation'; import * as nav from './commands/navigation';
import * as tabs from './commands/tabs'; import * as tabs from './commands/tabs';
import * as groups from './commands/groups'; import * as groups from './commands/groups';
@@ -17,6 +17,15 @@ import * as session from './commands/session';
const NATIVE_HOST = "com.browsercli.host"; const NATIVE_HOST = "com.browsercli.host";
let port = null; let port = null;
let keepaliveEnabled = true; let keepaliveEnabled = true;
const jobs = new Map();
const BACKGROUND_COMMANDS = new Set([
"session.load",
"tabs.close",
"tabs.dedupe",
"tabs.sort",
"tabs.merge_windows",
"group.close",
]);
// ── Connection management ───────────────────────────────────────────────────── // ── Connection management ─────────────────────────────────────────────────────
function sendControlMessage(targetPort, message) { function sendControlMessage(targetPort, message) {
@@ -88,6 +97,10 @@ chrome.alarms.onAlarm.addListener((alarm) => {
} }
}); });
chrome.tabs.onActivated.addListener(async ({ tabId }) => {
await session.activateLazyTab(tabId);
});
// ── Message dispatcher ──────────────────────────────────────────────────────── // ── Message dispatcher ────────────────────────────────────────────────────────
async function onMessage(msg) { async function onMessage(msg) {
@@ -98,11 +111,15 @@ async function onMessage(msg) {
let data, error; let data, error;
try { try {
const { __page, ...commandArgs } = args || {}; const { __page, __background, ...commandArgs } = args || {};
if (__background && BACKGROUND_COMMANDS.has(command)) {
data = await startBackgroundJob(command, commandArgs);
} else {
data = await dispatch(command, commandArgs); data = await dispatch(command, commandArgs);
if (__page && Array.isArray(data)) { if (__page && Array.isArray(data)) {
data = makePagedData(data, __page); data = makePagedData(data, __page);
} }
}
} catch (e) { } catch (e) {
error = e.message || String(e); error = e.message || String(e);
} }
@@ -121,6 +138,94 @@ async function onMessage(msg) {
await connect(); await connect();
} }
} }
async function persistJobs() {
const recentJobs = [...jobs.values()].slice(-50).map(job => ({ ...job, __timer: undefined }));
await chrome.storage.local.set({ recentJobs });
}
async function startBackgroundJob(command, args) {
const jobId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
const job = {
id: jobId,
command,
status: "running",
phase: "queued",
current: 0,
total: null,
percent: 0,
cancelRequested: false,
startedAt: Date.now(),
updatedAt: Date.now(),
finishedAt: null,
result: null,
error: null,
};
jobs.set(jobId, job);
job.__timer = setInterval(persistJobs, 1000);
await persistJobs();
dispatch(command, { ...args, __job: job })
.then(async result => {
job.status = "done";
job.phase = "done";
job.result = result;
job.current = job.total || job.current;
job.percent = 100;
job.finishedAt = Date.now();
job.updatedAt = Date.now();
if (job.__timer) clearInterval(job.__timer);
await persistJobs();
})
.catch(async error => {
job.status = job.cancelRequested ? "cancelled" : "error";
job.phase = job.status;
job.error = error?.message || String(error);
job.finishedAt = Date.now();
job.updatedAt = Date.now();
if (job.__timer) clearInterval(job.__timer);
await persistJobs();
});
return { jobId, command, status: job.status };
}
async function jobStatus({ jobId }) {
const job = jobs.get(jobId);
if (job) return { ...job };
const { recentJobs } = await chrome.storage.local.get("recentJobs");
const stored = (recentJobs || []).find(entry => entry.id === jobId);
if (!stored) throw new Error(`Job '${jobId}' not found`);
return stored;
}
async function jobCancel({ jobId }) {
const job = jobs.get(jobId);
if (!job) throw new Error(`Job '${jobId}' not running`);
job.cancelRequested = true;
job.updatedAt = Date.now();
await persistJobs();
return { jobId, cancelled: true };
}
async function perfStatus() {
const profile = await getPerformanceProfile();
const audible = await hasAudibleTabs();
const throttle = await getLargeOperationThrottle(0, "auto");
return {
performanceProfile: profile,
audible,
throttle,
jobs: [...jobs.values()].map(job => ({
id: job.id,
command: job.command,
status: job.status,
phase: job.phase,
current: job.current,
total: job.total,
percent: job.percent,
cancelRequested: job.cancelRequested,
})),
};
}
function makePagedData(items, page) { function makePagedData(items, page) {
const total = items.length; const total = items.length;
const offset = Math.max(0, Number(page.offset) || 0); const offset = Math.max(0, Number(page.offset) || 0);
@@ -160,9 +265,9 @@ async function dispatch(command, args) {
case "tabs.count": return tabs.tabsCount(args); case "tabs.count": return tabs.tabsCount(args);
case "tabs.query": return tabs.tabsQuery(args); case "tabs.query": return tabs.tabsQuery(args);
case "tabs.html": return tabs.tabsHtml(args); case "tabs.html": return tabs.tabsHtml(args);
case "tabs.dedupe": return tabs.tabsDedupe(); case "tabs.dedupe": return tabs.tabsDedupe(args);
case "tabs.sort": return tabs.tabsSort(args); case "tabs.sort": return tabs.tabsSort(args);
case "tabs.merge_windows": return tabs.tabsMergeWindows(); case "tabs.merge_windows": return tabs.tabsMergeWindows(args);
case "tabs.mute": return tabs.tabsMute(args); case "tabs.mute": return tabs.tabsMute(args);
case "tabs.unmute": return tabs.tabsUnmute(args); case "tabs.unmute": return tabs.tabsUnmute(args);
case "tabs.pin": return tabs.tabsPin(args); case "tabs.pin": return tabs.tabsPin(args);
@@ -234,6 +339,20 @@ async function dispatch(command, args) {
case "session.diff": return session.sessionDiff(args); case "session.diff": return session.sessionDiff(args);
case "session.auto_save": return session.sessionAutoSave(args); case "session.auto_save": return session.sessionAutoSave(args);
// ── Jobs ──────────────────────────────────────────────────────────────
case "jobs.status": return jobStatus(args);
case "jobs.cancel": return jobCancel(args);
// ── Performance ───────────────────────────────────────────────────────
case "perf.status": return perfStatus();
case "perf.set_profile": return setPerformanceProfile(args.profile);
// ── Extension ─────────────────────────────────────────────────────────
case "extension.reload": {
setTimeout(() => chrome.runtime.reload(), 200);
return { reloading: true };
}
// ── Misc ────────────────────────────────────────────────────────────── // ── Misc ──────────────────────────────────────────────────────────────
case "clients.list": return session.clientsList(); case "clients.list": return session.clientsList();
case "clients.rename_profile": return session.clientsRenameProfile(args); case "clients.rename_profile": return session.clientsRenameProfile(args);
+5 -2
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "browser-cli" name = "browser-cli"
version = "0.9.9" version = "0.10.0"
description = "Control your real running browser from the terminal or Python SDK" description = "Control your real running browser from the terminal or Python SDK"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
@@ -13,7 +13,10 @@ dependencies = [
browser-cli = "browser_cli.cli:main" browser-cli = "browser_cli.cli:main"
[dependency-groups] [dependency-groups]
dev = ["pytest>=8"] dev = [
"pytest>=8",
"pytest-cov>=7.1.0",
]
[build-system] [build-system]
requires = ["hatchling"] requires = ["hatchling"]
+103
View File
@@ -35,3 +35,106 @@ def test_navigation_and_tabs_report_browser_error_pages():
assert "last URL:" in tabs assert "last URL:" in tabs
assert "isBrowserErrorUrl" in navigation assert "isBrowserErrorUrl" in navigation
assert "showing an error page while waiting for load" in navigation assert "showing an error page while waiting for load" in navigation
def test_large_extension_operations_yield_between_batches():
core = (ROOT / "extension" / "src" / "core.ts").read_text()
tabs = (ROOT / "extension" / "src" / "commands" / "tabs.ts").read_text()
groups = (ROOT / "extension" / "src" / "commands" / "groups.ts").read_text()
session = (ROOT / "extension" / "src" / "commands" / "session.ts").read_text()
assert "yieldForLargeOperation" in core
assert "getLargeOperationThrottle" in core
assert "hasAudibleTabs" in core
assert "runLargeOperation" in core
assert "largeOperationQueue" in core
assert "updateJobProgress" in core
assert "throwIfJobCancelled" in core
assert "getPerformanceProfile" in core
assert "setPerformanceProfile" in core
assert "GENTLE_OPERATION_BATCH_SIZE" in core
assert "GENTLE_OPERATION_PAUSE_MS" in core
assert "itemCount >= 300" in core
assert "itemCount >= 100" in core
assert "chrome.tabs.query({ audible: true })" in core
assert "yieldForLargeOperation" in tabs
assert "toClose.slice" in tabs
assert "ids.slice" in tabs
assert "w.tabs.every" in tabs
assert "getLargeOperationThrottle" in tabs
assert "runLargeOperation(\"tabs.sort\"" in tabs
assert "yieldForLargeOperation" in groups
assert "tabIds.slice" in groups
assert "getLargeOperationThrottle" in groups
assert "runLargeOperation(\"group.close\"" in groups
assert "yieldForLargeOperation(createdTabs.length" in session
assert "getLargeOperationThrottle" in session
assert "runLargeOperation(\"session.load\"" in session
assert "chrome.tabs.discard" in session
assert "lazyPlaceholderUrl" in session
assert "activateLazyTab" in session
assert "lazySessionTabs" in session
assert "throwIfJobCancelled" in session
assert "updateJobProgress" in session
index = (ROOT / "extension" / "src" / "index.ts").read_text()
assert "BACKGROUND_COMMANDS" in index
assert "startBackgroundJob" in index
assert "persistJobs" in index
assert "recentJobs" in index
assert "jobs.status" in index
assert "jobs.cancel" in index
assert "perf.status" in index
assert "perf.set_profile" in index
assert "__background" in index
def test_session_autosave_is_debounced_and_non_overlapping():
session = (ROOT / "extension" / "src" / "commands" / "session.ts").read_text()
assert "autoSaveTimer" in session
assert "autoSaveInFlight" in session
assert "autoSavePending" in session
assert "scheduleAutoSave" in session
assert "autoSaveUpdatedHandler" in session
assert "saveAutoSessionIfChanged" in session
assert "sessionSignature" in session
assert "autoSaveSignature" in session
assert "chrome.tabs.onUpdated.addListener(autoSaveUpdatedHandler)" in session
assert "chrome.tabs.onCreated.addListener(autoSaveHandler)" in session
assert "chrome.tabs.onMoved.addListener(autoSaveHandler)" in session
assert "if (!(\"url\" in changeInfo)) return;" in session
assert "setTimeout(runAutoSave, delayMs)" in session
assert "clearTimeout(autoSaveTimer)" in session
def test_cli_and_sdk_expose_gentle_restore_controls():
session_cli = (ROOT / "browser_cli" / "commands" / "session.py").read_text()
tabs_cli = (ROOT / "browser_cli" / "commands" / "tabs.py").read_text()
groups_cli = (ROOT / "browser_cli" / "commands" / "groups.py").read_text()
sdk = (ROOT / "browser_cli" / "__init__.py").read_text()
assert "--gentle-mode" in session_cli
assert "--discard-background-tabs" in session_cli
assert "--background" in session_cli
assert "--lazy" in session_cli
assert "--eager-tabs" in session_cli
assert "job-status" in session_cli
assert "job-cancel" in session_cli
assert "discardBackgroundTabs" in session_cli
assert "--gentle-mode" in tabs_cli
assert "gentleMode" in tabs_cli
assert "--gentle-mode" in groups_cli
assert "discard_background_tabs" in sdk
assert "discardBackgroundTabs" in sdk
assert "session_load_background" in sdk
assert "job_status" in sdk
assert "job_cancel" in sdk
assert "perf_status" in sdk
assert "set_performance_profile" in sdk
perf_cli = (ROOT / "browser_cli" / "commands" / "perf.py").read_text()
root_cli = (ROOT / "browser_cli" / "cli.py").read_text()
assert "perf_group" in perf_cli
assert "perf.status" in perf_cli
assert "perf.set_profile" in perf_cli
assert "main.add_command(perf_group)" in root_cli
+157
View File
@@ -0,0 +1,157 @@
"""Integration tests for browser performance controls and background jobs."""
import time
import uuid
import pytest
def _wait_for_job(browser, job_id, timeout=10):
deadline = time.time() + timeout
last = None
while time.time() < deadline:
last = browser("jobs.status", {"jobId": job_id})
if last.get("status") in {"done", "error", "cancelled"}:
return last
time.sleep(0.1)
return last or {}
def _close_tabs(browser, tab_ids):
for tab_id in tab_ids:
try:
browser("tabs.close", {"tabId": tab_id})
except Exception:
pass
def _require_perf_features(browser):
try:
return browser("perf.status", {})
except RuntimeError as exc:
if "Unknown command: perf.status" in str(exc):
pytest.skip("Running browser has not reloaded the v0.10.0 extension background worker yet")
raise
def test_perf_status_and_profile_roundtrip_real_browser(browser):
initial = _require_perf_features(browser)
assert "performanceProfile" in initial
assert "audible" in initial
assert "throttle" in initial
assert "jobs" in initial
original = initial.get("performanceProfile", "auto")
try:
changed = browser("perf.set_profile", {"profile": "gentle"})
assert changed["performanceProfile"] == "gentle"
status = browser("perf.status", {})
assert status["performanceProfile"] == "gentle"
assert status["throttle"]["mode"] == "gentle"
finally:
browser("perf.set_profile", {"profile": original})
def test_background_session_load_job_reports_progress_real_browser(browser):
_require_perf_features(browser)
name = f"_pytest_perf_job_{uuid.uuid4().hex}"
marker_url = f"https://example.com/?browser-cli-job={uuid.uuid4().hex}"
marker_tab = browser("navigate.open", {"url": marker_url, "background": True})
loaded_ids = set()
try:
browser("session.save", {"name": name})
baseline_ids = {tab["id"] for tab in browser("tabs.list")}
started = browser("session.load", {
"name": name,
"__background": True,
"lazy": True,
"eagerTabs": 0,
"gentleMode": "ultra",
})
assert started["status"] == "running"
assert started["jobId"]
status = _wait_for_job(browser, started["jobId"])
assert status["status"] == "done"
assert status["command"] == "session.load"
assert status["percent"] == 100
assert status["phase"] == "done"
assert status["total"] is None or status["total"] >= 0
assert status.get("result", {}).get("lazy") is True
loaded_ids = {tab["id"] for tab in browser("tabs.list")} - baseline_ids
assert loaded_ids, "Expected lazy session load to create tabs"
finally:
_close_tabs(browser, loaded_ids)
_close_tabs(browser, [marker_tab["id"]])
try:
browser("session.remove", {"name": name})
except Exception:
pass
def test_lazy_session_load_creates_lightweight_placeholders_real_browser(browser):
_require_perf_features(browser)
name = f"_pytest_lazy_{uuid.uuid4().hex}"
marker_url = f"https://example.com/?browser-cli-lazy={uuid.uuid4().hex}"
marker_tab = browser("navigate.open", {"url": marker_url, "background": True})
loaded_ids = set()
try:
browser("session.save", {"name": name})
baseline_ids = {tab["id"] for tab in browser("tabs.list")}
result = browser("session.load", {"name": name, "lazy": True, "eagerTabs": 0, "gentleMode": "ultra"})
assert result["lazy"] is True
assert result["eagerTabs"] == 0
tabs_after = browser("tabs.list")
loaded_tabs = [tab for tab in tabs_after if tab["id"] not in baseline_ids]
loaded_ids = {tab["id"] for tab in loaded_tabs}
assert loaded_tabs
assert any((tab.get("url") or "").startswith("data:text/html") for tab in loaded_tabs)
finally:
_close_tabs(browser, loaded_ids)
_close_tabs(browser, [marker_tab["id"]])
try:
browser("session.remove", {"name": name})
except Exception:
pass
def test_session_load_restores_pinned_tabs_real_browser(browser):
_require_perf_features(browser)
name = f"_pytest_pinned_{uuid.uuid4().hex}"
marker_url = f"https://example.com/?browser-cli-pinned={uuid.uuid4().hex}"
marker_tab = browser("navigate.open", {"url": marker_url, "background": True})
loaded_ids = set()
try:
browser("tabs.pin", {"tabId": marker_tab["id"]})
browser("session.save", {"name": name})
baseline_ids = {tab["id"] for tab in browser("tabs.list")}
result = browser("session.load", {"name": name, "gentleMode": "ultra", "discardBackgroundTabs": True})
assert result["tabs"] >= 1
loaded_tabs = [tab for tab in browser("tabs.list") if tab["id"] not in baseline_ids]
loaded_ids = {tab["id"] for tab in loaded_tabs}
matching = [tab for tab in loaded_tabs if tab.get("url") == marker_url]
assert matching, "Expected session load to restore marker tab"
assert matching[0].get("pinned") is True
finally:
_close_tabs(browser, loaded_ids)
_close_tabs(browser, [marker_tab["id"]])
try:
browser("session.remove", {"name": name})
except Exception:
pass
def test_job_cancel_command_real_browser(browser):
_require_perf_features(browser)
started = browser("tabs.sort", {"by": "domain", "__background": True, "gentleMode": "ultra"})
job_id = started["jobId"]
try:
cancelled = browser("jobs.cancel", {"jobId": job_id})
assert cancelled["cancelled"] is True
except RuntimeError:
# Tiny real-browser sorts can finish before the cancel request arrives.
status = browser("jobs.status", {"jobId": job_id})
assert status["status"] in {"done", "cancelled"}
return
status = _wait_for_job(browser, job_id)
assert status["status"] in {"cancelled", "done"}
+1 -16
View File
@@ -1,7 +1,6 @@
"""Tests for tabs.* commands.""" """Tests for tabs.* commands."""
import pytest import pytest
def test_tabs_list(browser): def test_tabs_list(browser):
tabs = browser("tabs.list") tabs = browser("tabs.list")
assert isinstance(tabs, list) assert isinstance(tabs, list)
@@ -12,59 +11,51 @@ def test_tabs_list(browser):
assert "url" in first assert "url" in first
assert "title" in first assert "title" in first
assert "muted" in first assert "muted" in first
assert "groupId" in first
def test_tabs_count(browser): def test_tabs_count(browser):
count = browser("tabs.count", {}) count = browser("tabs.count", {})
tabs = browser("tabs.list") tabs = browser("tabs.list")
assert count == len(tabs) assert count == len(tabs)
def test_tabs_count_with_pattern(browser): def test_tabs_count_with_pattern(browser):
count = browser("tabs.count", {"pattern": "http"}) count = browser("tabs.count", {"pattern": "http"})
assert isinstance(count, int) assert isinstance(count, int)
assert count >= 0 assert count >= 0
def test_tabs_filter(browser): def test_tabs_filter(browser):
result = browser("tabs.filter", {"pattern": "http"}) result = browser("tabs.filter", {"pattern": "http"})
assert isinstance(result, list) assert isinstance(result, list)
for tab in result: for tab in result:
assert "http" in tab.get("url", "") assert "http" in tab.get("url", "")
def test_tabs_query(browser): def test_tabs_query(browser):
result = browser("tabs.query", {"search": "a"}) result = browser("tabs.query", {"search": "a"})
assert isinstance(result, list) assert isinstance(result, list)
def test_tabs_active_exists(browser): def test_tabs_active_exists(browser):
tabs = browser("tabs.list") tabs = browser("tabs.list")
active = [t for t in tabs if t.get("active")] active = [t for t in tabs if t.get("active")]
assert len(active) >= 1, "Expected at least one active tab" assert len(active) >= 1, "Expected at least one active tab"
def test_tabs_active_in_window(browser): def test_tabs_active_in_window(browser):
active = next(t for t in browser("tabs.list") if t.get("active")) active = next(t for t in browser("tabs.list") if t.get("active"))
result = browser("tabs.active_in_window", {"windowId": active["windowId"]}) result = browser("tabs.active_in_window", {"windowId": active["windowId"]})
assert result["id"] == active["id"] assert result["id"] == active["id"]
assert result["windowId"] == active["windowId"] assert result["windowId"] == active["windowId"]
def test_tabs_status(browser): def test_tabs_status(browser):
result = browser("tabs.status", {}) result = browser("tabs.status", {})
assert isinstance(result, dict) assert isinstance(result, dict)
assert "id" in result assert "id" in result
assert "muted" in result assert "muted" in result
def test_tabs_html(browser, http_tab): def test_tabs_html(browser, http_tab):
html = browser("tabs.html", {"tabId": http_tab["id"]}) html = browser("tabs.html", {"tabId": http_tab["id"]})
assert isinstance(html, str) assert isinstance(html, str)
assert len(html) > 0 assert len(html) > 0
assert "<html" in html.lower() or "<!doctype" in html.lower() assert "<html" in html.lower() or "<!doctype" in html.lower()
def test_tabs_close_by_id(browser): def test_tabs_close_by_id(browser):
result = browser("navigate.open", {"url": "https://example.com", "background": True}) result = browser("navigate.open", {"url": "https://example.com", "background": True})
tab_id = result["id"] tab_id = result["id"]
@@ -74,7 +65,6 @@ def test_tabs_close_by_id(browser):
tabs = browser("tabs.list") tabs = browser("tabs.list")
assert tab_id not in [t["id"] for t in tabs] assert tab_id not in [t["id"] for t in tabs]
def test_tabs_dedupe(browser): def test_tabs_dedupe(browser):
# Open the same URL twice # Open the same URL twice
r1 = browser("navigate.open", {"url": "https://example.com", "background": True}) r1 = browser("navigate.open", {"url": "https://example.com", "background": True})
@@ -97,13 +87,11 @@ def test_tabs_dedupe(browser):
except Exception: except Exception:
pass pass
def test_tabs_sort(browser): def test_tabs_sort(browser):
result = browser("tabs.sort", {"by": "domain"}) result = browser("tabs.sort", {"by": "domain"})
# No error and at least returns something (None or dict) # No error and at least returns something (None or dict)
assert result is None or isinstance(result, dict) assert result is None or isinstance(result, dict)
def test_tabs_move_forward(browser): def test_tabs_move_forward(browser):
r1 = browser("navigate.open", {"url": "https://example.com", "background": True}) r1 = browser("navigate.open", {"url": "https://example.com", "background": True})
r2 = browser("navigate.open", {"url": "https://example.com", "background": True}) r2 = browser("navigate.open", {"url": "https://example.com", "background": True})
@@ -116,13 +104,11 @@ def test_tabs_move_forward(browser):
browser("tabs.close", {"tabId": id1}) browser("tabs.close", {"tabId": id1})
browser("tabs.close", {"tabId": id2}) browser("tabs.close", {"tabId": id2})
def test_tabs_merge_windows_no_crash(browser): def test_tabs_merge_windows_no_crash(browser):
result = browser("tabs.merge_windows") result = browser("tabs.merge_windows")
assert isinstance(result, dict) assert isinstance(result, dict)
assert "moved" in result assert "moved" in result
def test_tabs_mute_and_unmute(browser, http_tab): def test_tabs_mute_and_unmute(browser, http_tab):
muted = browser("tabs.mute", {"tabId": http_tab["id"]}) muted = browser("tabs.mute", {"tabId": http_tab["id"]})
assert isinstance(muted, dict) assert isinstance(muted, dict)
@@ -142,7 +128,6 @@ def test_tabs_mute_and_unmute(browser, http_tab):
status = browser("tabs.status", {"tabId": http_tab["id"]}) status = browser("tabs.status", {"tabId": http_tab["id"]})
assert status["muted"] is False assert status["muted"] is False
def test_tabs_mute_requires_explicit_tab_when_multiple_tabs_open(browser): def test_tabs_mute_requires_explicit_tab_when_multiple_tabs_open(browser):
opened = browser("navigate.open", {"url": "https://example.com", "background": True}) opened = browser("navigate.open", {"url": "https://example.com", "background": True})
try: try:
Generated
+144 -8
View File
@@ -4,7 +4,7 @@ requires-python = ">=3.10"
[[package]] [[package]]
name = "browser-cli" name = "browser-cli"
version = "0.9.9" version = "0.10.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },
@@ -15,6 +15,7 @@ dependencies = [
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-cov" },
] ]
[package.metadata] [package.metadata]
@@ -25,7 +26,10 @@ requires-dist = [
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=8" }] dev = [
{ name = "pytest", specifier = ">=8" },
{ name = "pytest-cov", specifier = ">=7.1.0" },
]
[[package]] [[package]]
name = "cffi" name = "cffi"
@@ -111,14 +115,14 @@ wheels = [
[[package]] [[package]]
name = "click" name = "click"
version = "8.3.3" version = "8.4.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" }, { name = "colorama", marker = "sys_platform == 'win32'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } sdist = { url = "https://files.pythonhosted.org/packages/23/e4/796662cd90cf80e3a363c99db2b88e0e394b988a575f60a17e16440cd011/click-8.4.0.tar.gz", hash = "sha256:638f1338fe1235c8f4e008e4a8a254fb5c5fbdcbb40ece3c9142ebb78e792973", size = 350843, upload-time = "2026-05-17T00:47:58.425Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, { url = "https://files.pythonhosted.org/packages/ee/ae/8e92f8058baf87f6c7d86ee7e457668690195cc77efedb8d3797a06e3940/click-8.4.0-py3-none-any.whl", hash = "sha256:40c50b7c6c6adac2823d411041ec84f3f103f1b280d5e9ce0d7f998995832f81", size = 116147, upload-time = "2026-05-17T00:47:56.842Z" },
] ]
[[package]] [[package]]
@@ -130,6 +134,124 @@ 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 = "coverage"
version = "7.14.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/9d/7c83ef51c3eb495f10010094e661833588b7709946da634c8b66520b97c7/coverage-7.14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:84c32d90bf4537f0e7b4dec9aaa9a938fb8205136b9d2ecf4d7629d5262dc075", size = 219668, upload-time = "2026-05-10T17:59:23.106Z" },
{ url = "https://files.pythonhosted.org/packages/24/34/898546aefbd28f0af131201d0dc852c9e976f817bd7d5bfb8dc4e02863bb/coverage-7.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7c843572c605ab51cfdb5c6b5f2586e2a8467c0d28eca4bdef4ec70c5fecbd82", size = 220192, upload-time = "2026-05-10T17:59:26.095Z" },
{ url = "https://files.pythonhosted.org/packages/df/4a/b457c88aca72b0df13a98167ebd5d947135ccd9881ea88ce6a570e13aa9b/coverage-7.14.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0c451757d3fa2603354fdc789b5e58a0e327a117c370a40e3476ba4eabab228c", size = 246932, upload-time = "2026-05-10T17:59:27.806Z" },
{ url = "https://files.pythonhosted.org/packages/b5/d9/92600e89486fd074c50f0117422b2c9592c3e144e2f25bd5ac0bc62bc7a0/coverage-7.14.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3fd43f0616e765ab78d069cf8358def7363957a45cee446d65c502dcfeea7893", size = 248762, upload-time = "2026-05-10T17:59:29.479Z" },
{ url = "https://files.pythonhosted.org/packages/0d/e1/9ea1eb9c311da7f15853559dc1d9d82bef88ecd3e59fbeb51f16bc2ffa91/coverage-7.14.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:731e535b1498b27d13594a0527a79b0510867b0ad891532be41cb883f2128e20", size = 250625, upload-time = "2026-05-10T17:59:31.33Z" },
{ url = "https://files.pythonhosted.org/packages/a5/03/57afca1b8106f8549a5329139315041fe166d6099bd9381346b9430dfbd1/coverage-7.14.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c7492f2d493b976941c7ca050f273cbda2f43c381124f7586a3e3c16d1804fec", size = 252539, upload-time = "2026-05-10T17:59:32.692Z" },
{ url = "https://files.pythonhosted.org/packages/57/5e/2e9fc63c9928119c1dbae02222be51407d3e7ebac5811ebbda4af3557795/coverage-7.14.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc38367eaa2abb1b766ac333142bce7655335a73537f5c8b75aaa89c2b987757", size = 247636, upload-time = "2026-05-10T17:59:34.599Z" },
{ url = "https://files.pythonhosted.org/packages/f0/e2/0b7898cda21041cc67546e19b80ba66cbbb47cbece52a76a5904de6a3aaf/coverage-7.14.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0a951308cde22cf77f953955a754d04dccb57fe3bb8e345d685778ed9fc1632a", size = 248666, upload-time = "2026-05-10T17:59:36.232Z" },
{ url = "https://files.pythonhosted.org/packages/d6/e3/d33662a2fdaef23229c15921f39c84ec38441f3069ba26e134ed402c833b/coverage-7.14.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fab3877e4ebb06bd9d4d4d00ee53309ee5478e66873c66a382272e3ee33eb7ea", size = 246670, upload-time = "2026-05-10T17:59:38.029Z" },
{ url = "https://files.pythonhosted.org/packages/99/b2/533942c3bfbf6770b5c32d7f2ff029fe013dba31f3fe8b45cabbb250365e/coverage-7.14.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:b812eb847b19876ebf33fb6c4f11819af05ab6050b0bfa1bc53412ae81779adb", size = 250484, upload-time = "2026-05-10T17:59:39.974Z" },
{ url = "https://files.pythonhosted.org/packages/d8/00/15acbad83a96de13c73831486c7627bfed73dfaec53b04e4a6315edf3fd8/coverage-7.14.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d9c8ef6ed820c433de075657d72dda1f89a2984955e58b8a75feb3f184250218", size = 246942, upload-time = "2026-05-10T17:59:41.659Z" },
{ url = "https://files.pythonhosted.org/packages/70/db/cef0228de493f2c740c760a9057a61d00c6849480073b70a75b87c7d4bab/coverage-7.14.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d128b1bba9361fbaaf6a19e179e6cfd6a9103ce0c0555876f72780acc93efd85", size = 247544, upload-time = "2026-05-10T17:59:43.471Z" },
{ url = "https://files.pythonhosted.org/packages/77/a0/d9ef8e148f3025c2ae8401d77cda1502b6d2a4d8102603a8af31460aedb6/coverage-7.14.0-cp310-cp310-win32.whl", hash = "sha256:65f267ca1370726ec2c1aa38bbe4df9a71a740f22878d2d4bf59d71a4cd8d323", size = 222285, upload-time = "2026-05-10T17:59:44.908Z" },
{ url = "https://files.pythonhosted.org/packages/85/c0/30c454c7d3cf47b2805d4e06f12443f5eece8a5d030d3b0350e7b74ecb49/coverage-7.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:b34ece8065914f938ed7f2c5872bb865336977a52919149846eac3744327267a", size = 223215, upload-time = "2026-05-10T17:59:46.779Z" },
{ url = "https://files.pythonhosted.org/packages/fc/e4/649c8d4f7f1709b6dbfc474358aa1bba02f67bcd52e2fec291a5014006cd/coverage-7.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a78e2a9d9c5e3b8d4ab9b9d28c985ea66fced0a7d7c2aec1f216e03a2011480", size = 219795, upload-time = "2026-05-10T17:59:48.198Z" },
{ url = "https://files.pythonhosted.org/packages/7f/8d/46692d24b3f395d4cbf17bfcc57136b4f2f9c0c0df864b0bddfc1d71a014/coverage-7.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1816c505187592dcd1c5a5f226601a549f70365fbd00930ac88b0c225b76bb4", size = 220299, upload-time = "2026-05-10T17:59:49.683Z" },
{ url = "https://files.pythonhosted.org/packages/12/c2/a40f5cb295bbcbb697a76947a56081c494c61950366294ee426ffe261099/coverage-7.14.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d8e1762f0e9cbc26ec315471e7b47855218e833cd5a032d706fbf43845d878c7", size = 250721, upload-time = "2026-05-10T17:59:51.494Z" },
{ url = "https://files.pythonhosted.org/packages/fd/35/202235eb5c3c14c212462cd91d61b7386bf8fc44bc7a77f4742d2a69174b/coverage-7.14.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9336e23e8bb3a3925398261385e2a1533957d3e760e91070dcb0e98bfa514eed", size = 252633, upload-time = "2026-05-10T17:59:53.244Z" },
{ url = "https://files.pythonhosted.org/packages/bb/80/5f596e8995785124ee191c42535664c5e62c65995b66f4ca21e28ae04c81/coverage-7.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd1169b2230f9cbe9c638ba38022ed7a2b1e641cc07f7cea0365e4be2a74980", size = 254743, upload-time = "2026-05-10T17:59:55.021Z" },
{ url = "https://files.pythonhosted.org/packages/1e/6d/0d178825be2350f0adb27984d0aa7cf84bbdab201f6fb926b535d23a8f5f/coverage-7.14.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d1bb3543b58fea74d2cd1abc4054cc927e4724687cb4560cd2ed88d2c7d820c0", size = 256700, upload-time = "2026-05-10T17:59:56.511Z" },
{ url = "https://files.pythonhosted.org/packages/19/5b/9e549c2f6e9dfea472adadba06c294e64735dabc2dd19015fac082095013/coverage-7.14.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a93bac2cb577ef60074999ed56d8a1535894398e2ed920d4185c3ec0c8864742", size = 250854, upload-time = "2026-05-10T17:59:57.94Z" },
{ url = "https://files.pythonhosted.org/packages/3d/1c/b94f9f5f36396021ee2f62c5834b12e6a3d31f0bed5d6fc6d1c3caec087c/coverage-7.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5904abf7e18cddc463219b17552229650c6b79e061d31a1059283051169cf7d5", size = 252433, upload-time = "2026-05-10T17:59:59.688Z" },
{ url = "https://files.pythonhosted.org/packages/b5/cb/d192cd8e1345eccabc32016f2d39072ecd10cb4f4b983ed8d0ebdeaf00dc/coverage-7.14.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:741f57cddc9004a8c81b084660215f33a6b597dbe62c31386b983ee26310e327", size = 250494, upload-time = "2026-05-10T18:00:01.953Z" },
{ url = "https://files.pythonhosted.org/packages/53/c5/aac9f460a41d835dbddef1d377f105f6ac2311d0f3c1588e9f51046d8813/coverage-7.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:664123feb0929d7affc135717dbd70d61d98688a08ab1e5ba464739620c6252d", size = 254261, upload-time = "2026-05-10T18:00:03.779Z" },
{ url = "https://files.pythonhosted.org/packages/23/aa/7af7c0081980a9cb3d289c5a435a4b7657dcecbd128e25c580e6a50389b5/coverage-7.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c83d2399a51bbec8429266905d33616f04bc5726b1138c35844d5fcd896b2e20", size = 250216, upload-time = "2026-05-10T18:00:05.262Z" },
{ url = "https://files.pythonhosted.org/packages/35/60/a4257538ce2f6b978aeb51870d6c4208c510928a03db7e0339bb625dccb7/coverage-7.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb2e855b87321259a037429288ae85216d191c74de3e79bf57cd2bc0761992c", size = 251125, upload-time = "2026-05-10T18:00:06.858Z" },
{ url = "https://files.pythonhosted.org/packages/a1/ab/f91af47642ec1aa53490e835a95847168d9c77fc39aa58527604c051e145/coverage-7.14.0-cp311-cp311-win32.whl", hash = "sha256:731dc15b385ac52289743d476245b61e1a2927e803bef655b52bc3b2a75a21f3", size = 222300, upload-time = "2026-05-10T18:00:08.608Z" },
{ url = "https://files.pythonhosted.org/packages/f0/f0/a71ddbd874431e7a7cd96071f0c331cfbbad07704833c765d24ffbab8a67/coverage-7.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:bfb0ed8ec5d25e93face268115d7964db9df8b9aae8edcde9ec6b16c726a7cc1", size = 223241, upload-time = "2026-05-10T18:00:10.746Z" },
{ url = "https://files.pythonhosted.org/packages/d8/6e/d9d312a5151a96cd110efee32efc3fc97b01ebd86203fe618ccb29cf4c92/coverage-7.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:7ebb1c6df9f78046a1b1e0a89674cd4bf73b7c648914eebcf976a57fd99a5627", size = 221908, upload-time = "2026-05-10T18:00:12.242Z" },
{ url = "https://files.pythonhosted.org/packages/09/1e/2f996b2c8415cbb6f54b0f5ec1ee850c96d7911961afb4fc05f4a89d8c58/coverage-7.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7ffd19fc8aed057fd686a17a4935eef5f9859d69208f96310e893e64b9b6ccf5", size = 219967, upload-time = "2026-05-10T18:00:13.756Z" },
{ url = "https://files.pythonhosted.org/packages/34/23/35c7aea1274aef7525bdd2dc92f710bdde6d11652239d71d1ec450067939/coverage-7.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:829994cfe1aeb773ca27bf246d4badc1e764893e3bfb98fff820fcecd1ca4662", size = 220329, upload-time = "2026-05-10T18:00:15.264Z" },
{ url = "https://files.pythonhosted.org/packages/75/cf/a8f4b43a16e194b0261257ad28ded5853ec052570afef4a84e1d81189f3b/coverage-7.14.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b4f07cf7edcb7ec39431a5074d7ea83b29a9f71fcfc494f0f40af4e65180420f", size = 251839, upload-time = "2026-05-10T18:00:17.16Z" },
{ url = "https://files.pythonhosted.org/packages/69/ff/6699e7b71e60d3049eb2bdcbc95ee3f35707b2b0e48f32e9e63d3ce30c08/coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca3d9cf2c32b521bd9518385608787fa86f38daf993695307531822c3430ed67", size = 254576, upload-time = "2026-05-10T18:00:18.829Z" },
{ url = "https://files.pythonhosted.org/packages/22/ec/c936d495fcd67f48f03a9c4ad3297ff80d1f222a5df3980f15b34c186c21/coverage-7.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92af52828e7f29d827346b0294e5a0853fa206db77db0395b282918d41e28db9", size = 255690, upload-time = "2026-05-10T18:00:20.648Z" },
{ url = "https://files.pythonhosted.org/packages/5c/42/5af63f636cc62a4a2b1b3ba9146f6ee6f53a35a50d5cefc54d5670f60999/coverage-7.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b2bb6c9d7e769360d0f20a0f219603fd64f0c8f97de17ab25853261602be0fb", size = 257949, upload-time = "2026-05-10T18:00:22.28Z" },
{ url = "https://files.pythonhosted.org/packages/26/d3/a225317bd2012132a27e1176d51660b826f99bb975876463c44ea0d7ee5a/coverage-7.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c9ed6ef99f88fb8c14aa8e2bf8eb0fe55fa2edfea68f8675d78741df1a5ac0e", size = 252242, upload-time = "2026-05-10T18:00:24.076Z" },
{ url = "https://files.pythonhosted.org/packages/f1/7f/9e65495298c3ea414742998539c37d048b5e81cc818fb1828cc6b51d10bf/coverage-7.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8231ade007f37959fbf58acc677f26b922c02eda6f0428ea307da0fd39681bf3", size = 253608, upload-time = "2026-05-10T18:00:25.588Z" },
{ url = "https://files.pythonhosted.org/packages/94/46/1522b524a35bdad22b2b8c4f9d32d0a104b524726ec380b2db68db1746f5/coverage-7.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d8b013632cc1ce1d09dbe4f32667b4d320ec2f54fc326ebeffcd0b0bcc2bb6c4", size = 251753, upload-time = "2026-05-10T18:00:27.104Z" },
{ url = "https://files.pythonhosted.org/packages/f3/e9/cdf00d38817742c541ade405e115a3f7bf36e6f2a8b99d4f209861b85a2d/coverage-7.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1733198802d71ec4c524f322e2867ee05c62e9e75df86bdca545407a221827d1", size = 255823, upload-time = "2026-05-10T18:00:29.038Z" },
{ url = "https://files.pythonhosted.org/packages/38/fc/5e7877cf5f902d08a17ff1c532511476d87e1bea355bd5028cb97f902e79/coverage-7.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:72a305291fa8ee01332f1aaf38b348ca34097f6aa0b0ef627eef2837e57bbba5", size = 251323, upload-time = "2026-05-10T18:00:30.647Z" },
{ url = "https://files.pythonhosted.org/packages/18/9d/50f05a72dff8487464fdd4178dda5daed642a060e60afb644e3d45123559/coverage-7.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcaba850dd317c65423a9d63d88f9573c53b00354d6dd95724576cc98a131595", size = 253197, upload-time = "2026-05-10T18:00:32.211Z" },
{ url = "https://files.pythonhosted.org/packages/00/3f/6f61ffe6439df266c3cf60f5c99cfaa21103d0210d706a42fc6c30683ff8/coverage-7.14.0-cp312-cp312-win32.whl", hash = "sha256:5ac83957a80d0701310e96d8bec68cdcf4f90a7674b7d13f15a344315b41ab27", size = 222515, upload-time = "2026-05-10T18:00:33.717Z" },
{ url = "https://files.pythonhosted.org/packages/85/19/93853133df2cb371083285ef6a93982a0173e7a233b0f61373ba9fd30eb2/coverage-7.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:70390b0da32cb90b501953716302906e8bcce087cb283e70d8c97729f22e92b2", size = 223324, upload-time = "2026-05-10T18:00:35.172Z" },
{ url = "https://files.pythonhosted.org/packages/74/18/9f7fe62f659f24b7a82a0be56bf94c1bd0a89e0ae7ab4c668f6e82404294/coverage-7.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:91b993743d959b8be85b4abf9d5478216a69329c321efe5be0433c1a841d691d", size = 221944, upload-time = "2026-05-10T18:00:37.014Z" },
{ url = "https://files.pythonhosted.org/packages/6b/76/b7c66ee3c66e1b0f9d894c8125983aa0c03fb2336f2fd16559f9c966157f/coverage-7.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f2bbb8254370eb4c628ff3d6fa8a7f74ddc40565394d4f7ab791d1fe568e37ef", size = 219990, upload-time = "2026-05-10T18:00:38.887Z" },
{ url = "https://files.pythonhosted.org/packages/b3/af/e567cbad5ba69c013a50146dfa886dc7193361fda77521f51274ff620e1b/coverage-7.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23b81107f46d3f21d0cbce30664fcec0f5d9f585638a67081750f99738f6bf66", size = 220365, upload-time = "2026-05-10T18:00:40.864Z" },
{ url = "https://files.pythonhosted.org/packages/44/6f/9ad575d505b4d805b254febc8a5b338a2efe278f8786e56ff1cb8413f9c3/coverage-7.14.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:22a7e06a5f11a757cdfe79018e9095f9f69ae283c5cd8123774c788deec8717b", size = 251363, upload-time = "2026-05-10T18:00:42.489Z" },
{ url = "https://files.pythonhosted.org/packages/6f/5f/b5370068b2f57787454592ed7dcd1002f0f1703b7db1fa30f6a325a4ca6e/coverage-7.14.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9d1aa57a1dc8e05bdc42e81c5d671d849577aeedf279f4c449d6d286f9ed88ca", size = 253961, upload-time = "2026-05-10T18:00:44.079Z" },
{ url = "https://files.pythonhosted.org/packages/29/1e/51adf17738976e8f2b85ddef7b7aa12a0838b056c92f175941d8862767c1/coverage-7.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c1a51bcfddf645b3bb7ec333d9e94393a8e94f55642380fa8a9a5a9e636cb7", size = 255193, upload-time = "2026-05-10T18:00:45.623Z" },
{ url = "https://files.pythonhosted.org/packages/9e/7b/5bfd7ac1df3b881c2ac7a5cbc99c7609e6296c402f5ef587cd81c6f355b3/coverage-7.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a841fae2fadcae4f438d43b6ccc4aac2ad609f47cdb6cfdce60cbb3fe5ca7bc2", size = 257326, upload-time = "2026-05-10T18:00:47.173Z" },
{ url = "https://files.pythonhosted.org/packages/7d/38/1d37d316b174fad3843a1d76dbdfe4398771c9ecd0515935dd9ece9cd627/coverage-7.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c79d2319cabef1fe8e86df73371126931550804738f78ad7d31e3aad85a67367", size = 251582, upload-time = "2026-05-10T18:00:49.152Z" },
{ url = "https://files.pythonhosted.org/packages/34/46/746704f95980ba220214e1a41e18cec5aea80a898eaa53c51bf2d645ff36/coverage-7.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b23b0c6f0b1db6ad769b7050c8b641c0bf215ded26c1816955b17b7f26edfa9", size = 253325, upload-time = "2026-05-10T18:00:51.252Z" },
{ url = "https://files.pythonhosted.org/packages/e1/b9/bbe87206d9687b192352f893797825b5f5b15ecd3aa9c68fbff0c074d77b/coverage-7.14.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:55d3089079ce181a4566b1065ab28d2575eb76d8ac8f81f4fcda2bf037fee087", size = 251291, upload-time = "2026-05-10T18:00:52.816Z" },
{ url = "https://files.pythonhosted.org/packages/46/57/b8cdb12ac0d73ef0243218bd5e22c9df8f92edab8018213a86aec67c5324/coverage-7.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:49c005cba1e2f9677fb2845dcdf9a2e72a52a17d63e8231aaaae35d9f50215ef", size = 255448, upload-time = "2026-05-10T18:00:54.548Z" },
{ url = "https://files.pythonhosted.org/packages/1f/d4/5002019538b2036ce3c84340f54d2fd5100d55b0a6b0894eee56128d03c7/coverage-7.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9117377b823daa28aa8635fbb08cda1cd6be3d7143257345459559aeef852d52", size = 251110, upload-time = "2026-05-10T18:00:56.122Z" },
{ url = "https://files.pythonhosted.org/packages/37/53/20c5009477660f084e6ed60bc02a91894b8e234e617e86ecfd9aaf78e27b/coverage-7.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b79d646cf46d5cf9a9f40281d4441df5849e445726e369006d2b117710b33fe", size = 252885, upload-time = "2026-05-10T18:00:57.967Z" },
{ url = "https://files.pythonhosted.org/packages/ae/ab/3cf6427ac9c1f1db747dbb1ce71dde47984876d4c2cfd018a3fef0a78d4d/coverage-7.14.0-cp313-cp313-win32.whl", hash = "sha256:fb609b3658479e33f9516d46f1a89dbb9b6c261366e3a11844a96ec487533dae", size = 222539, upload-time = "2026-05-10T18:00:59.581Z" },
{ url = "https://files.pythonhosted.org/packages/8f/b8/9228523e80321c2cb4880d1f589bc0171f2f71432c35118ad04dc01decce/coverage-7.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0773d8329cf32b6fd222e4b52622c61fe8d503eb966cfc8d3c3c10c96266d50e", size = 223344, upload-time = "2026-05-10T18:01:01.531Z" },
{ url = "https://files.pythonhosted.org/packages/a3/99/118daa192f95e3a6cb2740100fbf8797cda1734b4134ef0b5d501a7fa8f3/coverage-7.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:b4e26a0f1b696faf283bffe5b8569e44e336c582439df5d53281ab89ee0cba96", size = 221966, upload-time = "2026-05-10T18:01:03.16Z" },
{ url = "https://files.pythonhosted.org/packages/e6/f1/a46cc0c013be170216253184a32366d7cbdb9252feaec866b05c2d12a894/coverage-7.14.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:953f521ca9445300397e65fda3dca58b2dbd68fee983777420b57ac3c77e9f90", size = 220679, upload-time = "2026-05-10T18:01:05.058Z" },
{ url = "https://files.pythonhosted.org/packages/64/8c/9c30a3d311a34177fa432995be7fbfc64477d8bac5630bd38055b1c9b424/coverage-7.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:98af83fd65ae24b1fdd03aaead967a9f523bcd2f1aab2d4f3ffda65bb568a6f1", size = 221033, upload-time = "2026-05-10T18:01:07.002Z" },
{ url = "https://files.pythonhosted.org/packages/9a/cd/3fb5e06c3badefd0c1b47e2044fdca67f8220a4ec2e7fcfb476aa0a67c6c/coverage-7.14.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:668b92e6958c4db7cf92e81caac328dfbbdbb215db2850ad28f0cbe1eea0bfbd", size = 262333, upload-time = "2026-05-10T18:01:08.903Z" },
{ url = "https://files.pythonhosted.org/packages/a8/e6/fbc322325c7294d3e22c1ad6b79e45d0806b25228c8e5842aed6d8169aa7/coverage-7.14.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9fbd898551762dea00d3fef2b1c4f99afd2c6a3ff952ea07d60a9bd5ed4f34bc", size = 264410, upload-time = "2026-05-10T18:01:10.531Z" },
{ url = "https://files.pythonhosted.org/packages/08/92/c497b264bec1673c47cc77e26f760fcda4654cabf1f39546d1a23a3b8c35/coverage-7.14.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68af363c07ecd8d4b7d4043d85cb376d7d227eceb54e5323ee45da73dbd3e426", size = 266836, upload-time = "2026-05-10T18:01:12.19Z" },
{ url = "https://files.pythonhosted.org/packages/78/fc/045da320987f401af5d2815d351e8aa799aec859f60e29f445e3089eeedb/coverage-7.14.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e57054a583da8ac55edf24117ea4c9133032cfc4cf72aa2d48c1e5d4b52f899", size = 267974, upload-time = "2026-05-10T18:01:13.926Z" },
{ url = "https://files.pythonhosted.org/packages/1b/ae/227b1e379497fb7a4fc3286e620f80c8a1e7cec66d45695a01639eb1af65/coverage-7.14.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3499459bbcdd51a65b64c35ab7ed2764eaf3cba826e0df3f1d7fe2e102b70b", size = 261578, upload-time = "2026-05-10T18:01:15.564Z" },
{ url = "https://files.pythonhosted.org/packages/a0/f5/3570342900f2acea31d33ff1590c5d8bac1a8e1a2e1c6d34a5d5e61de681/coverage-7.14.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:45899ec2138a4346ed34d601dedf5076fb74edf2d1dd9dc76a78e82397edee90", size = 264394, upload-time = "2026-05-10T18:01:17.607Z" },
{ url = "https://files.pythonhosted.org/packages/16/29/de1bbc01c935b28f89b1dc3db85b011c055e843a8e5e3b83141c3f80af7f/coverage-7.14.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8767486808c436f05b23ab98eb963fb29185e32a9357a166971685cb3459900f", size = 262022, upload-time = "2026-05-10T18:01:19.304Z" },
{ url = "https://files.pythonhosted.org/packages/35/95/f53890b0bf2fc10ab168e05d38869215e73ca24c4cb521c3bb0eb62fe16b/coverage-7.14.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a3b5ddfd6aa7ddad53ee3edb231e88a2151507a43229b7d71b953916deca127d", size = 265732, upload-time = "2026-05-10T18:01:21.494Z" },
{ url = "https://files.pythonhosted.org/packages/ed/ea/c919e259081dd2bdf0e43b87209709ba7ec2e4117c2a7f5185379c43463c/coverage-7.14.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:63df0fe568e698e1045792399f8ab6da3a6c2dce3182813fb92afa2641087b47", size = 260921, upload-time = "2026-05-10T18:01:23.533Z" },
{ url = "https://files.pythonhosted.org/packages/1a/2c/c2831889705a81dc5d1c6ca12e4d8e9b95dfc146d153488a6c0ea685d28e/coverage-7.14.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:827d6397dbd95144939b18f89edf31f63e1f99633e8d5f32f22ba8bdda567477", size = 263109, upload-time = "2026-05-10T18:01:25.165Z" },
{ url = "https://files.pythonhosted.org/packages/5a/a9/2fcae5003cac3d63fe344d2166243c2756935f48420863c5272b240d550b/coverage-7.14.0-cp313-cp313t-win32.whl", hash = "sha256:7bf43e000d24012599b879791cff41589af90674722421ef11b11a5431920bab", size = 223212, upload-time = "2026-05-10T18:01:27.157Z" },
{ url = "https://files.pythonhosted.org/packages/3f/bb/18e94d7b14b9b398164197114a587a04ab7c9fdbe1d237eef57311c5e883/coverage-7.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3f5549365af25d770e06b1f8f5682d9a5637d06eb494db91c6fa75d3950cc917", size = 224272, upload-time = "2026-05-10T18:01:29.107Z" },
{ url = "https://files.pythonhosted.org/packages/db/56/4f14fad782b035c81c4ffd09159e7103d42bb1d93ac8496d04b90a11b7da/coverage-7.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6d160217ec6fe890f16ad3a9531761589443749e448f91986c972714fad361c8", size = 222530, upload-time = "2026-05-10T18:01:31.151Z" },
{ url = "https://files.pythonhosted.org/packages/1c/18/b9a6586d73992807c26f9a5f274131be3d76b56b18a82b9392e2a25d2e45/coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d", size = 220036, upload-time = "2026-05-10T18:01:33.057Z" },
{ url = "https://files.pythonhosted.org/packages/f3/9b/4165a1d56ddc302a0e2d518fd9d412a4fd0b57562618c78c5f21c57194f5/coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63", size = 220368, upload-time = "2026-05-10T18:01:34.705Z" },
{ url = "https://files.pythonhosted.org/packages/69/aa/c12e52a5ba148d9995229d557e3be6e554fe469addc0e9241b2f0956d8ea/coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212", size = 251417, upload-time = "2026-05-10T18:01:36.949Z" },
{ url = "https://files.pythonhosted.org/packages/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3", size = 253924, upload-time = "2026-05-10T18:01:38.985Z" },
{ url = "https://files.pythonhosted.org/packages/33/c4/59c3de0bd1b538824173fd518fed51c1ce740ca5ed68e74545983f4053a9/coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97", size = 255269, upload-time = "2026-05-10T18:01:40.957Z" },
{ url = "https://files.pythonhosted.org/packages/7b/a9/36dfa153a62040296f6e7febfdb20a5720622f6ef5a81a41e8237b9a5344/coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8", size = 257583, upload-time = "2026-05-10T18:01:42.607Z" },
{ url = "https://files.pythonhosted.org/packages/26/7b/cc2c048d4114d9ab1c2409e9ee365e5ae10736df6dffcfc9444effa6c708/coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb", size = 251434, upload-time = "2026-05-10T18:01:44.537Z" },
{ url = "https://files.pythonhosted.org/packages/ee/df/6770eaa576e604575e9a78055313250faef5faa84bd6f71a39fece519c43/coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe", size = 253280, upload-time = "2026-05-10T18:01:46.175Z" },
{ url = "https://files.pythonhosted.org/packages/ad/9e/1c0264514a3f98259a6d64765a397b2c8373e3ba59ee722a4802d3ec0c61/coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa", size = 251241, upload-time = "2026-05-10T18:01:48.732Z" },
{ url = "https://files.pythonhosted.org/packages/64/16/4efdf3e3c4079cdbf0ece56a2fea872df9e8a3e15a13a0af4400e1075944/coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5", size = 255516, upload-time = "2026-05-10T18:01:50.819Z" },
{ url = "https://files.pythonhosted.org/packages/93/69/b1de96346603881b3d1bc8d6447c83200e1c9700ffbaff926ba01ff5724c/coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c", size = 251059, upload-time = "2026-05-10T18:01:52.773Z" },
{ url = "https://files.pythonhosted.org/packages/a4/66/2881853e0363a5e0a724d1103e53650795367471b6afb234f8b49e713bc6/coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca", size = 252716, upload-time = "2026-05-10T18:01:54.506Z" },
{ url = "https://files.pythonhosted.org/packages/55/5c/0d3305d002c41dcde873dbe456491e663dc55152ca526b630b5c47efd62f/coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828", size = 222788, upload-time = "2026-05-10T18:01:56.487Z" },
{ url = "https://files.pythonhosted.org/packages/f9/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d", size = 223600, upload-time = "2026-05-10T18:01:58.497Z" },
{ url = "https://files.pythonhosted.org/packages/00/70/a18c408e674bc26281cadaedc7351f929bd2094e191e4b15271c30b084cc/coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9", size = 222168, upload-time = "2026-05-10T18:02:00.411Z" },
{ url = "https://files.pythonhosted.org/packages/3d/89/2681f071d238b62aff8dfc2ab44fc24cfdb38d1c01f391a80522ff5d3a16/coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1", size = 220766, upload-time = "2026-05-10T18:02:02.313Z" },
{ url = "https://files.pythonhosted.org/packages/bd/c7/c987babafd9207ffa1995e1ef1f9b26762cf4963aa768a66b6f0501e4616/coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c", size = 221035, upload-time = "2026-05-10T18:02:04.017Z" },
{ url = "https://files.pythonhosted.org/packages/5a/e9/d6a5ac3b333088143d6fc877d398a9a674dc03124a2f776e131f03864823/coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84", size = 262405, upload-time = "2026-05-10T18:02:05.915Z" },
{ url = "https://files.pythonhosted.org/packages/38/b1/e70838d29a7c08e22d44398a46db90815bbcbf28de06992bd9210d1a8d8e/coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436", size = 264530, upload-time = "2026-05-10T18:02:07.582Z" },
{ url = "https://files.pythonhosted.org/packages/6b/73/5c31ef97763288d03d9995152b96d5475b527c63d91c84b01caea894b83a/coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a", size = 266932, upload-time = "2026-05-10T18:02:09.401Z" },
{ url = "https://files.pythonhosted.org/packages/e1/76/dd56d80f29c5f05b4d76f7e7c6d47cafacae017189c75c5759d24f9ff0cc/coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f", size = 268062, upload-time = "2026-05-10T18:02:11.399Z" },
{ url = "https://files.pythonhosted.org/packages/6e/c7/27ba85cd5b95614f159ff93ebff1901584a8d192e2e5e24c4943a7453f59/coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb", size = 261504, upload-time = "2026-05-10T18:02:13.257Z" },
{ url = "https://files.pythonhosted.org/packages/13/2e/e8149f60ab5d5684c6eee881bdf34b127115cddbb958b196768dd9d63473/coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490", size = 264398, upload-time = "2026-05-10T18:02:15.063Z" },
{ url = "https://files.pythonhosted.org/packages/d9/7f/1261b025285323225f4b4abffa5a643649dfd67e25ddca7ebcbdea3b7cb3/coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9", size = 262000, upload-time = "2026-05-10T18:02:16.756Z" },
{ url = "https://files.pythonhosted.org/packages/d3/dc/829c54f60b9d08389439c00f813c752781c496fc5788c78d8006db4b4f2b/coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020", size = 265732, upload-time = "2026-05-10T18:02:18.817Z" },
{ url = "https://files.pythonhosted.org/packages/ed/b0/70bd1419941652fa062689cba9c3eeafb8f5e6fbb890bce41c3bdda5dbd6/coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6", size = 260847, upload-time = "2026-05-10T18:02:20.528Z" },
{ url = "https://files.pythonhosted.org/packages/f2/73/be40b2390656c654d35ea0015ea7ba3d945769cf80790ad5e0bb2d56d2ba/coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db", size = 263166, upload-time = "2026-05-10T18:02:22.337Z" },
{ url = "https://files.pythonhosted.org/packages/29/55/4a643f712fcf7cf2881f8ec1e0ccb7b164aff3108f69b51801246c8799f2/coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2", size = 223573, upload-time = "2026-05-10T18:02:24.11Z" },
{ url = "https://files.pythonhosted.org/packages/27/96/3acae5da0953be042c0b4dea6d6789d2f080701c77b88e44d5bd41b9219b/coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644", size = 224680, upload-time = "2026-05-10T18:02:25.896Z" },
{ url = "https://files.pythonhosted.org/packages/93/3d/6ab5d2dd8325d838737c6f8d83d62eb6230e0d70b87b51b57bbfd08fa767/coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b", size = 222703, upload-time = "2026-05-10T18:02:27.822Z" },
{ url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" },
]
[package.optional-dependencies]
toml = [
{ name = "tomli", marker = "python_full_version <= '3.11'" },
]
[[package]] [[package]]
name = "cryptography" name = "cryptography"
version = "48.0.0" version = "48.0.0"
@@ -213,14 +335,14 @@ wheels = [
[[package]] [[package]]
name = "markdown-it-py" name = "markdown-it-py"
version = "4.0.0" version = "4.2.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "mdurl" }, { name = "mdurl" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
] ]
[[package]] [[package]]
@@ -286,6 +408,20 @@ 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" }, { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
] ]
[[package]]
name = "pytest-cov"
version = "7.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage", extra = ["toml"] },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
]
[[package]] [[package]]
name = "rich" name = "rich"
version = "15.0.0" version = "15.0.0"