chore: import upstream snapshot with attribution
CI / Clippy (push) Failing after 15m13s
CI / Test (ubuntu-latest) (push) Failing after 16m1s
CI / Test (macos-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Build (no embeddings / no ORT) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Cookbook (Node) (push) Has been cancelled
CI / Pi Extension (Node) (push) Has been cancelled
CI / Rust SDK (lean-ctx-client) (push) Has been cancelled
CI / Embed SDK (lean-ctx-sdk) (push) Has been cancelled
CI / Python SDK (leanctx) (push) Has been cancelled
CI / Hermes Plugin (Python) (push) Has been cancelled
CI / SDK Conformance Matrix (push) Has been cancelled
CI / Coverage (push) Has been cancelled
CI / cargo-deny (push) Has been cancelled
CI / Adversarial Safety (push) Has been cancelled
CI / Benchmarks (push) Has been cancelled
CI / Output-Quality Gate (eval A/B) (push) Has been cancelled
CI / Documentation (push) Has been cancelled
CI / CI Green (push) Has been cancelled
JetBrains Plugin / Actionlint (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (rust) (push) Has been cancelled
JetBrains Plugin / Validation (push) Has been cancelled
JetBrains Plugin / Build (push) Has been cancelled
JetBrains Plugin / Test (push) Has been cancelled
Security Check / Security Scan (push) Has been cancelled
CI / Clippy (push) Failing after 15m13s
CI / Test (ubuntu-latest) (push) Failing after 16m1s
CI / Test (macos-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Build (no embeddings / no ORT) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Cookbook (Node) (push) Has been cancelled
CI / Pi Extension (Node) (push) Has been cancelled
CI / Rust SDK (lean-ctx-client) (push) Has been cancelled
CI / Embed SDK (lean-ctx-sdk) (push) Has been cancelled
CI / Python SDK (leanctx) (push) Has been cancelled
CI / Hermes Plugin (Python) (push) Has been cancelled
CI / SDK Conformance Matrix (push) Has been cancelled
CI / Coverage (push) Has been cancelled
CI / cargo-deny (push) Has been cancelled
CI / Adversarial Safety (push) Has been cancelled
CI / Benchmarks (push) Has been cancelled
CI / Output-Quality Gate (eval A/B) (push) Has been cancelled
CI / Documentation (push) Has been cancelled
CI / CI Green (push) Has been cancelled
JetBrains Plugin / Actionlint (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (rust) (push) Has been cancelled
JetBrains Plugin / Validation (push) Has been cancelled
JetBrains Plugin / Build (push) Has been cancelled
JetBrains Plugin / Test (push) Has been cancelled
Security Check / Security Scan (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
"""lean-ctx Python SDK.
|
||||
|
||||
A thin, dependency-free client for the lean-ctx HTTP ``/v1`` contract. Mirrors
|
||||
the TypeScript (`lean-ctx-client`) and Rust (`lean-ctx-client`) SDKs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .client import LeanCtxClient
|
||||
from .conformance import (
|
||||
COVERED_ROUTES,
|
||||
SUPPORTED_HTTP_CONTRACT_VERSIONS,
|
||||
ConformanceCheck,
|
||||
ConformanceScorecard,
|
||||
run_conformance,
|
||||
)
|
||||
from .errors import (
|
||||
LeanCtxConfigError,
|
||||
LeanCtxError,
|
||||
LeanCtxHTTPError,
|
||||
LeanCtxTransportError,
|
||||
)
|
||||
from .tool_text import tool_result_to_text
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
__all__ = [
|
||||
"LeanCtxClient",
|
||||
"LeanCtxError",
|
||||
"LeanCtxConfigError",
|
||||
"LeanCtxTransportError",
|
||||
"LeanCtxHTTPError",
|
||||
"tool_result_to_text",
|
||||
"run_conformance",
|
||||
"ConformanceCheck",
|
||||
"ConformanceScorecard",
|
||||
"COVERED_ROUTES",
|
||||
"SUPPORTED_HTTP_CONTRACT_VERSIONS",
|
||||
"__version__",
|
||||
]
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Framework adapters for lean-ctx tools (EPIC 12.6).
|
||||
|
||||
Thin, optional integrations that expose the lean-ctx tool surface to popular
|
||||
agent frameworks. Each framework is an *optional* dependency, imported lazily —
|
||||
installing the SDK pulls in none of them. The OpenAI adapter is a pure
|
||||
transformation and needs no extra package.
|
||||
|
||||
from leanctx import LeanCtxClient
|
||||
from leanctx.adapters import to_openai_tools, to_langchain_tools
|
||||
|
||||
client = LeanCtxClient("http://127.0.0.1:8080")
|
||||
tools = to_openai_tools(client)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ._common import ToolSpec, coerce_arguments, normalized_tool_specs
|
||||
from .crewai import to_crewai_tools
|
||||
from .langchain import to_langchain_tools
|
||||
from .llamaindex import to_llamaindex_tools
|
||||
from .openai import run_openai_tool_call, to_openai_tools
|
||||
|
||||
__all__ = [
|
||||
"ToolSpec",
|
||||
"normalized_tool_specs",
|
||||
"coerce_arguments",
|
||||
"to_openai_tools",
|
||||
"run_openai_tool_call",
|
||||
"to_langchain_tools",
|
||||
"to_llamaindex_tools",
|
||||
"to_crewai_tools",
|
||||
]
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Shared helpers for framework adapters (EPIC 12.6).
|
||||
|
||||
All adapters normalize lean-ctx tools into a common [`ToolSpec`] and reuse the
|
||||
same SDK call path (`call_tool_text`), so every framework integration behaves
|
||||
identically and stays correct as the tool surface evolves.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Dict, List
|
||||
|
||||
from ..client import LeanCtxClient
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolSpec:
|
||||
"""A framework-neutral description of one lean-ctx tool."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
parameters: Dict[str, Any] # JSON Schema for the arguments object
|
||||
|
||||
|
||||
def _default_schema() -> Dict[str, Any]:
|
||||
return {"type": "object", "properties": {}}
|
||||
|
||||
|
||||
def normalized_tool_specs(client: LeanCtxClient, *, limit: int = 500) -> List[ToolSpec]:
|
||||
"""Fetch and normalize the server's tool surface into [`ToolSpec`]s."""
|
||||
listing = client.list_tools(limit=limit)
|
||||
tools = listing.get("tools", []) if isinstance(listing, dict) else []
|
||||
specs: List[ToolSpec] = []
|
||||
for entry in tools:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
name = entry.get("name")
|
||||
if not isinstance(name, str) or not name:
|
||||
continue
|
||||
description = entry.get("description")
|
||||
if not isinstance(description, str):
|
||||
description = ""
|
||||
schema = (
|
||||
entry.get("input_schema")
|
||||
or entry.get("inputSchema")
|
||||
or entry.get("parameters")
|
||||
or _default_schema()
|
||||
)
|
||||
if not isinstance(schema, dict):
|
||||
schema = _default_schema()
|
||||
specs.append(ToolSpec(name=name, description=description, parameters=schema))
|
||||
return specs
|
||||
|
||||
|
||||
def coerce_arguments(raw: Any) -> Dict[str, Any]:
|
||||
"""Coerce tool-call arguments (JSON string or dict) into a dict."""
|
||||
if raw is None:
|
||||
return {}
|
||||
if isinstance(raw, dict):
|
||||
return raw
|
||||
if isinstance(raw, str):
|
||||
text = raw.strip()
|
||||
if not text:
|
||||
return {}
|
||||
parsed = json.loads(text)
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
return {}
|
||||
|
||||
|
||||
def make_json_runner(client: LeanCtxClient, name: str) -> Callable[[str], str]:
|
||||
"""A `(arguments: str) -> str` runner used by string-input frameworks.
|
||||
|
||||
The single JSON-string argument is the most portable shape across framework
|
||||
versions and avoids brittle per-tool signature synthesis.
|
||||
"""
|
||||
|
||||
def run(arguments: str = "") -> str:
|
||||
return client.call_tool_text(name, coerce_arguments(arguments))
|
||||
|
||||
run.__name__ = name
|
||||
run.__doc__ = f"Invoke the lean-ctx tool '{name}' with a JSON object string."
|
||||
return run
|
||||
@@ -0,0 +1,53 @@
|
||||
"""CrewAI adapter (EPIC 12.6).
|
||||
|
||||
Exposes lean-ctx tools as CrewAI `BaseTool`s. The framework is an optional
|
||||
dependency, imported lazily.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, List
|
||||
|
||||
from ..client import LeanCtxClient
|
||||
from ._common import coerce_arguments, normalized_tool_specs
|
||||
|
||||
|
||||
def to_crewai_tools(client: LeanCtxClient) -> List[Any]:
|
||||
"""Return lean-ctx tools as CrewAI `BaseTool` instances.
|
||||
|
||||
Each tool takes a single ``arguments`` field: a JSON object string of the
|
||||
tool's arguments. This is portable across CrewAI versions and avoids
|
||||
synthesizing a distinct pydantic schema per tool.
|
||||
"""
|
||||
try:
|
||||
from crewai.tools import BaseTool
|
||||
from pydantic import BaseModel, Field
|
||||
except ImportError as exc: # pragma: no cover - exercised only without dep
|
||||
raise ImportError(
|
||||
"CrewAI is required for this adapter: pip install crewai"
|
||||
) from exc
|
||||
|
||||
class _ArgsSchema(BaseModel):
|
||||
arguments: str = Field(
|
||||
default="",
|
||||
description="JSON object string of the tool's arguments.",
|
||||
)
|
||||
|
||||
class _LeanCtxTool(BaseTool):
|
||||
args_schema: type[BaseModel] = _ArgsSchema
|
||||
|
||||
def __init__(self, tool_name: str, tool_description: str) -> None:
|
||||
super().__init__(name=tool_name, description=tool_description)
|
||||
self._tool_name = tool_name
|
||||
|
||||
def _run(self, arguments: str = "") -> str:
|
||||
return client.call_tool_text(self._tool_name, coerce_arguments(arguments))
|
||||
|
||||
tools: List[Any] = []
|
||||
for spec in normalized_tool_specs(client):
|
||||
description = (
|
||||
f"{spec.description} "
|
||||
"Input: a JSON object string matching this tool's argument schema."
|
||||
).strip()
|
||||
tools.append(_LeanCtxTool(spec.name, description))
|
||||
return tools
|
||||
@@ -0,0 +1,36 @@
|
||||
"""LangChain adapter (EPIC 12.6).
|
||||
|
||||
Exposes lean-ctx tools as LangChain `Tool`s. The framework is an optional
|
||||
dependency, imported lazily so the SDK has no hard coupling.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, List
|
||||
|
||||
from ..client import LeanCtxClient
|
||||
from ._common import make_json_runner, normalized_tool_specs
|
||||
|
||||
|
||||
def to_langchain_tools(client: LeanCtxClient) -> List[Any]:
|
||||
"""Return lean-ctx tools as `langchain_core.tools.Tool` instances.
|
||||
|
||||
Each tool accepts a JSON object string of arguments — the most portable
|
||||
shape across LangChain versions.
|
||||
"""
|
||||
try:
|
||||
from langchain_core.tools import Tool
|
||||
except ImportError as exc: # pragma: no cover - exercised only without dep
|
||||
raise ImportError(
|
||||
"LangChain is required for this adapter: pip install langchain-core"
|
||||
) from exc
|
||||
|
||||
tools: List[Any] = []
|
||||
for spec in normalized_tool_specs(client):
|
||||
runner = make_json_runner(client, spec.name)
|
||||
description = (
|
||||
f"{spec.description} "
|
||||
"Input: a JSON object string matching this tool's argument schema."
|
||||
).strip()
|
||||
tools.append(Tool(name=spec.name, description=description, func=runner))
|
||||
return tools
|
||||
@@ -0,0 +1,36 @@
|
||||
"""LlamaIndex adapter (EPIC 12.6).
|
||||
|
||||
Exposes lean-ctx tools as LlamaIndex `FunctionTool`s. The framework is an
|
||||
optional dependency, imported lazily.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, List
|
||||
|
||||
from ..client import LeanCtxClient
|
||||
from ._common import make_json_runner, normalized_tool_specs
|
||||
|
||||
|
||||
def to_llamaindex_tools(client: LeanCtxClient) -> List[Any]:
|
||||
"""Return lean-ctx tools as `llama_index.core.tools.FunctionTool` instances."""
|
||||
try:
|
||||
from llama_index.core.tools import FunctionTool
|
||||
except ImportError as exc: # pragma: no cover - exercised only without dep
|
||||
raise ImportError(
|
||||
"LlamaIndex is required for this adapter: pip install llama-index-core"
|
||||
) from exc
|
||||
|
||||
tools: List[Any] = []
|
||||
for spec in normalized_tool_specs(client):
|
||||
runner = make_json_runner(client, spec.name)
|
||||
description = (
|
||||
f"{spec.description} "
|
||||
"Input: a JSON object string matching this tool's argument schema."
|
||||
).strip()
|
||||
tools.append(
|
||||
FunctionTool.from_defaults(
|
||||
fn=runner, name=spec.name, description=description
|
||||
)
|
||||
)
|
||||
return tools
|
||||
@@ -0,0 +1,46 @@
|
||||
"""OpenAI function-calling adapter (EPIC 12.6).
|
||||
|
||||
Pure transformation — no `openai` package required. Turns the lean-ctx tool
|
||||
surface into OpenAI ``tools=[...]`` specs and executes the tool calls the model
|
||||
returns. Works with both the Chat Completions and Responses tool-call shapes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from ..client import LeanCtxClient
|
||||
from ._common import coerce_arguments, normalized_tool_specs
|
||||
|
||||
|
||||
def to_openai_tools(client: LeanCtxClient) -> List[Dict[str, Any]]:
|
||||
"""Return lean-ctx tools as OpenAI function-tool specs."""
|
||||
return [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": spec.name,
|
||||
"description": spec.description,
|
||||
"parameters": spec.parameters,
|
||||
},
|
||||
}
|
||||
for spec in normalized_tool_specs(client)
|
||||
]
|
||||
|
||||
|
||||
def run_openai_tool_call(client: LeanCtxClient, tool_call: Any) -> str:
|
||||
"""Execute one OpenAI tool call and return the tool's text result.
|
||||
|
||||
Accepts either the dict shape (``{"function": {"name", "arguments"}}``) or
|
||||
an SDK object exposing ``.function.name`` / ``.function.arguments``.
|
||||
"""
|
||||
function = tool_call.get("function") if isinstance(tool_call, dict) else getattr(tool_call, "function", None)
|
||||
if function is None:
|
||||
raise ValueError("tool_call has no 'function'")
|
||||
name = function.get("name") if isinstance(function, dict) else getattr(function, "name", None)
|
||||
if not name:
|
||||
raise ValueError("tool_call.function has no 'name'")
|
||||
raw_args = (
|
||||
function.get("arguments") if isinstance(function, dict) else getattr(function, "arguments", None)
|
||||
)
|
||||
return client.call_tool_text(name, coerce_arguments(raw_args))
|
||||
@@ -0,0 +1,351 @@
|
||||
"""Thin, dependency-free HTTP client for the lean-ctx ``/v1`` contract.
|
||||
|
||||
Uses only the Python standard library (``urllib``) so it installs and runs
|
||||
anywhere with no transitive dependencies. It speaks the wire protocol only — it
|
||||
never links the engine or re-implements compression — so it stays stable as
|
||||
lean-ctx evolves. Mirrors the TypeScript (`lean-ctx-client`) and Rust
|
||||
(`lean-ctx-client`) SDKs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any, Dict, Iterator, Optional
|
||||
|
||||
from .errors import LeanCtxConfigError, LeanCtxHTTPError, LeanCtxTransportError
|
||||
from .tool_text import tool_result_to_text
|
||||
|
||||
|
||||
class LeanCtxClient:
|
||||
"""A blocking client for a running lean-ctx HTTP server."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
*,
|
||||
bearer_token: Optional[str] = None,
|
||||
workspace_id: Optional[str] = None,
|
||||
channel_id: Optional[str] = None,
|
||||
timeout: float = 30.0,
|
||||
) -> None:
|
||||
trimmed = (base_url or "").strip()
|
||||
if not trimmed:
|
||||
raise LeanCtxConfigError("base_url is required")
|
||||
self.base_url = trimmed[:-1] if trimmed.endswith("/") else trimmed
|
||||
self._bearer_token = (bearer_token or "").strip() or None
|
||||
self._workspace_id = (workspace_id or "").strip() or None
|
||||
self._channel_id = (channel_id or "").strip() or None
|
||||
self._timeout = timeout
|
||||
|
||||
# -- discovery ---------------------------------------------------------
|
||||
|
||||
def health(self) -> str:
|
||||
return self._request_text("GET", "/health")
|
||||
|
||||
def manifest(self) -> Any:
|
||||
return self._get_json("/v1/manifest")
|
||||
|
||||
def capabilities(self) -> Dict[str, Any]:
|
||||
return self._get_json("/v1/capabilities")
|
||||
|
||||
def openapi(self) -> Dict[str, Any]:
|
||||
return self._get_json("/v1/openapi.json")
|
||||
|
||||
# -- tools -------------------------------------------------------------
|
||||
|
||||
def list_tools(
|
||||
self, *, offset: Optional[int] = None, limit: Optional[int] = None
|
||||
) -> Dict[str, Any]:
|
||||
query: Dict[str, str] = {}
|
||||
if offset is not None:
|
||||
query["offset"] = str(offset)
|
||||
if limit is not None:
|
||||
query["limit"] = str(limit)
|
||||
return self._get_json("/v1/tools", query)
|
||||
|
||||
def call_tool(
|
||||
self,
|
||||
name: str,
|
||||
arguments: Optional[Dict[str, Any]] = None,
|
||||
*,
|
||||
workspace_id: Optional[str] = None,
|
||||
channel_id: Optional[str] = None,
|
||||
) -> Any:
|
||||
"""Call a tool and return its raw ``result``."""
|
||||
if not name:
|
||||
raise LeanCtxConfigError("tool name is required")
|
||||
if arguments is not None and not isinstance(arguments, dict):
|
||||
raise LeanCtxConfigError("arguments must be a dict (JSON object)")
|
||||
|
||||
body: Dict[str, Any] = {"name": name}
|
||||
if arguments is not None:
|
||||
body["arguments"] = arguments
|
||||
ws = (workspace_id or "").strip() or self._workspace_id
|
||||
ch = (channel_id or "").strip() or self._channel_id
|
||||
if ws:
|
||||
body["workspaceId"] = ws
|
||||
if ch:
|
||||
body["channelId"] = ch
|
||||
|
||||
payload = self._request_json(
|
||||
"POST", "/v1/tools/call", body=body, workspace_id=ws
|
||||
)
|
||||
if not isinstance(payload, dict):
|
||||
raise LeanCtxConfigError("call_tool: unexpected response shape")
|
||||
return payload.get("result")
|
||||
|
||||
def call_tool_text(
|
||||
self,
|
||||
name: str,
|
||||
arguments: Optional[Dict[str, Any]] = None,
|
||||
**ctx: Any,
|
||||
) -> str:
|
||||
"""Call a tool and flatten its result into text."""
|
||||
return tool_result_to_text(self.call_tool(name, arguments, **ctx))
|
||||
|
||||
# -- context views -------------------------------------------------------
|
||||
|
||||
def context_summary(
|
||||
self,
|
||||
*,
|
||||
workspace_id: Optional[str] = None,
|
||||
channel_id: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Materialized workspace/channel summary (``GET /v1/context/summary``)."""
|
||||
query: Dict[str, str] = {}
|
||||
ws = (workspace_id or "").strip() or self._workspace_id
|
||||
ch = (channel_id or "").strip() or self._channel_id
|
||||
if ws:
|
||||
query["workspaceId"] = ws
|
||||
if ch:
|
||||
query["channelId"] = ch
|
||||
if limit is not None:
|
||||
query["limit"] = str(limit)
|
||||
return self._get_json("/v1/context/summary", query)
|
||||
|
||||
def search_events(
|
||||
self,
|
||||
q: str,
|
||||
*,
|
||||
workspace_id: Optional[str] = None,
|
||||
channel_id: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Full-text search over event payloads (``GET /v1/events/search``)."""
|
||||
if not q:
|
||||
raise LeanCtxConfigError("search query is required")
|
||||
query: Dict[str, str] = {"q": q}
|
||||
ws = (workspace_id or "").strip() or self._workspace_id
|
||||
ch = (channel_id or "").strip() or self._channel_id
|
||||
if ws:
|
||||
query["workspaceId"] = ws
|
||||
if ch:
|
||||
query["channelId"] = ch
|
||||
if limit is not None:
|
||||
query["limit"] = str(limit)
|
||||
return self._get_json("/v1/events/search", query)
|
||||
|
||||
def event_lineage(
|
||||
self,
|
||||
event_id: int,
|
||||
*,
|
||||
depth: Optional[int] = None,
|
||||
workspace_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Causal lineage chain for an event (``GET /v1/events/lineage``)."""
|
||||
query: Dict[str, str] = {"id": str(event_id)}
|
||||
if depth is not None:
|
||||
query["depth"] = str(depth)
|
||||
ws = (workspace_id or "").strip() or self._workspace_id
|
||||
if ws:
|
||||
query["workspaceId"] = ws
|
||||
return self._get_json("/v1/events/lineage", query)
|
||||
|
||||
def metrics(self) -> Dict[str, Any]:
|
||||
"""JSON metrics snapshot (``GET /v1/metrics``)."""
|
||||
return self._get_json("/v1/metrics")
|
||||
|
||||
# -- events (SSE) ------------------------------------------------------
|
||||
|
||||
def subscribe_events(
|
||||
self,
|
||||
*,
|
||||
workspace_id: Optional[str] = None,
|
||||
channel_id: Optional[str] = None,
|
||||
since: Optional[int] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> Iterator[Dict[str, Any]]:
|
||||
"""Yield ``ContextEventV1`` dicts from the SSE stream until the server closes it."""
|
||||
ws = (workspace_id or "").strip() or self._workspace_id
|
||||
ch = (channel_id or "").strip() or self._channel_id
|
||||
query: Dict[str, str] = {}
|
||||
if ws:
|
||||
query["workspaceId"] = ws
|
||||
if ch:
|
||||
query["channelId"] = ch
|
||||
if since is not None:
|
||||
query["since"] = str(since)
|
||||
if limit is not None:
|
||||
query["limit"] = str(limit)
|
||||
|
||||
req = self._build_request(
|
||||
"GET", "/v1/events", query, accept="text/event-stream", workspace_id=ws
|
||||
)
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, timeout=self._timeout)
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise self._http_error_from(exc, "GET", "/v1/events") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise LeanCtxTransportError(str(exc.reason)) from exc
|
||||
|
||||
with resp:
|
||||
buf = ""
|
||||
for raw in resp:
|
||||
buf += raw.decode("utf-8", "replace")
|
||||
while "\n\n" in buf:
|
||||
chunk, buf = buf.split("\n\n", 1)
|
||||
data = _parse_sse_data(chunk)
|
||||
if data is None:
|
||||
continue
|
||||
try:
|
||||
event = json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(event, dict):
|
||||
yield event
|
||||
|
||||
def events_probe(self) -> str:
|
||||
"""Open ``GET /v1/events`` and return its ``Content-Type`` header.
|
||||
|
||||
Returns as soon as response headers arrive (the body is never read),
|
||||
so it cannot block on an idle stream. Used by the conformance kit to
|
||||
prove the SSE endpoint exists and speaks ``text/event-stream``.
|
||||
"""
|
||||
req = self._build_request(
|
||||
"GET", "/v1/events", {"limit": "1"}, accept="text/event-stream"
|
||||
)
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, timeout=self._timeout)
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise self._http_error_from(exc, "GET", "/v1/events") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise LeanCtxTransportError(str(exc.reason)) from exc
|
||||
with resp:
|
||||
return resp.headers.get("content-type", "") or ""
|
||||
|
||||
# -- internals ---------------------------------------------------------
|
||||
|
||||
def _get_json(self, path: str, query: Optional[Dict[str, str]] = None) -> Any:
|
||||
return self._request_json("GET", path, query=query)
|
||||
|
||||
def _request_text(self, method: str, path: str) -> str:
|
||||
req = self._build_request(method, path, accept="text/plain")
|
||||
return self._send(req, method, path).decode("utf-8", "replace")
|
||||
|
||||
def _request_json(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
query: Optional[Dict[str, str]] = None,
|
||||
body: Optional[Dict[str, Any]] = None,
|
||||
workspace_id: Optional[str] = None,
|
||||
) -> Any:
|
||||
req = self._build_request(
|
||||
method,
|
||||
path,
|
||||
query,
|
||||
accept="application/json",
|
||||
body=body,
|
||||
workspace_id=workspace_id,
|
||||
)
|
||||
raw = self._send(req, method, path)
|
||||
if not raw:
|
||||
return None
|
||||
return json.loads(raw.decode("utf-8", "replace"))
|
||||
|
||||
def _build_request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
query: Optional[Dict[str, str]] = None,
|
||||
*,
|
||||
accept: str,
|
||||
body: Optional[Dict[str, Any]] = None,
|
||||
workspace_id: Optional[str] = None,
|
||||
) -> urllib.request.Request:
|
||||
url = self.base_url + path
|
||||
if query:
|
||||
url += "?" + urllib.parse.urlencode(query)
|
||||
data = None
|
||||
headers = {"Accept": accept}
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
if self._bearer_token:
|
||||
headers["Authorization"] = f"Bearer {self._bearer_token}"
|
||||
if workspace_id:
|
||||
headers["x-leanctx-workspace"] = workspace_id
|
||||
return urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
|
||||
def _send(self, req: urllib.request.Request, method: str, path: str) -> bytes:
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
||||
return resp.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise self._http_error_from(exc, method, path) from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise LeanCtxTransportError(str(exc.reason)) from exc
|
||||
|
||||
def _http_error_from(
|
||||
self, exc: urllib.error.HTTPError, method: str, path: str
|
||||
) -> LeanCtxHTTPError:
|
||||
url = self.base_url + path
|
||||
message = f"HTTP {exc.code} {method} {url}"
|
||||
error_code = None
|
||||
body: Any = None
|
||||
try:
|
||||
raw = exc.read()
|
||||
except Exception: # pragma: no cover - defensive
|
||||
raw = b""
|
||||
content_type = exc.headers.get("content-type", "") if exc.headers else ""
|
||||
if raw:
|
||||
if "application/json" in content_type:
|
||||
try:
|
||||
body = json.loads(raw.decode("utf-8", "replace"))
|
||||
if isinstance(body, dict):
|
||||
if isinstance(body.get("error"), str) and body["error"].strip():
|
||||
message = body["error"]
|
||||
if isinstance(body.get("error_code"), str):
|
||||
error_code = body["error_code"].strip() or None
|
||||
except json.JSONDecodeError:
|
||||
body = raw.decode("utf-8", "replace")
|
||||
else:
|
||||
text = raw.decode("utf-8", "replace")
|
||||
body = text
|
||||
if text.strip():
|
||||
message = text.strip()
|
||||
return LeanCtxHTTPError(
|
||||
status=exc.code,
|
||||
method=method,
|
||||
url=url,
|
||||
message=message,
|
||||
error_code=error_code,
|
||||
body=body,
|
||||
)
|
||||
|
||||
|
||||
def _parse_sse_data(chunk: str) -> Optional[str]:
|
||||
"""Join the ``data:`` lines of one SSE frame; ignore id/event/comments."""
|
||||
data_lines = []
|
||||
for line in chunk.split("\n"):
|
||||
line = line.rstrip("\r")
|
||||
if not line or line.startswith(":"):
|
||||
continue
|
||||
if line.startswith("data:"):
|
||||
data_lines.append(line[5:].lstrip())
|
||||
return "\n".join(data_lines) if data_lines else None
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Shared SDK conformance kit (EPIC 12.4/12.5, industrialized in GL #395).
|
||||
|
||||
A client-side check that proves the Python SDK + a live server interoperate over
|
||||
the **entire** frozen ``/v1`` contract. It is the exact mirror of the TypeScript
|
||||
SDK's ``runConformance`` and the Rust client's ``run_conformance``, so every
|
||||
first-party SDK proves the same contract and they stay in lockstep.
|
||||
|
||||
Two checks make this a drift gate (GL #395):
|
||||
|
||||
* ``route_coverage`` — every path the server's OpenAPI document advertises must
|
||||
be covered by an SDK method (``COVERED_ROUTES``). A new server route without
|
||||
SDK support fails conformance in the next CI run.
|
||||
* ``engine_compat`` — the server's ``http_mcp`` contract version must be one
|
||||
this SDK release supports (``SUPPORTED_HTTP_CONTRACT_VERSIONS``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, List
|
||||
|
||||
from .client import LeanCtxClient
|
||||
from .errors import LeanCtxHTTPError
|
||||
|
||||
#: ``METHOD path`` → client method name. The conformance kit fails if the live
|
||||
#: server's OpenAPI document lists a route that is missing here.
|
||||
COVERED_ROUTES: Dict[str, str] = {
|
||||
"GET /health": "health",
|
||||
"GET /v1/manifest": "manifest",
|
||||
"GET /v1/capabilities": "capabilities",
|
||||
"GET /v1/openapi.json": "openapi",
|
||||
"GET /v1/tools": "list_tools",
|
||||
"POST /v1/tools/call": "call_tool",
|
||||
"GET /v1/events": "subscribe_events",
|
||||
"GET /v1/context/summary": "context_summary",
|
||||
"GET /v1/events/search": "search_events",
|
||||
"GET /v1/events/lineage": "event_lineage",
|
||||
"GET /v1/metrics": "metrics",
|
||||
}
|
||||
|
||||
#: ``http_mcp`` contract versions this SDK release speaks (SemVer coupling:
|
||||
#: the SDK major follows the engine contract major).
|
||||
SUPPORTED_HTTP_CONTRACT_VERSIONS = (1,)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConformanceCheck:
|
||||
name: str
|
||||
passed: bool
|
||||
detail: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConformanceScorecard:
|
||||
checks: List[ConformanceCheck] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def passed(self) -> int:
|
||||
return sum(1 for c in self.checks if c.passed)
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
return len(self.checks)
|
||||
|
||||
@property
|
||||
def all_passed(self) -> bool:
|
||||
return all(c.passed for c in self.checks)
|
||||
|
||||
|
||||
def _add(card: ConformanceScorecard, name: str, probe: Callable[[], Any]) -> None:
|
||||
"""Run one probe; contract/network failures become failed checks."""
|
||||
try:
|
||||
ok, detail = probe()
|
||||
card.checks.append(ConformanceCheck(name, bool(ok), detail))
|
||||
except Exception as exc: # noqa: BLE001 - capture as a failed check
|
||||
card.checks.append(ConformanceCheck(name, False, str(exc)))
|
||||
|
||||
|
||||
def run_conformance(client: LeanCtxClient) -> ConformanceScorecard:
|
||||
"""Run the conformance kit against a live client.
|
||||
|
||||
Network/contract failures become failed checks rather than exceptions, so
|
||||
the returned scorecard is always complete and comparable across SDKs.
|
||||
"""
|
||||
card = ConformanceScorecard()
|
||||
|
||||
def health() -> Any:
|
||||
return isinstance(client.health(), str), ""
|
||||
|
||||
def manifest_shape() -> Any:
|
||||
m = client.manifest()
|
||||
return isinstance(m, dict) and bool(m), ""
|
||||
|
||||
def capabilities_shape() -> Any:
|
||||
caps = client.capabilities()
|
||||
server = caps.get("server", {}) if isinstance(caps, dict) else {}
|
||||
ok = (
|
||||
isinstance(caps, dict)
|
||||
and isinstance(caps.get("contract_version"), int)
|
||||
and isinstance(server, dict)
|
||||
and bool(server.get("version"))
|
||||
and isinstance(caps.get("plane"), str)
|
||||
and isinstance(caps.get("transports"), list)
|
||||
and isinstance(caps.get("features"), dict)
|
||||
and isinstance(caps.get("contracts"), dict)
|
||||
)
|
||||
return ok, ""
|
||||
|
||||
def contract_status_map() -> Any:
|
||||
# GL #394: stability per contract is part of the discovery document.
|
||||
status = client.capabilities().get("contract_status")
|
||||
ok = isinstance(status, dict) and status.get("http-mcp") in (
|
||||
"frozen",
|
||||
"stable",
|
||||
)
|
||||
return ok, "" if ok else f"contract_status={status!r}"
|
||||
|
||||
def engine_compat() -> Any:
|
||||
contracts = client.capabilities().get("contracts", {})
|
||||
version = contracts.get("leanctx.contract.http_mcp.contract_version")
|
||||
ok = version in SUPPORTED_HTTP_CONTRACT_VERSIONS
|
||||
return ok, "" if ok else f"server http_mcp contract v{version!r} unsupported"
|
||||
|
||||
def openapi_shape() -> Any:
|
||||
doc = client.openapi()
|
||||
version = doc.get("openapi", "") if isinstance(doc, dict) else ""
|
||||
ok = (
|
||||
isinstance(version, str)
|
||||
and version.startswith("3.")
|
||||
and isinstance(doc.get("paths"), dict)
|
||||
)
|
||||
return ok, ""
|
||||
|
||||
def route_coverage() -> Any:
|
||||
# The drift gate: every advertised route needs an SDK method.
|
||||
doc = client.openapi()
|
||||
paths = doc.get("paths", {}) if isinstance(doc, dict) else {}
|
||||
uncovered = [
|
||||
f"{method.upper()} {path}"
|
||||
for path, ops in paths.items()
|
||||
if isinstance(ops, dict)
|
||||
for method in ops
|
||||
if f"{method.upper()} {path}" not in COVERED_ROUTES
|
||||
]
|
||||
return not uncovered, ", ".join(sorted(uncovered))
|
||||
|
||||
def tools_list() -> Any:
|
||||
listing = client.list_tools(limit=1)
|
||||
ok = (
|
||||
isinstance(listing, dict)
|
||||
and isinstance(listing.get("tools"), list)
|
||||
and isinstance(listing.get("total"), int)
|
||||
and listing["total"] >= 0
|
||||
)
|
||||
return ok, ""
|
||||
|
||||
def tool_call_error_contract() -> Any:
|
||||
# Typed-error semantics: an unknown tool must produce a structured
|
||||
# 4xx with a machine-readable error_code, not a 5xx or free text.
|
||||
try:
|
||||
client.call_tool("definitely_not_a_tool_conformance_probe")
|
||||
except LeanCtxHTTPError as exc:
|
||||
ok = 400 <= exc.status < 500 and bool(exc.error_code)
|
||||
return ok, "" if ok else f"status={exc.status} error_code={exc.error_code!r}"
|
||||
return False, "unknown tool call unexpectedly succeeded"
|
||||
|
||||
def events_stream() -> Any:
|
||||
content_type = client.events_probe()
|
||||
ok = content_type.startswith("text/event-stream")
|
||||
return ok, "" if ok else f"content-type={content_type!r}"
|
||||
|
||||
def context_summary_shape() -> Any:
|
||||
summary = client.context_summary(limit=1)
|
||||
ok = (
|
||||
isinstance(summary, dict)
|
||||
and isinstance(summary.get("workspaceId"), str)
|
||||
and isinstance(summary.get("totalEvents"), int)
|
||||
and isinstance(summary.get("eventCountsByKind"), dict)
|
||||
)
|
||||
return ok, ""
|
||||
|
||||
def events_search_shape() -> Any:
|
||||
res = client.search_events("conformance-probe", limit=1)
|
||||
ok = (
|
||||
isinstance(res, dict)
|
||||
and isinstance(res.get("results"), list)
|
||||
and isinstance(res.get("count"), int)
|
||||
)
|
||||
return ok, ""
|
||||
|
||||
def event_lineage_shape() -> Any:
|
||||
res = client.event_lineage(1, depth=1)
|
||||
ok = (
|
||||
isinstance(res, dict)
|
||||
and "eventId" in res
|
||||
and isinstance(res.get("chain"), list)
|
||||
)
|
||||
return ok, ""
|
||||
|
||||
def metrics_shape() -> Any:
|
||||
return isinstance(client.metrics(), dict), ""
|
||||
|
||||
_add(card, "health", health)
|
||||
_add(card, "manifest_shape", manifest_shape)
|
||||
_add(card, "capabilities_shape", capabilities_shape)
|
||||
_add(card, "contract_status_map", contract_status_map)
|
||||
_add(card, "engine_compat", engine_compat)
|
||||
_add(card, "openapi_shape", openapi_shape)
|
||||
_add(card, "route_coverage", route_coverage)
|
||||
_add(card, "tools_list", tools_list)
|
||||
_add(card, "tool_call_error_contract", tool_call_error_contract)
|
||||
_add(card, "events_stream", events_stream)
|
||||
_add(card, "context_summary_shape", context_summary_shape)
|
||||
_add(card, "events_search_shape", events_search_shape)
|
||||
_add(card, "event_lineage_shape", event_lineage_shape)
|
||||
_add(card, "metrics_shape", metrics_shape)
|
||||
return card
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Error types for the lean-ctx Python client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
class LeanCtxError(Exception):
|
||||
"""Base class for all lean-ctx client errors."""
|
||||
|
||||
|
||||
class LeanCtxConfigError(LeanCtxError):
|
||||
"""Raised for invalid client configuration or arguments (no I/O performed)."""
|
||||
|
||||
|
||||
class LeanCtxTransportError(LeanCtxError):
|
||||
"""Raised when the request never produced an HTTP response (network/DNS/TLS)."""
|
||||
|
||||
|
||||
class LeanCtxHTTPError(LeanCtxError):
|
||||
"""Raised for a non-2xx HTTP response from the server.
|
||||
|
||||
Carries enough structured detail (status, method, url, server error code and
|
||||
body) for programmatic handling, mirroring the Rust/TS SDK error shape.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
status: int,
|
||||
method: str,
|
||||
url: str,
|
||||
message: str,
|
||||
error_code: Optional[str] = None,
|
||||
body: Any = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
self.method = method
|
||||
self.url = url
|
||||
self.message = message
|
||||
self.error_code = error_code
|
||||
self.body = body
|
||||
|
||||
def __str__(self) -> str: # pragma: no cover - trivial
|
||||
code = f" [{self.error_code}]" if self.error_code else ""
|
||||
return f"HTTP {self.status} {self.method} {self.url}{code}: {self.message}"
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Extract plain text from an MCP tool result.
|
||||
|
||||
Mirrors `toolText.ts` / `tool_text.rs` so all SDKs flatten tool results the same
|
||||
way: concatenate the ``text`` of every ``{"type": "text", "text": ...}`` content
|
||||
block. Non-text blocks are ignored; a bare string result is returned as-is.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def tool_result_to_text(result: Any) -> str:
|
||||
"""Flatten an MCP tool result into a single text string."""
|
||||
if result is None:
|
||||
return ""
|
||||
if isinstance(result, str):
|
||||
return result
|
||||
if isinstance(result, dict):
|
||||
content = result.get("content")
|
||||
if isinstance(content, list):
|
||||
parts = []
|
||||
for block in content:
|
||||
if (
|
||||
isinstance(block, dict)
|
||||
and block.get("type") == "text"
|
||||
and isinstance(block.get("text"), str)
|
||||
):
|
||||
parts.append(block["text"])
|
||||
return "".join(parts)
|
||||
return ""
|
||||
Reference in New Issue
Block a user