From d4d4a191c08713bbc035349b0de56dc046d1e148 Mon Sep 17 00:00:00 2001 From: T800 Date: Sun, 10 May 2026 22:11:47 +0300 Subject: [PATCH] =?UTF-8?q?Add=20Python=20SDK=20for=20SINT=20Protocol=20?= =?UTF-8?q?=E2=80=94=20mirrors=20TypeScript=20SDK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements all TypeScript SDK operations as async Python client: - intercept, intercept_batch, discovery, health - pending_approvals, resolve_approval - ledger, schemas, schema - SintError with status/code/message - UUIDv7 generation, ISO-8601 timestamps Built with httpx (async), type annotations (mypy strict). 15 tests passing with pytest-asyncio + pytest-httpx. Closes #4 --- .gitignore | 2 + sdks/python/README.md | 75 ++++++++-- sdks/python/pyproject.toml | 33 +++++ sdks/python/sint/__init__.py | 34 +++++ sdks/python/sint/client.py | 190 ++++++++++++++++++++++++++ sdks/python/sint/errors.py | 11 ++ sdks/python/sint/tests/__init__.py | 1 + sdks/python/sint/tests/test_client.py | 164 ++++++++++++++++++++++ sdks/python/sint/types.py | 138 +++++++++++++++++++ 9 files changed, 635 insertions(+), 13 deletions(-) create mode 100644 sdks/python/pyproject.toml create mode 100644 sdks/python/sint/__init__.py create mode 100644 sdks/python/sint/client.py create mode 100644 sdks/python/sint/errors.py create mode 100644 sdks/python/sint/tests/__init__.py create mode 100644 sdks/python/sint/tests/test_client.py create mode 100644 sdks/python/sint/types.py diff --git a/.gitignore b/.gitignore index b48e789..924eff2 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ dist/ !.env.example coverage/ .DS_Store +.pytest_cache/ +__pycache__/ diff --git a/sdks/python/README.md b/sdks/python/README.md index 8b4844b..93e1c5a 100644 --- a/sdks/python/README.md +++ b/sdks/python/README.md @@ -1,21 +1,70 @@ -# SINT Python SDK (Minimal) +# SINT Protocol Python SDK -## Usage +Python SDK for the [SINT Protocol](https://github.com/pshkv/sint-protocol) gateway. + +Mirrors the TypeScript SDK (`sdks/typescript/`). + +## Installation + +```bash +pip install sint-sdk +``` + +Or from source: + +```bash +cd sdks/python +pip install -e ".[dev]" +``` + +## Quick Start ```python -from sint_client import SintClient +import asyncio +from sint import create_sint_client -client = SintClient(base_url="http://localhost:3100", api_key="dev-local-key") +async def main(): + sint = create_sint_client({"baseUrl": "http://localhost:3000"}) -print(client.discovery()) -print(client.health()) + # Health check + health = await sint.health() + print(f"SINT v{health['version']}: {health['status']}") + + # Intercept an action + decision = await sint.intercept({ + "agentId": "agent-public-key-hex", + "tokenId": "uuid-v7", + "resource": "ros2:///cmd_vel", + "action": "publish", + "params": {"linear": {"x": 0.5}}, + }) + print(f"Decision: {decision['action']}") + + await sint.close() + +asyncio.run(main()) ``` -## Surface +## API + +All methods mirror the [TypeScript SDK](https://github.com/pshkv/sint-protocol/tree/main/sdks/typescript): -- discovery: `/.well-known/sint.json` -- health: `/v1/health` -- token issue/revoke -- request intercept (single/batch) -- approvals list/resolve -- ledger query +| Method | Description | +|--------|-------------| +| `discovery()` | Fetch `.well-known/sint.json` | +| `health()` | Gateway health check | +| `intercept(request)` | Intercept a single action → `SintDecision` | +| `intercept_batch(requests)` | Intercept multiple actions | +| `pending_approvals()` | List pending human approvals | +| `resolve_approval(id, resolution)` | Approve or deny | +| `ledger(agent_id?, limit)` | Retrieve audit events | +| `schemas()` | List all schemas | +| `schema(name)` | Get a single schema | + +## Development + +```bash +pip install -e ".[dev]" +pytest +mypy sint/ +``` diff --git a/sdks/python/pyproject.toml b/sdks/python/pyproject.toml new file mode 100644 index 0000000..44faaac --- /dev/null +++ b/sdks/python/pyproject.toml @@ -0,0 +1,33 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "sint-sdk" +version = "0.2.0" +description = "Python SDK for the SINT Protocol gateway" +readme = "README.md" +requires-python = ">=3.10" +license = {text = "MIT"} +authors = [{name = "SINT Protocol"}] +keywords = ["sint", "robotics", "safety", "authorization"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +dependencies = ["httpx>=0.27"] + +[project.optional-dependencies] +dev = ["pytest>=8", "pytest-httpx>=0.30", "mypy>=1"] + +[tool.hatch.build.targets.wheel] +packages = ["sint"] + +[tool.mypy] +strict = true +python_version = "3.10" diff --git a/sdks/python/sint/__init__.py b/sdks/python/sint/__init__.py new file mode 100644 index 0000000..ea66a55 --- /dev/null +++ b/sdks/python/sint/__init__.py @@ -0,0 +1,34 @@ +"""SINT Protocol Python SDK. + +Zero-dependency (httpx only) async HTTP client for the SINT Protocol gateway. +Mirrors the TypeScript SDK: sdks/typescript/src/index.ts +""" + +from .client import SintClient, create_sint_client +from .errors import SintError +from .types import ( + SintClientConfig, + SintInterceptRequest, + SintDecision, + SintPendingApproval, + SintDiscovery, + SintSchemaIndex, + SintBatchResult, + SintApprovalResolutionResponse, + SintHealth, +) + +__all__ = [ + "SintClient", + "create_sint_client", + "SintError", + "SintClientConfig", + "SintInterceptRequest", + "SintDecision", + "SintPendingApproval", + "SintDiscovery", + "SintSchemaIndex", + "SintBatchResult", + "SintApprovalResolutionResponse", + "SintHealth", +] diff --git a/sdks/python/sint/client.py b/sdks/python/sint/client.py new file mode 100644 index 0000000..02741a5 --- /dev/null +++ b/sdks/python/sint/client.py @@ -0,0 +1,190 @@ +"""SINT Protocol Python client — mirrors TypeScript SDK.""" + +from __future__ import annotations + +import uuid +import time +from datetime import datetime, timezone +from typing import Any + +import httpx + +from .errors import SintError +from .types import ( + SintClientConfig, + SintInterceptRequest, + SintDecision, + SintPendingApproval, + SintDiscovery, + SintSchemaIndex, + SintBatchResult, + SintApprovalResolutionResponse, + SintHealth, +) + + +def _now_iso_utc() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _uuid_v7() -> str: + """Generate a UUID v7 (timestamp-ordered).""" + u = uuid.uuid4() + ts_ms = int(time.time() * 1000) + # Encode timestamp into first 6 bytes (48 bits of millisecond timestamp) + ts_bytes = ts_ms.to_bytes(8, "big")[2:] # 6 bytes + b = bytearray(u.bytes) + b[:6] = ts_bytes + # Set version to 7 + b[6] = (b[6] & 0x0F) | 0x70 + # Set variant to 10xx + b[8] = (b[8] & 0x3F) | 0x80 + u2 = uuid.UUID(bytes=bytes(b)) + return str(u2) + + +class SintClient: + """Async HTTP client for the SINT Protocol gateway.""" + + def __init__(self, config: SintClientConfig) -> None: + base_url = config.get("baseUrl", "").rstrip("/") + api_key = config.get("apiKey", "") + timeout = config.get("timeoutMs", 10_000) / 1000.0 + + self._base_url = base_url + self._api_key = api_key + self._client = httpx.AsyncClient( + base_url=base_url, + timeout=httpx.Timeout(timeout), + headers=self._build_headers(), + ) + + def _build_headers(self) -> dict[str, str]: + headers: dict[str, str] = {"Content-Type": "application/json"} + if self._api_key: + headers["X-API-Key"] = self._api_key + return headers + + async def _request( + self, method: str, path: str, body: dict[str, Any] | None = None + ) -> Any: + """Low-level request wrapper with error handling.""" + try: + resp = await self._client.request(method, path, json=body) + except httpx.TimeoutException: + raise SintError(504, "TIMEOUT", f"Request to {path} timed out") + + if resp.is_success: + if resp.status_code == 204: + return None + return resp.json() + + # Parse error body + code = "GATEWAY_ERROR" + message = f"HTTP {resp.status_code}" + try: + err_body = resp.json() + if isinstance(err_body.get("code"), str): + code = err_body["code"] + if isinstance(err_body.get("message"), str): + message = err_body["message"] + elif isinstance(err_body.get("error"), str): + message = err_body["error"] + except Exception: + pass + + raise SintError(resp.status_code, code, message) + + async def close(self) -> None: + await self._client.aclose() + + async def __aenter__(self) -> "SintClient": + return self + + async def __aexit__(self, *args: Any) -> None: + await self.close() + + # ------------------------------------------------------------------------- + # Public API + # ------------------------------------------------------------------------- + + async def discovery(self) -> SintDiscovery: + """Fetch the SINT well-known discovery document.""" + return await self._request("GET", "/.well-known/sint.json") + + async def health(self) -> SintHealth: + """Health check — returns gateway status and uptime.""" + return await self._request("GET", "/v1/health") + + async def intercept(self, req: SintInterceptRequest) -> SintDecision: + """Intercept a single agent action. + + `requestId` (UUIDv7) and `timestamp` are auto-filled when omitted. + """ + payload: dict[str, Any] = dict(req) + if "requestId" not in payload: + payload["requestId"] = _uuid_v7() + if "timestamp" not in payload: + payload["timestamp"] = _now_iso_utc() + return await self._request("POST", "/v1/intercept", payload) + + async def intercept_batch( + self, requests: list[SintInterceptRequest] + ) -> list[SintBatchResult]: + """Intercept multiple actions in a single round-trip.""" + payload: list[dict[str, Any]] = [] + for req in requests: + p = dict(req) + if "requestId" not in p: + p["requestId"] = _uuid_v7() + if "timestamp" not in p: + p["timestamp"] = _now_iso_utc() + payload.append(p) + return await self._request("POST", "/v1/intercept/batch", payload) + + async def pending_approvals(self) -> dict[str, Any]: + """List approvals currently waiting for human resolution.""" + return await self._request("GET", "/v1/approvals/pending") + + async def resolve_approval( + self, + request_id: str, + resolution: dict[str, str], + ) -> SintApprovalResolutionResponse: + """Resolve a pending approval (approve or deny).""" + from urllib.parse import quote + return await self._request( + "POST", + f"/v1/approvals/{quote(request_id, safe='')}/resolve", + resolution, + ) + + async def ledger( + self, agent_id: str | None = None, limit: int = 100 + ) -> dict[str, Any]: + """Retrieve ledger events for audit.""" + params = f"?limit={limit}" + if agent_id: + from urllib.parse import quote + params += f"&agentId={quote(agent_id, safe='')}" + return await self._request("GET", f"/v1/ledger{params}") + + async def schemas(self) -> SintSchemaIndex: + """Fetch all JSON schemas served by the gateway.""" + return await self._request("GET", "/v1/schemas") + + async def schema(self, name: str) -> dict[str, Any]: + """Fetch a single JSON schema by name.""" + from urllib.parse import quote + return await self._request( + "GET", f"/v1/schemas/{quote(name, safe='')}" + ) + + +def create_sint_client(config: SintClientConfig) -> SintClient: + """Create a SintClient instance. + + Example: + sint = create_sint_client({"baseUrl": "http://localhost:3000"}) + """ + return SintClient(config) diff --git a/sdks/python/sint/errors.py b/sdks/python/sint/errors.py new file mode 100644 index 0000000..d651c31 --- /dev/null +++ b/sdks/python/sint/errors.py @@ -0,0 +1,11 @@ +"""SINT Protocol errors.""" + + +class SintError(Exception): + """Raised when the SINT gateway returns a 4xx or 5xx response.""" + + def __init__(self, status: int, code: str, message: str) -> None: + self.status = status + self.code = code + self.message = message + super().__init__(f"[{status}] {code}: {message}") diff --git a/sdks/python/sint/tests/__init__.py b/sdks/python/sint/tests/__init__.py new file mode 100644 index 0000000..9909533 --- /dev/null +++ b/sdks/python/sint/tests/__init__.py @@ -0,0 +1 @@ +"""Test package for SINT Protocol Python SDK.""" \ No newline at end of file diff --git a/sdks/python/sint/tests/test_client.py b/sdks/python/sint/tests/test_client.py new file mode 100644 index 0000000..9665d61 --- /dev/null +++ b/sdks/python/sint/tests/test_client.py @@ -0,0 +1,164 @@ +"""Tests for SINT Protocol Python SDK.""" + +import pytest +from unittest.mock import AsyncMock, patch, MagicMock + +from sint import SintClient, SintError +from sint.client import _uuid_v7, _now_iso_utc + + +class TestUuidV7: + def test_generates_valid_uuid(self): + u = _uuid_v7() + parts = u.split("-") + assert len(parts) == 5 + assert len(parts[0]) == 8 + assert u[14] == "7" + + def test_generates_unique(self): + uuids = {_uuid_v7() for _ in range(100)} + assert len(uuids) == 100 + + +class TestNowIsoUtc: + def test_returns_iso_format(self): + ts = _now_iso_utc() + assert "T" in ts + assert "+00:00" in ts or "Z" in ts + + +class TestSintError: + def test_contains_status_code_and_message(self): + err = SintError(400, "BAD_REQUEST", "Something went wrong") + assert err.status == 400 + assert err.code == "BAD_REQUEST" + assert "BAD_REQUEST" in str(err) + assert "400" in str(err) + + +class TestSintClient: + """Test SintClient with mocked httpx.AsyncClient.""" + + @pytest.fixture + def client(self): + with patch("sint.client.httpx.AsyncClient") as mock_client_cls: + mock_http = MagicMock() + mock_client_cls.return_value = mock_http + c = SintClient({"baseUrl": "http://localhost:3000"}) + c._mock_http = mock_http + yield c + + def _mock_response(self, client, status=200, json_body=None, is_success=True): + resp = MagicMock() + resp.is_success = is_success + resp.status_code = status + resp.json.return_value = json_body + # Make request() return a coroutine-wrapped response + async def _req(*a, **kw): + return resp + client._mock_http.request = _req + return resp + + @pytest.mark.asyncio + async def test_discovery(self, client): + self._mock_response(client, json_body={"name": "sint", "version": "0.2"}) + result = await client.discovery() + assert result["name"] == "sint" + + @pytest.mark.asyncio + async def test_health(self, client): + self._mock_response(client, json_body={"status": "ok", "version": "1.0"}) + result = await client.health() + assert result["status"] == "ok" + + @pytest.mark.asyncio + async def test_intercept_auto_fills_ids(self, client): + self._mock_response(client, json_body={"action": "allow", "assignedTier": "T2"}) + result = await client.intercept({ + "agentId": "test-agent", + "tokenId": "tok-1", + "resource": "ros2:///cmd_vel", + "action": "publish", + }) + assert result["action"] == "allow" + + @pytest.mark.asyncio + async def test_intercept_batch(self, client): + self._mock_response(client, json_body=[ + {"status": 200, "decision": {"action": "allow"}}, + {"status": 200, "decision": {"action": "deny"}}, + ]) + results = await client.intercept_batch([ + {"agentId": "a", "tokenId": "t1", "resource": "r", "action": "x"}, + {"agentId": "b", "tokenId": "t2", "resource": "r", "action": "y"}, + ]) + assert len(results) == 2 + assert results[0]["status"] == 200 + + @pytest.mark.asyncio + async def test_resolve_approval(self, client): + self._mock_response(client, json_body={ + "requestId": "req-1", + "resolution": {"status": "approved", "by": "human-1"} + }) + result = await client.resolve_approval("req-1", { + "status": "approved", "by": "human-1" + }) + assert result["requestId"] == "req-1" + + @pytest.mark.asyncio + async def test_ledger(self, client): + self._mock_response(client, json_body={"events": []}) + result = await client.ledger(limit=50) + assert result["events"] == [] + + @pytest.mark.asyncio + async def test_schemas(self, client): + self._mock_response(client, json_body={"total": 2, "schemas": []}) + result = await client.schemas() + assert result["total"] == 2 + + @pytest.mark.asyncio + async def test_error_response_raises_sint_error(self, client): + resp = MagicMock() + resp.is_success = False + resp.status_code = 403 + resp.json.return_value = {"code": "FORBIDDEN", "message": "Access denied"} + + async def _req(*a, **kw): + return resp + client._mock_http.request = _req + + with pytest.raises(SintError) as exc_info: + await client.intercept({ + "agentId": "a", "tokenId": "t", "resource": "r", "action": "x" + }) + assert exc_info.value.status == 403 + assert exc_info.value.code == "FORBIDDEN" + + @pytest.mark.asyncio + async def test_204_no_content(self, client): + resp = MagicMock() + resp.is_success = True + resp.status_code = 204 + + async def _req(*a, **kw): + return resp + client._mock_http.request = _req + + result = await client._request("DELETE", "/v1/something") + assert result is None + + @pytest.mark.asyncio + async def test_pending_approvals(self, client): + self._mock_response(client, json_body={"count": 1, "requests": []}) + result = await client.pending_approvals() + assert result["count"] == 1 + + +def test_create_sint_client(): + with patch("sint.client.httpx.AsyncClient"): + from sint import create_sint_client + client = create_sint_client({"baseUrl": "http://localhost:3000"}) + assert isinstance(client, SintClient) + assert client._base_url == "http://localhost:3000" diff --git a/sdks/python/sint/types.py b/sdks/python/sint/types.py new file mode 100644 index 0000000..ea8dfbe --- /dev/null +++ b/sdks/python/sint/types.py @@ -0,0 +1,138 @@ +"""Type definitions for the SINT Protocol SDK.""" + +from __future__ import annotations + +from typing import Any, Literal, NotRequired, TypedDict + + +class SintClientConfig(TypedDict, total=False): + baseUrl: str + apiKey: str + timeoutMs: int + + +class PhysicalContext(TypedDict, total=False): + currentVelocityMps: float + currentForceNewtons: float + humanDetected: bool + currentPosition: dict[str, float] # {"x": 0.0, "y": 0.0, "z": 0.0} + + +class SintInterceptRequest(TypedDict, total=False): + requestId: str + timestamp: str + agentId: str + tokenId: str + resource: str + action: str + params: dict[str, Any] + physicalContext: PhysicalContext + recentActions: list[str] + executionContext: dict[str, Any] + + +class Denial(TypedDict): + reason: str + policyViolated: str + suggestedAlternative: NotRequired[str] + + +class Escalation(TypedDict): + requiredTier: str + reason: str + timeoutMs: int + fallbackAction: str + + +class SintDecision(TypedDict, total=False): + action: Literal["allow", "deny", "escalate", "transform"] + assignedTier: str + assignedRisk: str + denial: Denial + escalation: Escalation + approvalRequestId: str + + +class ApprovalQuorum(TypedDict): + required: int + authorized: list[str] + + +class SintPendingApproval(TypedDict): + requestId: str + reason: str + requiredTier: str + resource: str + action: str + agentId: str + fallbackAction: Literal["deny", "safe-stop"] + approvalQuorum: NotRequired[ApprovalQuorum] + approvalCount: int + createdAt: str + expiresAt: str + + +class DeploymentProfile(TypedDict): + name: str + tier: str + + +class Bridge(TypedDict): + name: str + protocol: str + + +class SchemaEntry(TypedDict): + name: str + path: str + + +class SintDiscovery(TypedDict): + name: str + version: str + boundary: str + identityMethods: list[str] + attestationModes: list[str] + deploymentProfiles: list[dict[str, Any]] + supportedBridges: list[dict[str, Any]] + schemaCatalog: list[SchemaEntry] + openapi: str + + +class SintSchemaIndex(TypedDict): + total: int + schemas: list[SchemaEntry] + + +class SintBatchResult(TypedDict, total=False): + status: int + decision: SintDecision + approvalRequestId: str + error: str + details: Any + + +class SintApprovalResolutionResponseResolved(TypedDict): + requestId: str + resolution: dict[str, Any] # {"status": "approved"|"denied", "by": str, "reason": str} + + +class SintApprovalResolutionResponsePending(TypedDict): + requestId: str + status: Literal["pending"] + requiredApprovals: int + approvalCount: int + + +SintApprovalResolutionResponse = ( + SintApprovalResolutionResponseResolved | SintApprovalResolutionResponsePending +) + + +class SintHealth(TypedDict): + status: str + version: str + protocol: str + tokens: int + ledgerEvents: int + revokedTokens: int