Skip to content

Commit ff475ec

Browse files
committed
feat(search,contracts): add semantic-search and contract-validation clients
1 parent 1f1e8b7 commit ff475ec

2 files changed

Lines changed: 309 additions & 0 deletions

File tree

streamline_sdk/contracts.py

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
"""Contracts validate client (M2 wiring).
2+
3+
Wraps ``POST /api/v1/contracts/validate`` so producers can dry-run a
4+
contract before applying it. Stateless: each call is self-contained.
5+
6+
Example:
7+
client = ContractsClient("http://localhost:9094")
8+
result = await client.validate(
9+
contract={
10+
"name": "orders.v1",
11+
"schema_id": 7,
12+
"fields": [{"name": "id", "type": "string", "required": True}],
13+
},
14+
value={"id": "abc"},
15+
)
16+
if not result.valid:
17+
print(result.errors)
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import asyncio
23+
import json
24+
from dataclasses import dataclass, field
25+
from typing import Any, Optional, Union
26+
27+
try:
28+
import aiohttp
29+
30+
_HAS_AIOHTTP = True
31+
except ImportError: # pragma: no cover
32+
_HAS_AIOHTTP = False
33+
34+
from .exceptions import StreamlineError
35+
36+
37+
class ContractsError(StreamlineError):
38+
"""Raised when the contracts API itself fails (5xx, network, etc.)."""
39+
40+
41+
@dataclass
42+
class ValidationError:
43+
"""A single validation failure."""
44+
45+
field_path: str
46+
expected: str
47+
actual: str
48+
message: str = ""
49+
50+
@classmethod
51+
def from_json(cls, data: dict[str, Any]) -> "ValidationError":
52+
return cls(
53+
field_path=str(data.get("field_path", "")),
54+
expected=str(data.get("expected", "")),
55+
actual=str(data.get("actual", "")),
56+
message=str(data.get("message", "")),
57+
)
58+
59+
60+
@dataclass
61+
class ValidationResult:
62+
"""Outcome of a contract validation request."""
63+
64+
valid: bool
65+
schema_id: Optional[int] = None
66+
errors: list[ValidationError] = field(default_factory=list)
67+
68+
69+
class ContractsClient:
70+
"""Async client for the contracts validate endpoint.
71+
72+
Args:
73+
http_url: HTTP base URL (default: ``http://localhost:9094``).
74+
timeout: Per-request timeout in seconds.
75+
"""
76+
77+
def __init__(
78+
self, http_url: str = "http://localhost:9094", timeout: float = 10.0
79+
) -> None:
80+
self.http_url = http_url.rstrip("/")
81+
self.timeout = timeout
82+
83+
async def validate(
84+
self,
85+
contract: dict[str, Any],
86+
value: Union[dict[str, Any], list[Any], str, bytes],
87+
) -> ValidationResult:
88+
"""Dry-run ``contract`` against ``value``.
89+
90+
``value`` may be a JSON-serializable object (sent as ``value``)
91+
or raw bytes/str (sent as ``value_b64``-style, encoded inline).
92+
"""
93+
body: dict[str, Any] = {"contract": contract}
94+
if isinstance(value, (bytes, bytearray)):
95+
body["value_string"] = value.decode("utf-8", errors="replace")
96+
elif isinstance(value, str):
97+
body["value_string"] = value
98+
else:
99+
body["value"] = value
100+
101+
url = f"{self.http_url}/api/v1/contracts/validate"
102+
status, payload = await self._post(url, body)
103+
104+
if status == 200:
105+
data = payload or {}
106+
return ValidationResult(
107+
valid=True,
108+
schema_id=data.get("schema_id"),
109+
errors=[],
110+
)
111+
if status == 400:
112+
data = payload or {}
113+
errs = [
114+
ValidationError.from_json(e)
115+
for e in data.get("errors", [])
116+
]
117+
return ValidationResult(
118+
valid=False,
119+
schema_id=data.get("schema_id"),
120+
errors=errs,
121+
)
122+
raise ContractsError(
123+
f"validate -> HTTP {status}: {json.dumps(payload)[:512]}"
124+
)
125+
126+
async def _post(self, url: str, body: dict[str, Any]) -> tuple[int, Any]:
127+
if _HAS_AIOHTTP:
128+
timeout = aiohttp.ClientTimeout(total=self.timeout)
129+
async with aiohttp.ClientSession(timeout=timeout) as session:
130+
async with session.post(url, json=body) as resp:
131+
text = await resp.text()
132+
payload: Any = None
133+
if text:
134+
try:
135+
payload = json.loads(text)
136+
except json.JSONDecodeError:
137+
payload = {"raw": text}
138+
return resp.status, payload
139+
else:
140+
import urllib.request
141+
import urllib.error
142+
143+
data = json.dumps(body).encode("utf-8")
144+
req = urllib.request.Request(
145+
url,
146+
data=data,
147+
headers={"Content-Type": "application/json"},
148+
method="POST",
149+
)
150+
151+
def _sync() -> tuple[int, Any]:
152+
try:
153+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
154+
text = resp.read().decode("utf-8")
155+
return resp.status, json.loads(text) if text else None
156+
except urllib.error.HTTPError as e:
157+
text = e.read().decode("utf-8", errors="replace")
158+
try:
159+
return e.code, json.loads(text)
160+
except json.JSONDecodeError:
161+
return e.code, {"raw": text}
162+
163+
return await asyncio.to_thread(_sync)
164+
165+
166+
__all__ = [
167+
"ContractsClient",
168+
"ContractsError",
169+
"ValidationError",
170+
"ValidationResult",
171+
]

streamline_sdk/search.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
"""HTTP client for semantic-topic search (M2 P1, Experimental).
2+
3+
Wraps the broker's ``POST /api/v1/topics/{topic}/search`` endpoint.
4+
5+
Example:
6+
client = SearchClient("http://localhost:9094")
7+
hits = await client.search("logs", "payment failure", k=5)
8+
for h in hits:
9+
print(h.partition, h.offset, h.score)
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import asyncio
15+
import json
16+
from dataclasses import dataclass
17+
from typing import Any, Optional
18+
19+
try:
20+
import aiohttp
21+
22+
_HAS_AIOHTTP = True
23+
except ImportError: # pragma: no cover - import guard
24+
_HAS_AIOHTTP = False
25+
26+
from .exceptions import StreamlineError
27+
28+
29+
class SearchError(StreamlineError):
30+
"""Raised on search API failures."""
31+
32+
33+
@dataclass
34+
class SearchHit:
35+
partition: int
36+
offset: int
37+
score: float
38+
value: Optional[str] = None
39+
40+
@classmethod
41+
def from_json(cls, data: dict[str, Any]) -> "SearchHit":
42+
return cls(
43+
partition=int(data.get("partition", 0)),
44+
offset=int(data.get("offset", 0)),
45+
score=float(data.get("score", 0.0)),
46+
value=data.get("value"),
47+
)
48+
49+
50+
@dataclass
51+
class SearchResult:
52+
hits: list[SearchHit]
53+
took_ms: int
54+
55+
@classmethod
56+
def from_json(cls, data: dict[str, Any]) -> "SearchResult":
57+
return cls(
58+
hits=[SearchHit.from_json(h) for h in data.get("hits", [])],
59+
took_ms=int(data.get("took_ms", 0)),
60+
)
61+
62+
63+
class SearchClient:
64+
"""Async client for the semantic-search HTTP API.
65+
66+
Args:
67+
http_url: Broker HTTP base URL (e.g. ``http://localhost:9094``).
68+
timeout: Per-request timeout in seconds.
69+
"""
70+
71+
def __init__(self, http_url: str, *, timeout: float = 30.0) -> None:
72+
self.http_url = http_url.rstrip("/")
73+
self.timeout = timeout
74+
75+
async def search(
76+
self,
77+
topic: str,
78+
query: str,
79+
*,
80+
k: int = 10,
81+
filter: Optional[dict[str, Any]] = None,
82+
) -> SearchResult:
83+
if not topic:
84+
raise SearchError("topic must not be empty")
85+
if not query:
86+
raise SearchError("query must not be empty")
87+
if k <= 0 or k > 1000:
88+
raise SearchError("k must be in [1, 1000]")
89+
body: dict[str, Any] = {"query": query, "k": k}
90+
if filter is not None:
91+
body["filter"] = filter
92+
data = await self._post(f"/api/v1/topics/{topic}/search", body)
93+
return SearchResult.from_json(data)
94+
95+
# ------------------------------------------------------------------
96+
async def _post(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
97+
url = f"{self.http_url}{path}"
98+
if _HAS_AIOHTTP:
99+
timeout = aiohttp.ClientTimeout(total=self.timeout)
100+
async with aiohttp.ClientSession(timeout=timeout) as session:
101+
async with session.post(url, json=body) as resp:
102+
text = await resp.text()
103+
self._check(resp.status, text, path)
104+
return json.loads(text) if text else {}
105+
else:
106+
import urllib.error
107+
import urllib.request
108+
109+
data = json.dumps(body).encode("utf-8")
110+
req = urllib.request.Request(
111+
url,
112+
data=data,
113+
headers={"Content-Type": "application/json"},
114+
method="POST",
115+
)
116+
117+
def _sync() -> dict[str, Any]:
118+
try:
119+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
120+
text = resp.read().decode("utf-8")
121+
self._check(resp.status, text, path)
122+
return json.loads(text) if text else {}
123+
except urllib.error.HTTPError as e:
124+
text = e.read().decode("utf-8", errors="replace")
125+
self._check(e.code, text, path)
126+
return {}
127+
128+
return await asyncio.to_thread(_sync)
129+
130+
@staticmethod
131+
def _check(status: int, body: str, path: str) -> None:
132+
if status >= 400:
133+
raise SearchError(
134+
f"search request to {path} failed (HTTP {status}): {body}"
135+
)
136+
137+
138+
__all__ = ["SearchClient", "SearchHit", "SearchResult", "SearchError"]

0 commit comments

Comments
 (0)