mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
refactor(zap): consume canonical zap-proto, drop hand-rolled wire
hanzo-tools-browser: the zapd router envelope (frame.rs mirror) is no longer reimplemented — zapd_consumer now uses zap.frame + zap.ZapClient (dep zap-proto>=1.2.0). The end-to-end browser command codec stays local and untagged to match the extension's decodeCmd (frame.encode_cmd is tagged and would corrupt it); a new test proves the byte layout. hanzo-zap: wire.py now delegates the zero-copy codec (Message/Builder/ ObjectBuilder/object readers) to canonical zap.wire instead of a 3rd hand-rolled copy; the hanzo cloud-service schema + handshake + frame I/O remain as a thin layer. dep zap-proto>=1.2.0. 114 tests pass.
This commit is contained in:
@@ -5,137 +5,76 @@ router at ``~/.zap/run/zapd.sock`` as a *consumer*, lists providers, and routes
|
||||
opaque commands to a ``browser:*`` provider. No WebSocket, no mDNS, no CDP
|
||||
bridge, no :9224, no Playwright fallback for native-browser mode.
|
||||
|
||||
The wire is the binary ZAP router envelope (mirrors zapd's ``frame.rs``); the
|
||||
browser command payload is the compact binary codec shared with the extension.
|
||||
The router envelope and its control bodies (HELLO/WELCOME/PROVIDERS/ROUTE/…) are
|
||||
NOT reimplemented here — they come from the canonical ``zap`` package
|
||||
(``zap.frame`` + :class:`zap.ZapClient`), the one Python copy of the wire. This
|
||||
module is a thin *policy* layer over that client: lazy connect, provider
|
||||
resolution for ``browser:*``, and the method+params convenience for routing.
|
||||
|
||||
The only codec that lives here is the end-to-end **browser command payload**
|
||||
(:func:`_encode_cmd`). The router forwards it opaquely; the peer that decodes it
|
||||
is the browser extension (``extension/.../shared/native-zap.ts``), whose
|
||||
``decodeCmd`` reads an *untagged* ``method + params(key→u32-len value)`` body.
|
||||
That is deliberately NOT ``zap.frame.encode_cmd`` — the canonical helper inserts
|
||||
a per-value type tag (to round-trip str vs bytes between Python peers) which the
|
||||
extension does not read. Encoding with the tagged helper would shift every
|
||||
param by one byte and corrupt the command, so this peer keeps the untagged form.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
import struct
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
# Envelope types (match frame.rs).
|
||||
HELLO, WELCOME, PROVIDERS_LIST, PROVIDERS = 1, 2, 3, 4
|
||||
PEER_CONNECTED, PEER_DISCONNECTED, ERROR = 5, 6, 7
|
||||
ROUTE, RESPONSE, EVENT = 16, 17, 18
|
||||
ROLE_CONSUMER = 2
|
||||
from zap import frame
|
||||
from zap.client import ZapClient
|
||||
|
||||
|
||||
def socket_path() -> str:
|
||||
p = os.environ.get("ZAP_SOCK")
|
||||
if p:
|
||||
return p
|
||||
xrd = os.environ.get("XDG_RUNTIME_DIR")
|
||||
if xrd:
|
||||
return os.path.join(xrd, "zap", "zapd.sock")
|
||||
return os.path.expanduser("~/.zap/run/zapd.sock")
|
||||
|
||||
|
||||
def _put_str(s: str) -> bytes:
|
||||
b = s.encode()
|
||||
return struct.pack("<H", len(b)) + b
|
||||
|
||||
|
||||
def _encode_frame(typ, frm, to, payload=b"", flags=0) -> bytes:
|
||||
fb, tb = frm.encode(), to.encode()
|
||||
body = struct.pack("<BHHHI", typ, flags, len(fb), len(tb), len(payload)) + fb + tb + payload
|
||||
return struct.pack("<I", len(body)) + body
|
||||
|
||||
|
||||
def _hello_payload(role, brand, caps) -> bytes:
|
||||
b = bytes([role]) + _put_str(brand) + struct.pack("<H", len(caps))
|
||||
for c in caps:
|
||||
b += _put_str(c)
|
||||
return b
|
||||
"""The brand-neutral zapd socket; delegates to the canonical resolver."""
|
||||
return frame.socket_path()
|
||||
|
||||
|
||||
def _encode_cmd(method: str, params: dict) -> bytes:
|
||||
b = _put_str(method) + struct.pack("<H", len(params))
|
||||
"""Untagged browser command body — peer of the extension's ``decodeCmd``.
|
||||
|
||||
Layout (little-endian): ``method(u16 len + bytes)`` then ``u16`` param count,
|
||||
each param ``key(u16 len + bytes) + value(u32 len + bytes)``. No type tag —
|
||||
see the module docstring for why this is not ``frame.encode_cmd``.
|
||||
"""
|
||||
b = frame._put_str(method) + struct.pack("<H", len(params))
|
||||
for k, v in params.items():
|
||||
vb = v.encode() if isinstance(v, str) else bytes(v)
|
||||
b += _put_str(k) + struct.pack("<I", len(vb)) + vb
|
||||
b += frame._put_str(k) + struct.pack("<I", len(vb)) + vb
|
||||
return b
|
||||
|
||||
|
||||
def _parse_providers(pay: bytes):
|
||||
(n,) = struct.unpack_from("<H", pay, 0)
|
||||
o = 2
|
||||
out = []
|
||||
for _ in range(n):
|
||||
(idl,) = struct.unpack_from("<H", pay, o); o += 2
|
||||
pid = pay[o : o + idl].decode(); o += idl
|
||||
role = pay[o]; o += 1
|
||||
(bl,) = struct.unpack_from("<H", pay, o); o += 2
|
||||
brand = pay[o : o + bl].decode(); o += bl
|
||||
(cn,) = struct.unpack_from("<H", pay, o); o += 2
|
||||
caps = []
|
||||
for _ in range(cn):
|
||||
(cl,) = struct.unpack_from("<H", pay, o); o += 2
|
||||
caps.append(pay[o : o + cl].decode()); o += cl
|
||||
out.append({"id": pid, "role": role, "brand": brand, "caps": caps})
|
||||
return out
|
||||
|
||||
|
||||
class ZapdConsumer:
|
||||
"""A persistent consumer connection to the local zapd router."""
|
||||
|
||||
def __init__(self, agent_id: Optional[str] = None):
|
||||
import os
|
||||
|
||||
self.agent_id = agent_id or f"consumer:hanzo-mcp/{os.getpid()}"
|
||||
self._sock: Optional[socket.socket] = None
|
||||
self._lock = threading.Lock()
|
||||
self._client: Optional[ZapClient] = None
|
||||
|
||||
def connect(self) -> bool:
|
||||
if self._sock is not None:
|
||||
if self._client is not None:
|
||||
return True
|
||||
try:
|
||||
s = socket.socket(socket.AF_UNIX)
|
||||
s.settimeout(5)
|
||||
s.connect(socket_path())
|
||||
s.sendall(_encode_frame(HELLO, self.agent_id, "", _hello_payload(ROLE_CONSUMER, "hanzo", [])))
|
||||
self._sock = s
|
||||
self._read_until(WELCOME)
|
||||
self._client = ZapClient.connect(
|
||||
id=self.agent_id, role="consumer", brand="hanzo", timeout=5.0
|
||||
)
|
||||
return True
|
||||
except OSError:
|
||||
self._sock = None
|
||||
self._client = None
|
||||
return False
|
||||
|
||||
def _recvn(self, n) -> bytes:
|
||||
buf = b""
|
||||
while len(buf) < n:
|
||||
c = self._sock.recv(n - len(buf))
|
||||
if not c:
|
||||
raise EOFError("zapd closed")
|
||||
buf += c
|
||||
return buf
|
||||
|
||||
def _read_frame(self) -> dict:
|
||||
(length,) = struct.unpack("<I", self._recvn(4))
|
||||
buf = self._recvn(length)
|
||||
typ, flags, fl, tl, pl = struct.unpack_from("<BHHHI", buf, 0)
|
||||
o = 11
|
||||
frm = buf[o : o + fl].decode(); o += fl
|
||||
to = buf[o : o + tl].decode(); o += tl
|
||||
return {"t": typ, "from": frm, "to": to, "payload": buf[o : o + pl]}
|
||||
|
||||
def _read_until(self, want, from_id=None) -> dict:
|
||||
for _ in range(100):
|
||||
f = self._read_frame()
|
||||
if f["t"] == want and (from_id is None or f["from"] == from_id):
|
||||
return f
|
||||
if f["t"] == ERROR:
|
||||
raise RuntimeError(f["payload"].decode(errors="replace"))
|
||||
raise TimeoutError(f"no frame type {want}")
|
||||
|
||||
def list_providers(self, brand: str = "") -> list:
|
||||
with self._lock:
|
||||
if not self.connect():
|
||||
return []
|
||||
payload = _put_str(brand) if brand else b""
|
||||
self._sock.sendall(_encode_frame(PROVIDERS_LIST, self.agent_id, "", payload))
|
||||
f = self._read_until(PROVIDERS)
|
||||
return _parse_providers(f["payload"])
|
||||
if not self.connect():
|
||||
return []
|
||||
assert self._client is not None
|
||||
provs = self._client.providers_list(brand=brand)
|
||||
return [{"id": p.id, "role": p.role, "brand": p.brand, "caps": p.caps} for p in provs]
|
||||
|
||||
def resolve_browser(self, browser: Optional[str], client_id: Optional[str]) -> Optional[str]:
|
||||
provs = [p for p in self.list_providers() if p["id"].startswith("browser:")]
|
||||
@@ -152,20 +91,17 @@ class ZapdConsumer:
|
||||
return provs[0]["id"] if provs else None
|
||||
|
||||
def route(self, provider_id: str, method: str, params: dict, timeout: float = 30.0) -> bytes:
|
||||
with self._lock:
|
||||
if not self.connect():
|
||||
raise RuntimeError("zapd not reachable")
|
||||
self._sock.settimeout(timeout)
|
||||
self._sock.sendall(_encode_frame(ROUTE, self.agent_id, provider_id, _encode_cmd(method, params)))
|
||||
f = self._read_until(RESPONSE, from_id=provider_id)
|
||||
return f["payload"]
|
||||
if not self.connect():
|
||||
raise RuntimeError("zapd not reachable")
|
||||
assert self._client is not None
|
||||
return self._client.route(provider_id, _encode_cmd(method, params), timeout=timeout)
|
||||
|
||||
def close(self):
|
||||
if self._sock is not None:
|
||||
if self._client is not None:
|
||||
try:
|
||||
self._sock.close()
|
||||
self._client.close()
|
||||
finally:
|
||||
self._sock = None
|
||||
self._client = None
|
||||
|
||||
|
||||
_consumer: Optional[ZapdConsumer] = None
|
||||
|
||||
@@ -15,6 +15,9 @@ dependencies = [
|
||||
"hanzo-tools>=0.3.0",
|
||||
"mcp>=1.25.0",
|
||||
"pydantic>=2.12.5",
|
||||
# Canonical ZAP router envelope (zap.frame + ZapClient). The zapd wire is
|
||||
# imported from here, never reimplemented in this package.
|
||||
"zap-proto>=1.2.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tests for hanzo-tools-browser."""
|
||||
|
||||
import struct
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -111,3 +113,54 @@ class TestCdpTool:
|
||||
)
|
||||
assert sent["method"] == "Page.navigate"
|
||||
assert sent["params"]["url"] == "https://example.com"
|
||||
|
||||
|
||||
class TestZapdWire:
|
||||
"""The consumer delegates the router envelope to canonical ``zap.frame`` and
|
||||
keeps only the untagged browser-command codec the extension peer decodes."""
|
||||
|
||||
def test_uses_canonical_frame(self):
|
||||
# No hand-rolled envelope: socket_path and the client come from zap.
|
||||
from hanzo_tools.browser import zapd_consumer as zc
|
||||
from zap import frame
|
||||
from zap.client import ZapClient
|
||||
|
||||
assert zc.socket_path() == frame.socket_path()
|
||||
assert zc.ZapClient is ZapClient
|
||||
# The duplicated framing primitives are gone from this module.
|
||||
for gone in ("_encode_frame", "_hello_payload", "_parse_providers", "_read_frame"):
|
||||
assert not hasattr(zc, gone), f"{gone} should be deleted (use zap.frame)"
|
||||
|
||||
def test_cmd_codec_is_extension_compatible_untagged(self):
|
||||
"""``_encode_cmd`` must match the extension's ``decodeCmd`` byte layout:
|
||||
method + u16 count + per-param(key + u32 len + value), NO type tag.
|
||||
|
||||
This is intentionally NOT ``zap.frame.encode_cmd`` (which inserts a
|
||||
per-value tag byte). Decode it here exactly as native-zap.ts does and
|
||||
confirm the round trip; then confirm the tagged helper differs.
|
||||
"""
|
||||
from hanzo_tools.browser.zapd_consumer import _encode_cmd
|
||||
from zap import frame
|
||||
|
||||
method, params = "Page.navigate", {"url": "https://example.com", "tabId": "7"}
|
||||
buf = _encode_cmd(method, params)
|
||||
|
||||
# Mirror extension/.../shared/native-zap.ts decodeCmd (little-endian).
|
||||
o = 0
|
||||
ml = struct.unpack_from("<H", buf, o)[0]; o += 2
|
||||
assert buf[o:o + ml].decode() == method; o += ml
|
||||
n = struct.unpack_from("<H", buf, o)[0]; o += 2
|
||||
assert n == len(params)
|
||||
out: dict[str, str] = {}
|
||||
for _ in range(n):
|
||||
kl = struct.unpack_from("<H", buf, o)[0]; o += 2
|
||||
k = buf[o:o + kl].decode(); o += kl
|
||||
vl = struct.unpack_from("<I", buf, o)[0]; o += 4 # u32 value len, no tag
|
||||
out[k] = buf[o:o + vl].decode(); o += vl
|
||||
assert o == len(buf)
|
||||
assert out == params
|
||||
|
||||
# The canonical (tagged) helper is a different, longer wire — proving we
|
||||
# were right not to use it for this peer.
|
||||
assert frame.encode_cmd(method, params) != buf
|
||||
assert len(frame.encode_cmd(method, params)) == len(buf) + len(params)
|
||||
|
||||
+73
-119
@@ -1,27 +1,36 @@
|
||||
"""
|
||||
luxfi/zap binary wire protocol — Python implementation.
|
||||
"""ZAP binary wire protocol — the hanzo-zap service layer over canonical ``zap``.
|
||||
|
||||
Compatible with the Rust `hanzo-zap` crate and Go `github.com/luxfi/zap` v0.2.0.
|
||||
The zero-copy codec is NOT reimplemented here. The object encode/decode, the
|
||||
deferred out-of-line layout, 8-byte alignment, and the 16-byte header all come
|
||||
from the canonical ``zap.wire`` package (the byte-faithful port of
|
||||
``zap-proto/go``), the one Python copy of the wire. This module is a thin layer
|
||||
that keeps hanzo-zap's small API surface (``set_u32``/``obj_uint32`` spellings,
|
||||
the ``Message`` header view) and adds the hanzo *cloud service* schema
|
||||
(``MsgType 100`` request/response, the handshake, and length-prefixed frame I/O)
|
||||
on top of those canonical primitives.
|
||||
|
||||
Wire format:
|
||||
Wire format (defined and owned by ``zap.wire``):
|
||||
Frame: [4-byte LE length][message bytes]
|
||||
Message header (16 bytes): magic(4) + version(2) + flags(2) + root_offset(4) + size(4)
|
||||
Object fields: inline primitives, (relOffset:i32 + length:u32) for text/bytes
|
||||
relOffset is relative to the field's absolute position in the buffer.
|
||||
Object fields: inline primitives; (relOffset:u32 + length:u32) for text/bytes,
|
||||
the offset being relative to the field's absolute position in the buffer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
|
||||
# ── Constants ────────────────────────────────────────────────────────────
|
||||
from zap import wire as _zw
|
||||
|
||||
ZAP_MAGIC = b"ZAP\x00"
|
||||
HEADER_SIZE = 16
|
||||
VERSION = 1
|
||||
ALIGNMENT = 8
|
||||
# ── Constants ────────────────────────────────────────────────────────────
|
||||
# Codec constants are sourced from the canonical wire so they can never drift.
|
||||
ZAP_MAGIC = _zw.MAGIC # b"ZAP\x00"
|
||||
HEADER_SIZE = _zw.HEADER_SIZE # 16
|
||||
VERSION = _zw.VERSION # 1
|
||||
ALIGNMENT = _zw.ALIGNMENT # 8
|
||||
MAX_MESSAGE_SIZE = 10 * 1024 * 1024 # 10 MB
|
||||
|
||||
# hanzo cloud-service schema (layered on the wire; not part of the codec).
|
||||
MSG_TYPE_CLOUD = 100
|
||||
|
||||
# Cloud request field byte offsets (each Text/Bytes = 8 bytes: relOffset + length)
|
||||
@@ -32,8 +41,8 @@ CLOUD_REQ_BODY = 16
|
||||
# Cloud response field byte offsets
|
||||
# Layout: status(0:u32, 4 bytes) + body(4:Bytes, 8 bytes) + error(12:Text, 8 bytes)
|
||||
CLOUD_RESP_STATUS = 0 # u32 inline (4 bytes)
|
||||
CLOUD_RESP_BODY = 4 # (relOffset:i32 + length:u32)
|
||||
CLOUD_RESP_ERROR = 12 # (relOffset:i32 + length:u32)
|
||||
CLOUD_RESP_BODY = 4 # (relOffset:u32 + length:u32)
|
||||
CLOUD_RESP_ERROR = 12 # (relOffset:u32 + length:u32)
|
||||
|
||||
# Call correlation flags
|
||||
REQ_FLAG_REQ = 1
|
||||
@@ -48,7 +57,12 @@ HANDSHAKE_ID_LEN_OFFSET = 60
|
||||
# ── Message ──────────────────────────────────────────────────────────────
|
||||
|
||||
class Message:
|
||||
"""Parsed ZAP binary message (owns the full buffer including header)."""
|
||||
"""A parsed ZAP message — a header view over the full buffer.
|
||||
|
||||
The object body is read with the canonical :class:`zap.wire.Object` (via the
|
||||
``obj_*`` helpers below). This wrapper only exposes the 16-byte header fields
|
||||
in hanzo-zap's vocabulary (``msg_type``, ``root_offset``, ``total_size``).
|
||||
"""
|
||||
|
||||
__slots__ = ("_data",)
|
||||
|
||||
@@ -92,130 +106,70 @@ class Message:
|
||||
return struct.unpack_from("<I", self._data, 12)[0]
|
||||
|
||||
|
||||
# ── Object reader (works on full message buffer with absolute offsets) ──
|
||||
# ── Object reader (delegates field decode to canonical zap.wire.Object) ──
|
||||
|
||||
def obj_uint32(data: bytes, obj_offset: int, field_offset: int) -> int:
|
||||
"""Read a u32 inline field from object at obj_offset."""
|
||||
pos = obj_offset + field_offset
|
||||
if pos + 4 > len(data):
|
||||
return 0
|
||||
return struct.unpack_from("<I", data, pos)[0]
|
||||
"""Read a u32 inline field from the object at ``obj_offset``."""
|
||||
return _zw.Object(memoryview(data), obj_offset).uint32(field_offset)
|
||||
|
||||
|
||||
def obj_bytes(data: bytes, obj_offset: int, field_offset: int) -> bytes:
|
||||
"""Read a Bytes field (relOffset:i32 + length:u32) from object."""
|
||||
pos = obj_offset + field_offset
|
||||
if pos + 8 > len(data):
|
||||
return b""
|
||||
rel_off = struct.unpack_from("<i", data, pos)[0] # signed i32
|
||||
length = struct.unpack_from("<I", data, pos + 4)[0]
|
||||
if length == 0 or rel_off == 0:
|
||||
return b""
|
||||
abs_off = pos + rel_off
|
||||
if abs_off < 0 or abs_off + length > len(data):
|
||||
return b""
|
||||
return data[abs_off:abs_off + length]
|
||||
"""Read a Bytes field (relOffset + length) from the object."""
|
||||
return _zw.Object(memoryview(data), obj_offset).bytes(field_offset)
|
||||
|
||||
|
||||
def obj_text(data: bytes, obj_offset: int, field_offset: int) -> str:
|
||||
"""Read a Text field from object."""
|
||||
b = obj_bytes(data, obj_offset, field_offset)
|
||||
return b.decode("utf-8", errors="replace") if b else ""
|
||||
"""Read a Text field from the object."""
|
||||
return _zw.Object(memoryview(data), obj_offset).text(field_offset)
|
||||
|
||||
|
||||
# ── Builder (matches Rust Builder exactly) ───────────────────────────────
|
||||
# ── Builder (delegates the codec to canonical zap.wire.Builder) ──────────
|
||||
|
||||
def _align(pos: int) -> int:
|
||||
return (pos + ALIGNMENT - 1) & ~(ALIGNMENT - 1)
|
||||
class ObjectBuilder:
|
||||
"""hanzo-zap object builder — wraps :class:`zap.wire.ObjectBuilder`.
|
||||
|
||||
Keeps hanzo-zap's ``set_u32``/``set_u8`` spellings over the canonical
|
||||
``set_uint32``/``set_uint8`` so existing schema code is unchanged.
|
||||
"""
|
||||
|
||||
__slots__ = ("_ob",)
|
||||
|
||||
def __init__(self, ob: _zw.ObjectBuilder) -> None:
|
||||
self._ob = ob
|
||||
|
||||
def set_u32(self, field_offset: int, v: int) -> None:
|
||||
self._ob.set_uint32(field_offset, v)
|
||||
|
||||
def set_u8(self, field_offset: int, v: int) -> None:
|
||||
self._ob.set_uint8(field_offset, v)
|
||||
|
||||
def set_bytes(self, field_offset: int, data: bytes) -> None:
|
||||
self._ob.set_bytes(field_offset, data)
|
||||
|
||||
def set_text(self, field_offset: int, text: str) -> None:
|
||||
self._ob.set_text(field_offset, text)
|
||||
|
||||
def finish_as_root(self) -> None:
|
||||
self._ob.finish_as_root()
|
||||
|
||||
|
||||
class Builder:
|
||||
"""Builds a ZAP message in a single buffer, matching Rust Builder."""
|
||||
"""hanzo-zap message builder — wraps :class:`zap.wire.Builder`.
|
||||
|
||||
Adds the hanzo ``finish(flags)`` convenience (canonical splits this into
|
||||
``finish`` / ``finish_with_flags``).
|
||||
"""
|
||||
|
||||
__slots__ = ("_b",)
|
||||
|
||||
def __init__(self, capacity: int = 256) -> None:
|
||||
cap = max(capacity, 256)
|
||||
self._buf = bytearray(cap)
|
||||
self._buf[:4] = ZAP_MAGIC
|
||||
struct.pack_into("<H", self._buf, 4, VERSION)
|
||||
self._pos = HEADER_SIZE
|
||||
self._root_offset = 0
|
||||
self._b = _zw.Builder(max(capacity, 256))
|
||||
|
||||
def _grow(self, n: int) -> None:
|
||||
needed = self._pos + n
|
||||
if needed <= len(self._buf):
|
||||
return
|
||||
new_cap = max(len(self._buf) * 2, needed)
|
||||
self._buf.extend(b"\x00" * (new_cap - len(self._buf)))
|
||||
|
||||
def _align_pos(self) -> None:
|
||||
padding = (ALIGNMENT - (self._pos % ALIGNMENT)) % ALIGNMENT
|
||||
self._grow(padding)
|
||||
for _ in range(padding):
|
||||
self._buf[self._pos] = 0
|
||||
self._pos += 1
|
||||
|
||||
def start_object(self, data_size: int) -> "ObjectBuilder":
|
||||
self._align_pos()
|
||||
return ObjectBuilder(self, self._pos, data_size)
|
||||
def start_object(self, data_size: int) -> ObjectBuilder:
|
||||
return ObjectBuilder(self._b.start_object(data_size))
|
||||
|
||||
def finish(self, flags: int = 0) -> bytes:
|
||||
struct.pack_into("<H", self._buf, 6, flags)
|
||||
struct.pack_into("<I", self._buf, 8, self._root_offset)
|
||||
struct.pack_into("<I", self._buf, 12, self._pos)
|
||||
return bytes(self._buf[:self._pos])
|
||||
|
||||
|
||||
class ObjectBuilder:
|
||||
"""Builds a single object within a Builder, matching Rust ObjectBuilder."""
|
||||
|
||||
def __init__(self, builder: Builder, start_pos: int, data_size: int) -> None:
|
||||
self._builder = builder
|
||||
self._start = start_pos
|
||||
self._data_size = data_size
|
||||
self._deferred: list[tuple[int, bytes]] = [] # (field_offset, data)
|
||||
|
||||
def _ensure_field(self, end_offset: int) -> None:
|
||||
needed = self._start + end_offset
|
||||
if needed > self._builder._pos:
|
||||
self._builder._grow(needed - self._builder._pos)
|
||||
for i in range(self._builder._pos, needed):
|
||||
self._builder._buf[i] = 0
|
||||
self._builder._pos = needed
|
||||
|
||||
def set_u32(self, field_offset: int, v: int) -> None:
|
||||
self._ensure_field(field_offset + 4)
|
||||
struct.pack_into("<I", self._builder._buf, self._start + field_offset, v)
|
||||
|
||||
def set_u8(self, field_offset: int, v: int) -> None:
|
||||
self._ensure_field(field_offset + 1)
|
||||
self._builder._buf[self._start + field_offset] = v & 0xFF
|
||||
|
||||
def set_bytes(self, field_offset: int, data: bytes) -> None:
|
||||
self._ensure_field(field_offset + 8)
|
||||
pos = self._start + field_offset
|
||||
if not data:
|
||||
struct.pack_into("<I", self._builder._buf, pos, 0)
|
||||
struct.pack_into("<I", self._builder._buf, pos + 4, 0)
|
||||
return
|
||||
struct.pack_into("<I", self._builder._buf, pos + 4, len(data))
|
||||
self._deferred.append((field_offset, data))
|
||||
|
||||
def set_text(self, field_offset: int, text: str) -> None:
|
||||
self.set_bytes(field_offset, text.encode("utf-8"))
|
||||
|
||||
def finish_as_root(self) -> None:
|
||||
"""Finalize and set as root object."""
|
||||
self._ensure_field(self._data_size)
|
||||
for field_offset, data in self._deferred:
|
||||
data_pos = self._builder._pos
|
||||
self._builder._grow(len(data))
|
||||
start = self._builder._pos
|
||||
self._builder._buf[start:start + len(data)] = data
|
||||
self._builder._pos += len(data)
|
||||
field_abs = self._start + field_offset
|
||||
rel_offset = data_pos - field_abs
|
||||
struct.pack_into("<i", self._builder._buf, field_abs, rel_offset) # signed i32
|
||||
self._builder._root_offset = self._start
|
||||
return self._b.finish_with_flags(flags) if flags else self._b.finish()
|
||||
|
||||
|
||||
# ── Cloud service helpers ────────────────────────────────────────────────
|
||||
|
||||
@@ -24,6 +24,9 @@ classifiers = [
|
||||
keywords = ["agent", "ai", "hanzo", "mcp", "protocol", "zap", "zero-copy"]
|
||||
dependencies = [
|
||||
"httpx>=0.23.0",
|
||||
# Canonical zero-copy wire codec (zap.wire). hanzo-zap layers its cloud
|
||||
# service schema on top of this; the codec is never reimplemented here.
|
||||
"zap-proto>=1.2.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
Reference in New Issue
Block a user