refactor(api): namespaced SDK + dedicated transport layer
Testing / remote-protocol-compat (0.9.3) (push) Successful in 42s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 44s
Package Extension / package-extension (push) Successful in 43s
Build & Publish Package / publish (push) Successful in 43s
Testing / test (push) Successful in 45s

Restructure the Python API and internals around composable namespaces and
a standalone transport/endpoint layer. Bump to 0.12.0.

Python API:
- Replace flat methods (b.tabs_list(), b.group_list()) with namespaces:
  b.nav, b.tabs, b.groups, b.windows, b.dom, b.extract, b.page, b.storage,
  b.cookies, b.session, b.perf, b.extension.
- Shrink browser_cli/__init__.py to a thin composition root; move all
  behaviour into browser_cli/sdk/ (one module per namespace + factories,
  base, routing).

Internals:
- Add browser_cli/transport.py and remote_transport.py to isolate IPC from
  command logic; client.py now delegates instead of owning transport.
- Add browser_cli/endpoints.py for endpoint resolution and
  browser_cli/errors.py for shared error types.
- Extract markdown rendering into browser_cli/markdown.py (out of extract).
- Add USER_AGENT to version_manager.

Tooling & tests:
- Add justfile with common dev tasks.
- Update CLI commands and demo to the namespaced API.
- Rework tests for the new layout; add test_transport.py and
  test_refactor_boundaries.py to lock in module boundaries.

BREAKING CHANGE: flat API methods are removed in favour of namespaces
(e.g. b.tabs_list() -> b.tabs.list(), b.group_list() -> b.groups.list()).
This commit is contained in:
2026-06-11 13:58:41 +02:00
parent 0813ae2de9
commit fd5447cbb9
52 changed files with 3344 additions and 2348 deletions
+125 -7
View File
@@ -6,6 +6,7 @@ import threading
import pytest
from browser_cli import transport
from browser_cli.auth import generate_keypair, load_private_key, new_nonce, pq_decrypt, pq_encrypt, sign
from browser_cli.client import BrowserNotConnected
from browser_cli.commands.serve import _handle_client
@@ -18,7 +19,6 @@ FAKE_UA = "browser-cli/0.9.3"
def _send_framed(sock: socket.socket, data: bytes) -> None:
sock.sendall(struct.pack("<I", len(data)) + data)
def _recv_framed(sock: socket.socket) -> dict:
raw = b""
while len(raw) < 4:
@@ -35,7 +35,6 @@ def _recv_framed(sock: socket.socket) -> dict:
data += chunk
return json.loads(data)
def _spawn(server_sock: socket.socket, auth_keys_path) -> threading.Thread:
t = threading.Thread(
target=_handle_client,
@@ -45,15 +44,12 @@ def _spawn(server_sock: socket.socket, auth_keys_path) -> threading.Thread:
t.start()
return t
def _pair():
return socket.socketpair()
def _mock_no_browser(*_args, **_kwargs):
raise BrowserNotConnected("no browser")
# ── challenge frame ────────────────────────────────────────────────────────────
class TestChallenge:
@@ -89,7 +85,6 @@ class TestChallenge:
client.close()
t.join(timeout=2)
# ── rejection paths ────────────────────────────────────────────────────────────
class TestRejection:
@@ -199,7 +194,6 @@ class TestRejection:
client.close()
t.join(timeout=2)
# ── auth success paths ─────────────────────────────────────────────────────────
class TestAuthSuccess:
@@ -371,3 +365,127 @@ class TestAuthSuccess:
assert "unauthorized" not in resp.get("error", "").lower()
client.close()
t.join(timeout=2)
# ── response encoding (compression / msgpack) ───────────────────────────────────
def _recv_framed_raw(sock: socket.socket) -> bytes:
raw = b""
while len(raw) < 4:
chunk = sock.recv(4 - len(raw))
if not chunk:
raise ConnectionError("socket closed before response header")
raw += chunk
n = struct.unpack("<I", raw)[0]
data = b""
while len(data) < n:
chunk = sock.recv(n - len(data))
if not chunk:
raise ConnectionError("socket closed mid-response")
data += chunk
return data
class _FakeNativeHost:
"""Minimal AF_UNIX server speaking the 4-byte framed protocol the proxy expects."""
def __init__(self, sock_path, response_obj):
self.sock_path = str(sock_path)
self.response = json.dumps(response_obj).encode()
self.srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.srv.bind(self.sock_path)
self.srv.listen(1)
self.thread = threading.Thread(target=self._serve, daemon=True)
self.thread.start()
def _serve(self):
try:
conn, _ = self.srv.accept()
with conn:
hdr = conn.recv(4)
n = struct.unpack("<I", hdr)[0]
got = b""
while len(got) < n:
got += conn.recv(n - len(got))
conn.sendall(struct.pack("<I", len(self.response)) + self.response)
except OSError:
pass
def close(self):
self.srv.close()
class TestResponseEncoding:
def test_client_accept_encoding_yields_decodable_tagged_response(self, tmp_path, monkeypatch):
if socket.AF_UNIX is None: # pragma: no cover
pytest.skip("AF_UNIX unavailable")
big = {"id": "x", "success": True,
"data": {"items": [{"url": f"https://example.com/{i}", "title": f"Tab {i}"} for i in range(300)]}}
host_path = tmp_path / "native.sock"
host = _FakeNativeHost(host_path, big)
monkeypatch.setattr("browser_cli.client._resolve_socket", lambda *_a, **_k: str(host_path))
client, server = _pair()
t = _spawn(server, None) # no auth
_recv_framed(client) # challenge
msg = {
"id": "x", "command": "tabs.list", "args": {},
"user_agent": "browser-cli/0.9.5",
"accept_encoding": {"ser": ["json"], "comp": ["gzip"]},
}
_send_framed(client, json.dumps(msg).encode())
raw = _recv_framed_raw(client)
assert raw[:1] not in (b"{", b"[") # tagged, not plain JSON
assert len(raw) < len(json.dumps(big)) # compressed
assert transport.decode_response(raw) == big
client.close()
host.close()
t.join(timeout=2)
def test_no_accept_encoding_stays_plain_json(self, tmp_path, monkeypatch):
big = {"id": "y", "success": True, "data": {"items": list(range(500))}}
host_path = tmp_path / "native2.sock"
host = _FakeNativeHost(host_path, big)
monkeypatch.setattr("browser_cli.client._resolve_socket", lambda *_a, **_k: str(host_path))
client, server = _pair()
t = _spawn(server, None)
_recv_framed(client)
msg = {"id": "y", "command": "tabs.list", "args": {}, "user_agent": "browser-cli/0.9.5"}
_send_framed(client, json.dumps(msg).encode())
raw = _recv_framed_raw(client)
assert raw[:1] in (b"{", b"[") # old client → plain JSON
assert json.loads(raw) == big
client.close()
host.close()
t.join(timeout=2)
def test_no_compress_flag_forces_plain_json(self, tmp_path, monkeypatch):
big = {"id": "z", "success": True,
"data": {"items": [{"url": f"https://e/{i}"} for i in range(300)]}}
host_path = tmp_path / "native3.sock"
host = _FakeNativeHost(host_path, big)
monkeypatch.setattr("browser_cli.client._resolve_socket", lambda *_a, **_k: str(host_path))
client, server = _pair()
t = threading.Thread(
target=_handle_client,
args=(server, ("127.0.0.1", 9999), None, None, False), # compress=False
daemon=True,
)
t.start()
_recv_framed(client)
msg = {"id": "z", "command": "tabs.list", "args": {},
"user_agent": "browser-cli/0.9.5",
"accept_encoding": {"ser": ["msgpack"], "comp": ["zstd"]}}
_send_framed(client, json.dumps(msg).encode())
raw = _recv_framed_raw(client)
assert raw[:1] in (b"{", b"[") # server disabled encoding
assert json.loads(raw) == big
client.close()
host.close()
t.join(timeout=2)