Compare commits

...

5 Commits

Author SHA1 Message Date
daniel156161 753e4c4449 update version to 0.8.6 and update requirements
Testing / test (push) Successful in 23s
Package Extension / package-extension (push) Successful in 21s
Build & Publish Package / publish (push) Successful in 22s
2026-05-02 12:44:54 +02:00
daniel156161 f1734cd2c1 make remote browser listing more simpler when giving --browser ip only shows remote browsers
Testing / test (push) Successful in 22s
2026-05-02 12:42:21 +02:00
daniel156161 22f39a1a77 fix echo of versions that uv not shows uv uv 0.9.29 and look more normal like uv 0.9.29
Testing / test (push) Successful in 37s
2026-05-02 12:26:23 +02:00
daniel156161 a9071abc9a cleanup tests
Package Extension / package-extension (push) Successful in 24s
Build & Publish Package / publish (push) Successful in 31s
Testing / test (push) Successful in 26s
2026-05-02 01:48:13 +02:00
daniel156161 edafd349df use full terminal columns for completion test and add native host app as a single script wraper for native host app import 2026-05-02 01:14:28 +02:00
15 changed files with 149 additions and 112 deletions
+62 -32
View File
@@ -6,7 +6,6 @@ import click
import sys import sys
import os import os
import json import json
import stat
import shutil import shutil
import re import re
from importlib.metadata import PackageNotFoundError, version as package_version from importlib.metadata import PackageNotFoundError, version as package_version
@@ -32,12 +31,28 @@ from browser_cli.client import (
active_browser_targets, active_browser_targets,
display_browser_name, display_browser_name,
save_remote_token, save_remote_token,
remote_target_for_alias,
remote_browser_targets,
) )
from browser_cli.platform import install_base_dir, is_windows from browser_cli.platform import install_base_dir, is_windows
from browser_cli.registry import load_registry from browser_cli.registry import load_registry
console = Console() console = Console()
# Click's Group.shell_complete hardcodes no limit for get_short_help_str (defaults to 45 chars);
# patch to use a wider limit so zsh completion descriptions aren't truncated.
def _patched_group_shell_complete(self, ctx, incomplete):
from click.shell_completion import CompletionItem
results = [
CompletionItem(name, help=command.get_short_help_str(limit=shutil.get_terminal_size().columns))
for name, command in self.commands.items()
if not command.hidden and name.startswith(incomplete)
]
results.extend(click.Command.shell_complete(self, ctx, incomplete))
return results
click.Group.shell_complete = _patched_group_shell_complete
NATIVE_HOST_NAME = "com.browsercli.host" NATIVE_HOST_NAME = "com.browsercli.host"
EXTENSION_ID = "bfpmkhngkjnfhabmfckgeohlilokodkg" EXTENSION_ID = "bfpmkhngkjnfhabmfckgeohlilokodkg"
@@ -97,15 +112,25 @@ def _ensure_unique_browser_alias(alias: str, target_browser: str | None) -> None
raise click.ClickException(f"Browser alias '{alias}' already exists") raise click.ClickException(f"Browser alias '{alias}' already exists")
def _native_host_wrapper_path() -> Path: def _native_host_exe() -> Path:
base_dir = install_base_dir() base = install_base_dir()
if is_windows(): if is_windows():
return base_dir / "libexec" / "native-host.cmd" return base / "libexec" / "browser-cli-native-host.cmd"
return base_dir / "libexec" / "native-host" return base / "libexec" / "browser-cli-native-host"
def _native_host_script_path() -> Path: def _write_native_host_exe(path: Path) -> None:
return _native_host_wrapper_path().with_name("native_host.py") path.parent.mkdir(parents=True, exist_ok=True)
if is_windows():
path.write_text(
f'@echo off\r\n"{sys.executable}" -c "from browser_cli.native_host import main; main()" %*\r\n',
encoding="utf-8",
)
else:
path.write_text(
f'#!{sys.executable}\nfrom browser_cli.native_host import main\nmain()\n'
)
path.chmod(path.stat().st_mode | 0o111)
def _windows_registry_views(): def _windows_registry_views():
@@ -219,12 +244,34 @@ def clients_group(ctx):
all_clients = [] all_clients = []
browser_alias = (ctx.obj or {}).get("browser")
remote = (ctx.obj or {}).get("remote") or os.environ.get("BROWSER_CLI_REMOTE") remote = (ctx.obj or {}).get("remote") or os.environ.get("BROWSER_CLI_REMOTE")
if remote: token = (ctx.obj or {}).get("token") or os.environ.get("BROWSER_CLI_TOKEN")
if not remote and browser_alias:
# --browser <host> without --remote: resolve host alias to a remote endpoint,
# then show ALL clients from that remote (not just the one resolved profile).
resolved = remote_target_for_alias(browser_alias)
if resolved:
resolved_token = token or resolved.token
try: try:
result = send_command("clients.list", profile=(ctx.obj or {}).get("browser")) targets = remote_browser_targets(resolved.remote, resolved_token)
except (BrowserNotConnected, RuntimeError) as e:
console.print(f"[red]Error:[/red] {e}")
sys.exit(1)
for target in targets:
try:
result = send_command("clients.list", profile=target.profile, remote=resolved.remote, token=resolved_token)
for c in (result or []): for c in (result or []):
c["profile"] = c.get("profile") or (ctx.obj or {}).get("browser") or "remote" c["profile"] = target.display_name
all_clients.append(c)
except (BrowserNotConnected, RuntimeError):
continue
elif remote:
try:
result = send_command("clients.list", profile=browser_alias, remote=remote, token=token)
for c in (result or []):
c["profile"] = c.get("profile") or browser_alias or "remote"
all_clients.append(c) all_clients.append(c)
except (BrowserNotConnected, RuntimeError) as e: except (BrowserNotConnected, RuntimeError) as e:
console.print(f"[red]Error:[/red] {e}") console.print(f"[red]Error:[/red] {e}")
@@ -308,22 +355,8 @@ def cmd_clients_rename(target_browser, alias):
def cmd_install(browser): def cmd_install(browser):
"""Register the native messaging host and print extension load instructions.""" """Register the native messaging host and print extension load instructions."""
# Install wrapper outside PATH — the browser uses the absolute path from the host_exe = _native_host_exe()
# native messaging manifest, so only `browser-cli` needs to be on PATH. _write_native_host_exe(host_exe)
wrapper_path = _native_host_wrapper_path()
native_host_script_path = _native_host_script_path()
wrapper_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(Path(__file__).with_name("native_host.py"), native_host_script_path)
if not is_windows():
native_host_script_path.chmod(
native_host_script_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
)
wrapper_content = f'#!/bin/sh\nexec "{sys.executable}" "{native_host_script_path}" "$@"\n'
wrapper_path.write_text(wrapper_content)
wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
else:
wrapper_content = f'@echo off\r\n"{sys.executable}" "{native_host_script_path}" %*\r\n'
wrapper_path.write_text(wrapper_content, encoding="utf-8")
# Load extension # Load extension
ext_urls = { ext_urls = {
@@ -346,14 +379,14 @@ def cmd_install(browser):
manifest = { manifest = {
"name": NATIVE_HOST_NAME, "name": NATIVE_HOST_NAME,
"description": "browser-cli native messaging host", "description": "browser-cli native messaging host",
"path": str(wrapper_path), "path": str(host_exe),
"type": "stdio", "type": "stdio",
"allowed_origins": [f"chrome-extension://{extension_id}/"], "allowed_origins": [f"chrome-extension://{extension_id}/"],
} }
installed = [] installed = []
if is_windows(): if is_windows():
manifest_dir = wrapper_path.parent manifest_dir = host_exe.parent
manifest_dir.mkdir(parents=True, exist_ok=True) manifest_dir.mkdir(parents=True, exist_ok=True)
manifest_path = manifest_dir / f"{NATIVE_HOST_NAME}.json" manifest_path = manifest_dir / f"{NATIVE_HOST_NAME}.json"
manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8") manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
@@ -380,10 +413,7 @@ def cmd_install(browser):
console.print(f"[green]✓[/green] Registered native host: {p}") console.print(f"[green]✓[/green] Registered native host: {p}")
else: else:
console.print(f"[green]✓[/green] Wrote native host manifest: {p}") console.print(f"[green]✓[/green] Wrote native host manifest: {p}")
console.print(f"[green]✓[/green] Installed native host script: {native_host_script_path}") console.print(f"[green]✓[/green] Installed native host: {host_exe}")
console.print(f"[green]✓[/green] Installed native host wrapper: {wrapper_path}")
if is_windows():
console.print("\n[green]✓[/green] Wrote native host manifest:", manifest_path)
console.print(f"\n[bold]Step 2:[/bold] Restart {browser.capitalize()} completely (quit app, then reopen)") console.print(f"\n[bold]Step 2:[/bold] Restart {browser.capitalize()} completely (quit app, then reopen)")
console.print("\n[green bold]✓ Installation complete![/green bold]") console.print("\n[green bold]✓ Installation complete![/green bold]")
-9
View File
@@ -1,9 +0,0 @@
{
"name": "com.browsercli.host",
"description": "browser-cli native messaging host",
"path": "/REPLACE_WITH_ABSOLUTE_PATH/browser-cli/libexec/native-host",
"type": "stdio",
"allowed_origins": [
"chrome-extension://REPLACE_WITH_EXTENSION_ID/"
]
}
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "browser-cli", "name": "browser-cli",
"version": "0.8.4", "version": "0.8.6",
"description": "Control your browser from the terminal via browser-cli", "description": "Control your browser from the terminal via browser-cli",
"permissions": [ "permissions": [
"tabs", "tabs",
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "browser-cli" name = "browser-cli"
version = "0.8.4" version = "0.8.6"
description = "Control your real running browser from the terminal via a browser extension" description = "Control your real running browser from the terminal via a browser extension"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
+1 -1
View File
@@ -8,7 +8,7 @@ pkgs.mkShell {
]; ];
shellHook = '' shellHook = ''
echo "browser-cli dev shell: node $(node --version), npm $(npm --version), uv $(uv --version)" echo "browser-cli dev shell: node $(node --version), npm $(npm --version), uv $(uv --version | awk '{print $2}')"
if [ -f package-lock.json ]; then if [ -f package-lock.json ]; then
if [ ! -f node_modules/.package-lock.json ] || [ package-lock.json -nt node_modules/.package-lock.json ]; then if [ ! -f node_modules/.package-lock.json ] || [ package-lock.json -nt node_modules/.package-lock.json ]; then
-1
View File
@@ -286,7 +286,6 @@ class TestTabs:
def test_tabs_filter_predicate(self, b, mock_send): def test_tabs_filter_predicate(self, b, mock_send):
mock_send.return_value = [TAB_DATA, {**TAB_DATA, "id": 11, "url": "https://youtube.com"}] mock_send.return_value = [TAB_DATA, {**TAB_DATA, "id": 11, "url": "https://youtube.com"}]
tabs = b.tabs_filter(lambda tab: "youtube" in tab.url) tabs = b.tabs_filter(lambda tab: "youtube" in tab.url)
print(tabs)
assert [tab.id for tab in tabs] == [11] assert [tab.id for tab in tabs] == [11]
def test_tabs_filter_list_transformer(self, b, mock_send): def test_tabs_filter_list_transformer(self, b, mock_send):
+74 -34
View File
@@ -80,22 +80,15 @@ def test_install_help_lists_supported_browsers():
assert "[chrome|chromium|brave|edge|vivaldi]" in result.output assert "[chrome|chromium|brave|edge|vivaldi]" in result.output
def test_install_windows_registers_native_host(tmp_path, monkeypatch): def test_install_windows_registers_native_host(tmp_path):
local_app_data = tmp_path / "LocalAppData"
extension_dir = tmp_path / "extension"
extension_dir.mkdir()
native_host_src = tmp_path / "native_host.py"
native_host_src.write_text("print('ok')", encoding="utf-8")
writes = [] writes = []
class FakeKey: class FakeKey:
def __init__(self, path): def __init__(self, path):
self.path = path self.path = path
def __enter__(self): def __enter__(self):
return self return self
def __exit__(self, _exc_type, _exc, _tb):
def __exit__(self, exc_type, exc, tb):
return False return False
fake_winreg = SimpleNamespace( fake_winreg = SimpleNamespace(
@@ -104,35 +97,47 @@ def test_install_windows_registers_native_host(tmp_path, monkeypatch):
KEY_WOW64_32KEY=0x0200, KEY_WOW64_32KEY=0x0200,
KEY_WOW64_64KEY=0x0100, KEY_WOW64_64KEY=0x0100,
REG_SZ=1, REG_SZ=1,
CreateKeyEx=lambda _root, path, _reserved, _access: FakeKey(path),
SetValueEx=lambda key, name, _reserved, _reg_type, value: writes.append((key.path, name, value)),
) )
def fake_create_key(root, path, reserved, access): host_exe = tmp_path / "browser-cli-native-host.exe"
return FakeKey(path)
def fake_set_value(key, name, reserved, reg_type, value):
writes.append((key.path, name, value))
fake_winreg.CreateKeyEx = fake_create_key
fake_winreg.SetValueEx = fake_set_value
monkeypatch.setenv("LOCALAPPDATA", str(local_app_data))
with patch("browser_cli.cli.is_windows", return_value=True), patch( with patch("browser_cli.cli.is_windows", return_value=True), patch(
"browser_cli.cli.Path.home", return_value=tmp_path "browser_cli.cli._native_host_exe", return_value=host_exe
), patch("browser_cli.cli.click.prompt", return_value="abc123"), patch( ), patch("browser_cli.cli._write_native_host_exe"), patch(
"browser_cli.cli.shutil.copy2" "browser_cli.cli.Path.write_text"
) as copy2, patch("browser_cli.cli.Path.write_text") as write_text, patch.dict( ), patch.dict(sys.modules, {"winreg": fake_winreg}):
sys.modules, {"winreg": fake_winreg}
):
copy2.side_effect = lambda src, dst: Path(dst).write_text(native_host_src.read_text(encoding="utf-8"), encoding="utf-8")
result = CliRunner().invoke(main, ["install", "edge"]) result = CliRunner().invoke(main, ["install", "edge"])
assert result.exit_code == 0 assert result.exit_code == 0
assert any("Software\\Microsoft\\Edge\\NativeMessagingHosts\\com.browsercli.host" in path for path, _, _ in writes) assert any("Software\\Microsoft\\Edge\\NativeMessagingHosts\\com.browsercli.host" in path for path, _, _ in writes)
assert "Registered native host" in result.output
assert "Wrote native host manifest" in result.output def test_write_native_host_exe_unix(tmp_path):
wrapper_writes = [call.args[0] for call in write_text.call_args_list if call.args] from browser_cli.cli import _write_native_host_exe
assert any("@echo off" in text for text in wrapper_writes)
host = tmp_path / "libexec" / "browser-cli-native-host"
with patch("browser_cli.cli.is_windows", return_value=False):
_write_native_host_exe(host)
assert host.exists()
content = host.read_text()
assert content.startswith(f"#!{sys.executable}")
assert "from browser_cli.native_host import main" in content
assert host.stat().st_mode & 0o111 # executable bit set
def test_write_native_host_exe_windows(tmp_path):
from browser_cli.cli import _write_native_host_exe
host = tmp_path / "libexec" / "browser-cli-native-host.cmd"
with patch("browser_cli.cli.is_windows", return_value=True):
_write_native_host_exe(host)
assert host.exists()
content = host.read_text(encoding="utf-8")
assert "@echo off" in content
assert "browser_cli.native_host" in content
def test_clients_exits_cleanly_when_registry_is_missing(): def test_clients_exits_cleanly_when_registry_is_missing():
with patch("browser_cli.cli.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")), patch( with patch("browser_cli.cli.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")), patch(
@@ -163,9 +168,11 @@ def test_clients_reads_registry_with_trailing_garbage(tmp_path):
assert "0.8.2" in result.output assert "0.8.2" in result.output
def test_clients_remote_uses_remote_endpoint_without_local_registry(): def test_clients_remote_uses_remote_endpoint_without_local_registry():
def fake_send_command(command, args=None, profile=None): def fake_send_command(command, args=None, profile=None, remote=None, token=None):
assert command == "clients.list" assert command == "clients.list"
assert profile is None assert profile is None
assert remote == "127.0.0.1:8765"
assert token == "test"
return [{"name": "Chrome", "version": "1", "extensionVersion": "2.3.4"}] return [{"name": "Chrome", "version": "1", "extensionVersion": "2.3.4"}]
with patch.dict(os.environ, {}, clear=True), patch( with patch.dict(os.environ, {}, clear=True), patch(
@@ -187,7 +194,41 @@ def test_clients_remote_respects_global_browser_route():
result = CliRunner().invoke(main, ["--remote", "127.0.0.1:8765", "--browser", "work", "clients"]) result = CliRunner().invoke(main, ["--remote", "127.0.0.1:8765", "--browser", "work", "clients"])
assert result.exit_code == 1 assert result.exit_code == 1
send_command.assert_called_once_with("clients.list", profile="work") send_command.assert_called_once_with("clients.list", profile="work", remote="127.0.0.1:8765", token=None)
def test_clients_browser_alias_resolves_to_remote():
"""--browser <host> without --remote resolves the alias, fetches all targets from that remote,
and shows only clients from that host (not local profiles)."""
from browser_cli.client import BrowserTarget
resolved_target = BrowserTarget(
profile="automatisation",
display_name="192.168.188.104:automatisation",
socket_path="",
remote="192.168.188.104:8765",
token="tok",
)
all_remote_targets = [resolved_target]
def fake_send_command(command, args=None, profile=None, remote=None, token=None):
assert command == "clients.list"
assert profile == "automatisation"
assert remote == "192.168.188.104:8765"
assert token == "tok"
return [{"name": "Chrome", "version": "147.0.0.0", "extensionVersion": "0.8.5"}]
with patch.dict(os.environ, {}, clear=True), patch(
"browser_cli.cli.remote_target_for_alias", return_value=resolved_target
), patch(
"browser_cli.cli.remote_browser_targets", return_value=all_remote_targets
), patch("browser_cli.cli.send_command", side_effect=fake_send_command) as send_command:
result = CliRunner().invoke(main, ["--browser", "192.168.188.104", "clients"])
assert result.exit_code == 0
send_command.assert_called_once()
assert "Chrome" in result.output
assert "0.8.5" in result.output
def test_clients_shows_named_profile_and_uses_socket_uuid_for_default(tmp_path): def test_clients_shows_named_profile_and_uses_socket_uuid_for_default(tmp_path):
@@ -357,7 +398,6 @@ def test_groups_move_accepts_left_short_alias():
) )
def test_windows_list_multi_browser_shows_browser_column(): def test_windows_list_multi_browser_shows_browser_column():
def fake_send_command(command, args=None, profile=None): def fake_send_command(command, args=None, profile=None):
assert command == "windows.list" assert command == "windows.list"
-2
View File
@@ -1,6 +1,4 @@
"""Tests for dom.* commands (require an http/https active tab).""" """Tests for dom.* commands (require an http/https active tab)."""
import pytest
from browser_cli.client import send_command
def test_dom_query_body(browser, http_tab): def test_dom_query_body(browser, http_tab):
-14
View File
@@ -1,6 +1,4 @@
"""Tests for extract.* commands (require an http/https active tab).""" """Tests for extract.* commands (require an http/https active tab)."""
import pytest
from browser_cli.client import send_command
def test_extract_links(browser, http_tab): def test_extract_links(browser, http_tab):
@@ -58,15 +56,3 @@ def test_extract_markdown_missing_selector_errors(browser, http_tab):
assert "No element" in str(exc) assert "No element" in str(exc)
def test_dom_exists(browser, http_tab):
browser("tabs.active", {"tabId": http_tab["id"]})
result = browser("dom.exists", {"selector": "body"})
assert result is True
def test_dom_query(browser, http_tab):
browser("tabs.active", {"tabId": http_tab["id"]})
elements = browser("dom.query", {"selector": "body"})
assert isinstance(elements, list)
assert len(elements) > 0
assert elements[0].get("tag") == "body"
-1
View File
@@ -1,6 +1,5 @@
"""Tests for group.* commands.""" """Tests for group.* commands."""
import pytest import pytest
from browser_cli.client import send_command
def test_group_list(browser): def test_group_list(browser):
-2
View File
@@ -1,6 +1,4 @@
"""Tests for navigate.* commands.""" """Tests for navigate.* commands."""
import pytest
from browser_cli.client import send_command
def test_nav_open_and_close(browser): def test_nav_open_and_close(browser):
-1
View File
@@ -1,6 +1,5 @@
"""Tests for session.* commands.""" """Tests for session.* commands."""
import time import time
import pytest
from browser_cli.client import send_command from browser_cli.client import send_command
from tests.conftest import TEST_BROWSER_PROFILE from tests.conftest import TEST_BROWSER_PROFILE
-1
View File
@@ -1,6 +1,5 @@
"""Tests for tabs.* commands.""" """Tests for tabs.* commands."""
import pytest import pytest
from browser_cli.client import send_command
def test_tabs_list(browser): def test_tabs_list(browser):
-2
View File
@@ -1,6 +1,4 @@
"""Tests for windows.* commands.""" """Tests for windows.* commands."""
import pytest
from browser_cli.client import send_command
def test_windows_list(browser): def test_windows_list(browser):
Generated
+10 -10
View File
@@ -4,7 +4,7 @@ requires-python = ">=3.10"
[[package]] [[package]]
name = "browser-cli" name = "browser-cli"
version = "0.8.4" version = "0.8.6"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },
@@ -27,14 +27,14 @@ dev = [{ name = "pytest", specifier = ">=8" }]
[[package]] [[package]]
name = "click" name = "click"
version = "8.3.2" version = "8.3.3"
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/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } 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" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" }, { 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" },
] ]
[[package]] [[package]]
@@ -90,11 +90,11 @@ wheels = [
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "26.0" version = "26.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
] ]
[[package]] [[package]]
@@ -135,15 +135,15 @@ wheels = [
[[package]] [[package]]
name = "rich" name = "rich"
version = "14.3.3" version = "15.0.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "markdown-it-py" }, { name = "markdown-it-py" },
{ name = "pygments" }, { name = "pygments" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
] ]
[[package]] [[package]]