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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:35:30 +08:00
commit 26382a7ac6
2353 changed files with 629146 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
__pycache__/
*.py[cod]
*.egg-info/
.eggs/
build/
dist/
.pytest_cache/
.venv/
+123
View File
@@ -0,0 +1,123 @@
# lean-ctx-client (Python SDK)
Thin, **dependency-free** Python client for the lean-ctx HTTP `/v1` contract.
Standard library only (`urllib`) — installs and runs anywhere, no transitive
dependencies. It speaks the wire protocol only; it never links the engine or
re-implements compression. Mirrors the TypeScript and Rust
`lean-ctx-client` SDKs.
## Install
```bash
pip install lean-ctx-client
# from this repo:
pip install ./clients/python
```
## Usage
```python
from leanctx import LeanCtxClient, run_conformance
client = LeanCtxClient("http://127.0.0.1:8080")
# Discovery
caps = client.capabilities() # GET /v1/capabilities
api = client.openapi() # GET /v1/openapi.json
# Tools
listing = client.list_tools(limit=10)
text = client.call_tool_text("ctx_read", {"path": "README.md"})
# Live events (SSE)
for event in client.subscribe_events():
print(event["kind"], event["payload"])
```
## Methods
| Method | Endpoint |
|--------|----------|
| `health()` | `GET /health` |
| `manifest()` | `GET /v1/manifest` |
| `capabilities()` | `GET /v1/capabilities` |
| `openapi()` | `GET /v1/openapi.json` |
| `list_tools(offset=, limit=)` | `GET /v1/tools` |
| `call_tool(name, arguments, ...)` | `POST /v1/tools/call` |
| `call_tool_text(name, arguments, ...)` | `POST /v1/tools/call` + text extraction |
| `subscribe_events(...)` | `GET /v1/events` (SSE) |
| `context_summary(...)` | `GET /v1/context/summary` |
| `search_events(q, ...)` | `GET /v1/events/search` |
| `event_lineage(event_id, ...)` | `GET /v1/events/lineage` |
| `metrics()` | `GET /v1/metrics` |
## Shared conformance kit
`run_conformance(client)` runs the language-agnostic SDK conformance checks
against a live server and returns a scorecard. It mirrors the TypeScript SDK's
`runConformance` and the server-side `lean-ctx conformance`, keeping all clients
in lockstep on the same contract.
```python
card = run_conformance(client)
assert card.all_passed, [c for c in card.checks if not c.passed]
```
The kit covers **every** `/v1` route (`COVERED_ROUTES`): its `route_coverage`
check fails when the server advertises a route this SDK does not cover, and
`engine_compat` fails when the server speaks an `http_mcp` contract version
outside `SUPPORTED_HTTP_CONTRACT_VERSIONS`. The published matrix lives at
`docs/reference/sdk-conformance-matrix.md` (regenerated by
`scripts/sdk-conformance.sh` against a real server in the `sdk-conformance`
CI job).
### SemVer coupling
The SDK's major version follows the engine's `http_mcp` contract major
(CONTRACTS.md § Versioning rules): a `v2` contract ships as SDK `2.x`, and
`1.x` keeps speaking `v1`.
## Framework adapters
Expose the lean-ctx tool surface to popular agent frameworks. Each framework is
an **optional** dependency, imported lazily — installing `lean-ctx-client` pulls in none
of them. The OpenAI adapter is a pure transformation and needs no extra package.
```python
from leanctx import LeanCtxClient
from leanctx.adapters import (
to_openai_tools, run_openai_tool_call, # no extra dep
to_langchain_tools, # pip install "lean-ctx-client[langchain]"
to_llamaindex_tools, # pip install "lean-ctx-client[llamaindex]"
to_crewai_tools, # pip install "lean-ctx-client[crewai]"
)
client = LeanCtxClient("http://127.0.0.1:8080")
# OpenAI function calling
tools = to_openai_tools(client)
# ... pass tools to client.chat.completions.create(...), then:
text = run_openai_tool_call(client, tool_call)
# LangChain / LlamaIndex / CrewAI
lc_tools = to_langchain_tools(client)
```
## Errors
- `LeanCtxConfigError` — invalid arguments / configuration (no I/O performed).
- `LeanCtxTransportError` — request never produced a response (network/DNS/TLS).
- `LeanCtxHTTPError` — non-2xx response, with `status`, `method`, `url`,
`error_code`, and parsed `body`.
## Non-goals
- No engine linkage and no re-implemented compression/indexing logic.
- Stability over surface: only the documented `/v1` contract is exposed.
## Development
```bash
cd clients/python
python -m pytest
```
+40
View File
@@ -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
+53
View File
@@ -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
+46
View File
@@ -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))
+351
View File
@@ -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
+217
View File
@@ -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
+47
View File
@@ -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}"
+31
View File
@@ -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 ""
+36
View File
@@ -0,0 +1,36 @@
[build-system]
requires = ["setuptools>=64"]
build-backend = "setuptools.build_meta"
[project]
name = "lean-ctx-client"
version = "0.1.0"
description = "Thin, dependency-free Python client for the lean-ctx HTTP /v1 contract"
readme = "README.md"
requires-python = ">=3.9"
license = { text = "MIT" }
authors = [{ name = "lean-ctx" }]
keywords = ["lean-ctx", "llm", "context", "mcp", "agent"]
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Intended Audience :: Developers",
"Topic :: Software Development :: Libraries",
]
# Standard-library only: no runtime dependencies.
dependencies = []
[project.optional-dependencies]
dev = ["pytest>=7"]
# Framework adapters (EPIC 12.6). The OpenAI adapter is pure and needs no extra.
langchain = ["langchain-core>=0.2"]
llamaindex = ["llama-index-core>=0.10"]
crewai = ["crewai>=0.30"]
[project.urls]
Homepage = "https://github.com/yvgude/lean-ctx"
Repository = "https://github.com/yvgude/lean-ctx"
[tool.setuptools.packages.find]
where = ["."]
include = ["leanctx*"]
+156
View File
@@ -0,0 +1,156 @@
"""Tests for the framework adapters.
The OpenAI adapter is a pure transformation and is fully tested against a stub
server. The framework adapters (LangChain/LlamaIndex/CrewAI) are optional deps;
each test runs the adapter if the framework is importable, otherwise asserts the
helpful ImportError — so the suite is deterministic with or without them.
"""
from __future__ import annotations
import importlib.util
import json
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import Any, Tuple
import pytest
from leanctx import LeanCtxClient
from leanctx.adapters import (
coerce_arguments,
normalized_tool_specs,
run_openai_tool_call,
to_crewai_tools,
to_langchain_tools,
to_llamaindex_tools,
to_openai_tools,
)
TOOLS = [
{
"name": "ctx_read",
"description": "Read a file",
"input_schema": {
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"],
},
},
{"name": "ctx_tree", "description": "List a directory", "input_schema": {"type": "object"}},
]
class _Handler(BaseHTTPRequestHandler):
def log_message(self, *args: Any) -> None:
pass
def _send(self, status: int, body: Any) -> None:
payload = json.dumps(body).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def do_GET(self) -> None: # noqa: N802
if self.path.startswith("/v1/tools"):
self._send(200, {"tools": TOOLS, "total": len(TOOLS), "offset": 0, "limit": 500})
else:
self._send(404, {"error": "unknown"})
def do_POST(self) -> None: # noqa: N802
length = int(self.headers.get("Content-Length", "0"))
body = json.loads(self.rfile.read(length) or b"{}")
text = f"called {body.get('name')} with {json.dumps(body.get('arguments', {}), sort_keys=True)}"
self._send(200, {"result": {"content": [{"type": "text", "text": text}]}})
@pytest.fixture()
def client() -> Tuple[LeanCtxClient, HTTPServer]:
httpd = HTTPServer(("127.0.0.1", 0), _Handler)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
host, port = httpd.server_address
yield LeanCtxClient(f"http://{host}:{port}"), httpd
httpd.shutdown()
def test_normalized_specs(client: Tuple[LeanCtxClient, HTTPServer]) -> None:
c, _ = client
specs = normalized_tool_specs(c)
assert [s.name for s in specs] == ["ctx_read", "ctx_tree"]
assert specs[0].parameters["required"] == ["path"]
def test_to_openai_tools_shape(client: Tuple[LeanCtxClient, HTTPServer]) -> None:
c, _ = client
tools = to_openai_tools(c)
assert tools[0]["type"] == "function"
assert tools[0]["function"]["name"] == "ctx_read"
assert tools[0]["function"]["parameters"]["properties"]["path"]["type"] == "string"
def test_run_openai_tool_call_dict_and_object(client: Tuple[LeanCtxClient, HTTPServer]) -> None:
c, _ = client
# dict shape with JSON-string arguments
out = run_openai_tool_call(
c, {"function": {"name": "ctx_read", "arguments": '{"path": "README.md"}'}}
)
assert out == 'called ctx_read with {"path": "README.md"}'
# object shape with dict arguments
class _Fn:
name = "ctx_tree"
arguments = {"path": "."}
class _Call:
function = _Fn()
out2 = run_openai_tool_call(c, _Call())
assert out2 == 'called ctx_tree with {"path": "."}'
def test_coerce_arguments() -> None:
assert coerce_arguments(None) == {}
assert coerce_arguments("") == {}
assert coerce_arguments('{"a": 1}') == {"a": 1}
assert coerce_arguments({"a": 1}) == {"a": 1}
assert coerce_arguments("[1,2]") == {} # non-object JSON -> empty
def _has(mod: str) -> bool:
# find_spec raises (not returns None) when a parent package is absent.
try:
return importlib.util.find_spec(mod) is not None
except ModuleNotFoundError:
return False
def test_langchain_adapter(client: Tuple[LeanCtxClient, HTTPServer]) -> None:
c, _ = client
if _has("langchain_core"):
tools = to_langchain_tools(c)
assert len(tools) == 2
else:
with pytest.raises(ImportError, match="langchain-core"):
to_langchain_tools(c)
def test_llamaindex_adapter(client: Tuple[LeanCtxClient, HTTPServer]) -> None:
c, _ = client
if _has("llama_index.core"):
tools = to_llamaindex_tools(c)
assert len(tools) == 2
else:
with pytest.raises(ImportError, match="llama-index-core"):
to_llamaindex_tools(c)
def test_crewai_adapter(client: Tuple[LeanCtxClient, HTTPServer]) -> None:
c, _ = client
if _has("crewai"):
tools = to_crewai_tools(c)
assert len(tools) == 2
else:
with pytest.raises(ImportError, match="crewai"):
to_crewai_tools(c)
+108
View File
@@ -0,0 +1,108 @@
"""Live adapter smoke tests against a real lean-ctx server (GL #395).
One end-to-end test per framework adapter (OpenAI / LangChain / LlamaIndex /
CrewAI): build the tools from the live ``/v1/tools`` manifest, execute one real
tool call through the framework's own invocation path, and assert real output.
Driven by ``scripts/sdk-conformance.sh`` (CI job ``sdk-conformance``) via
``LEANCTX_CONFORMANCE_URL``. Without the URL the suite skips (hermetic local
``pytest``); without the optional framework the individual test skips.
"""
from __future__ import annotations
import os
import pytest
from leanctx import LeanCtxClient
from leanctx.adapters import (
run_openai_tool_call,
to_crewai_tools,
to_langchain_tools,
to_llamaindex_tools,
to_openai_tools,
)
from leanctx.adapters._common import normalized_tool_specs
URL = os.environ.get("LEANCTX_CONFORMANCE_URL", "").strip()
pytestmark = pytest.mark.skipif(not URL, reason="LEANCTX_CONFORMANCE_URL not set")
@pytest.fixture(scope="module")
def client() -> LeanCtxClient:
return LeanCtxClient(
URL, bearer_token=os.environ.get("LEANCTX_CONFORMANCE_TOKEN") or None
)
@pytest.fixture(scope="module")
def smoke_tool(client: LeanCtxClient) -> str:
"""Pick a real tool that needs no arguments, straight from the live manifest."""
specs = normalized_tool_specs(client)
assert specs, "live server returned no tools"
no_arg = [s.name for s in specs if not s.parameters.get("required")]
assert no_arg, "no argument-free tool available for the smoke call"
# Prefer cheap, read-only diagnostics when present.
for preferred in ("ctx_health", "ctx_metrics", "ctx_overview"):
if preferred in no_arg:
return preferred
return no_arg[0]
def _pick(items: list, name_of, name: str) -> object:
matches = [t for t in items if name_of(t) == name]
assert matches, f"{name} missing from adapter output"
return matches[0]
def test_openai_adapter_live_round_trip(client: LeanCtxClient, smoke_tool: str) -> None:
specs = to_openai_tools(client)
assert specs and all(s["type"] == "function" for s in specs)
spec = _pick(specs, lambda s: s["function"]["name"], smoke_tool)
# The exact dict shape an OpenAI chat completion returns for a tool call.
out = run_openai_tool_call(
client,
{"function": {"name": spec["function"]["name"], "arguments": "{}"}},
)
assert isinstance(out, str) and out.strip(), "empty tool output"
def test_langchain_adapter_live_round_trip(
client: LeanCtxClient, smoke_tool: str
) -> None:
pytest.importorskip("langchain_core")
tool = _pick(to_langchain_tools(client), lambda t: t.name, smoke_tool)
out = tool.invoke("{}")
assert isinstance(out, str) and out.strip()
def test_llamaindex_adapter_live_round_trip(
client: LeanCtxClient, smoke_tool: str
) -> None:
pytest.importorskip("llama_index.core")
tool = _pick(
to_llamaindex_tools(client), lambda t: t.metadata.name, smoke_tool
)
out = tool.call("{}")
assert str(out).strip()
def test_crewai_adapter_live_round_trip(
client: LeanCtxClient, smoke_tool: str
) -> None:
pytest.importorskip("crewai")
tool = _pick(to_crewai_tools(client), lambda t: t.name, smoke_tool)
out = tool.run(arguments="{}")
assert str(out).strip()
def test_adapter_specs_cover_full_manifest(client: LeanCtxClient) -> None:
"""Drift gate: every tool in the live manifest converts to an OpenAI spec."""
manifest_names = {s.name for s in normalized_tool_specs(client)}
spec_names = {s["function"]["name"] for s in to_openai_tools(client)}
assert spec_names == manifest_names, (
f"adapter dropped tools: {sorted(manifest_names - spec_names)}"
)
+137
View File
@@ -0,0 +1,137 @@
"""Integration tests against a real in-process HTTP server (stdlib only)."""
from __future__ import annotations
import json
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import Any, Dict, Tuple
import pytest
from leanctx import LeanCtxClient, LeanCtxConfigError, LeanCtxHTTPError
class _Handler(BaseHTTPRequestHandler):
def log_message(self, *args: Any) -> None: # silence test output
pass
def _send(self, status: int, body: Any, content_type: str = "application/json") -> None:
payload = body if isinstance(body, bytes) else json.dumps(body).encode()
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def do_GET(self) -> None: # noqa: N802
if self.path == "/health":
self._send(200, b"ok", "text/plain")
elif self.path == "/v1/capabilities":
self._send(
200,
{
"contract_version": 1,
"server": {"name": "lean-ctx", "version": "3.7.5"},
"plane": "personal",
"transports": ["rest"],
"presets": ["coding"],
"read_modes": ["full"],
"tools": {"total": 1, "names": ["ctx_read"]},
"features": {},
"extensions": {},
"contracts": {},
},
)
elif self.path == "/v1/openapi.json":
self._send(200, {"openapi": "3.0.3", "info": {}, "paths": {}})
elif self.path.startswith("/v1/tools"):
self._send(200, {"tools": [], "total": 0, "offset": 0, "limit": 1})
elif self.path == "/v1/notfound":
self._send(404, {"error": "nope", "error_code": "E_NOT_FOUND"})
else:
self._send(404, {"error": "unknown"})
def do_POST(self) -> None: # noqa: N802
length = int(self.headers.get("Content-Length", "0"))
raw = self.rfile.read(length) if length else b"{}"
body = json.loads(raw)
# Echo back what we received so the test can assert forwarding.
self._send(
200,
{
"result": {
"content": [{"type": "text", "text": "tool-ok"}],
"echo": body,
"workspace": self.headers.get("x-leanctx-workspace"),
}
},
)
@pytest.fixture()
def server() -> Tuple[str, HTTPServer]:
httpd = HTTPServer(("127.0.0.1", 0), _Handler)
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
host, port = httpd.server_address
yield f"http://{host}:{port}", httpd
httpd.shutdown()
def test_health(server: Tuple[str, HTTPServer]) -> None:
base, _ = server
client = LeanCtxClient(base)
assert client.health() == "ok"
def test_capabilities_and_openapi(server: Tuple[str, HTTPServer]) -> None:
base, _ = server
client = LeanCtxClient(base)
caps = client.capabilities()
assert caps["contract_version"] == 1
assert caps["server"]["version"] == "3.7.5"
api = client.openapi()
assert api["openapi"].startswith("3.")
def test_list_tools(server: Tuple[str, HTTPServer]) -> None:
base, _ = server
client = LeanCtxClient(base)
listing = client.list_tools(limit=1)
assert listing["total"] == 0
assert listing["tools"] == []
def test_call_tool_forwards_args_and_workspace(server: Tuple[str, HTTPServer]) -> None:
base, _ = server
client = LeanCtxClient(base, workspace_id="ws1", channel_id="ch1")
result: Dict[str, Any] = client.call_tool("ctx_read", {"path": "x"})
assert result["echo"]["arguments"] == {"path": "x"}
assert result["echo"]["workspaceId"] == "ws1"
assert result["echo"]["channelId"] == "ch1"
assert result["workspace"] == "ws1"
def test_call_tool_text(server: Tuple[str, HTTPServer]) -> None:
base, _ = server
client = LeanCtxClient(base)
assert client.call_tool_text("ctx_read", {"path": "x"}) == "tool-ok"
def test_http_error_parsing(server: Tuple[str, HTTPServer]) -> None:
base, _ = server
client = LeanCtxClient(base)
with pytest.raises(LeanCtxHTTPError) as exc:
client._get_json("/v1/notfound")
assert exc.value.status == 404
assert exc.value.error_code == "E_NOT_FOUND"
assert exc.value.message == "nope"
def test_invalid_config() -> None:
with pytest.raises(LeanCtxConfigError):
LeanCtxClient("")
client = LeanCtxClient("http://127.0.0.1:9")
with pytest.raises(LeanCtxConfigError):
client.call_tool("")
+142
View File
@@ -0,0 +1,142 @@
"""Conformance-kit tests against the in-process stub server."""
from __future__ import annotations
import json
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import Any, Tuple
from urllib.parse import urlparse
from leanctx import COVERED_ROUTES, LeanCtxClient, run_conformance
CAPS = {
"contract_version": 1,
"server": {"name": "lean-ctx", "version": "3.7.5"},
"plane": "personal",
"transports": ["rest"],
"presets": ["coding"],
"read_modes": ["full"],
"tools": {"total": 1, "names": ["ctx_read"]},
"features": {},
"extensions": {},
"contracts": {"leanctx.contract.http_mcp.contract_version": 1},
"contract_status": {"http-mcp": "frozen"},
}
def _openapi_paths() -> dict:
"""OpenAPI paths mirroring COVERED_ROUTES (the live server's shape)."""
paths: dict = {}
for route in COVERED_ROUTES:
method, path = route.split(" ", 1)
paths.setdefault(path, {})[method.lower()] = {"summary": route}
return paths
def _make_handler(caps: Any, extra_route: str | None = None):
class _Handler(BaseHTTPRequestHandler):
def log_message(self, *args: Any) -> None:
pass
def _send(self, status: int, body: Any, ct: str = "application/json") -> None:
payload = body if isinstance(body, bytes) else json.dumps(body).encode()
self.send_response(status)
self.send_header("Content-Type", ct)
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def do_GET(self) -> None: # noqa: N802
path = urlparse(self.path).path
if path == "/health":
self._send(200, b"ok", "text/plain")
elif path == "/v1/manifest":
self._send(200, {"schema_version": 1, "tools": []})
elif path == "/v1/capabilities":
self._send(200, caps)
elif path == "/v1/openapi.json":
paths = _openapi_paths()
if extra_route:
method, route_path = extra_route.split(" ", 1)
paths.setdefault(route_path, {})[method.lower()] = {}
self._send(200, {"openapi": "3.0.3", "info": {}, "paths": paths})
elif path == "/v1/tools":
self._send(200, {"tools": [], "total": 0, "offset": 0, "limit": 1})
elif path == "/v1/events":
self._send(200, b"", "text/event-stream")
elif path == "/v1/context/summary":
self._send(
200,
{
"workspaceId": "default",
"channelId": "default",
"totalEvents": 0,
"latestVersion": 0,
"activeAgents": [],
"recentDecisions": [],
"knowledgeDelta": [],
"conflictAlerts": [],
"eventCountsByKind": {},
},
)
elif path == "/v1/events/search":
self._send(200, {"query": "x", "results": [], "count": 0})
elif path == "/v1/events/lineage":
self._send(200, {"eventId": 1, "chain": [], "depth": 0})
elif path == "/v1/metrics":
self._send(200, {"events_published": 0})
else:
self._send(404, {"error": "unknown", "error_code": "not_found"})
def do_POST(self) -> None: # noqa: N802
if urlparse(self.path).path == "/v1/tools/call":
self._send(
404,
{"error": "unknown tool", "error_code": "unknown_tool"},
)
else:
self._send(404, {"error": "unknown", "error_code": "not_found"})
return _Handler
def _serve(caps: Any, extra_route: str | None = None) -> Tuple[str, HTTPServer]:
httpd = HTTPServer(("127.0.0.1", 0), _make_handler(caps, extra_route))
threading.Thread(target=httpd.serve_forever, daemon=True).start()
host, port = httpd.server_address
return f"http://{host}:{port}", httpd
def test_conformance_passes_against_valid_server() -> None:
base, httpd = _serve(CAPS)
try:
card = run_conformance(LeanCtxClient(base))
assert card.all_passed, [c for c in card.checks if not c.passed]
assert card.total == 14
finally:
httpd.shutdown()
def test_conformance_flags_malformed_capabilities() -> None:
base, httpd = _serve({"wrong": True})
try:
card = run_conformance(LeanCtxClient(base))
assert not card.all_passed
for name in ("capabilities_shape", "contract_status_map", "engine_compat"):
failed = next(c for c in card.checks if c.name == name)
assert not failed.passed, name
finally:
httpd.shutdown()
def test_route_coverage_catches_endpoint_drift() -> None:
# GL #395 AC 3: a route the SDK does not cover fails within one run.
base, httpd = _serve(CAPS, extra_route="GET /v1/brand-new-route")
try:
card = run_conformance(LeanCtxClient(base))
coverage = next(c for c in card.checks if c.name == "route_coverage")
assert not coverage.passed
assert "GET /v1/brand-new-route" in coverage.detail
finally:
httpd.shutdown()
@@ -0,0 +1,49 @@
"""Live conformance run against a real lean-ctx server (GL #395).
Driven by ``scripts/sdk-conformance.sh`` (CI job ``sdk-conformance``): the
script builds the engine, starts ``lean-ctx serve`` and exports
``LEANCTX_CONFORMANCE_URL``. Without that variable the suite skips, so plain
``pytest`` runs stay hermetic.
"""
from __future__ import annotations
import json
import os
import pathlib
import pytest
from leanctx import LeanCtxClient, run_conformance
URL = os.environ.get("LEANCTX_CONFORMANCE_URL", "").strip()
@pytest.mark.skipif(not URL, reason="LEANCTX_CONFORMANCE_URL not set")
def test_live_conformance_all_checks_pass() -> None:
client = LeanCtxClient(
URL, bearer_token=os.environ.get("LEANCTX_CONFORMANCE_TOKEN") or None
)
card = run_conformance(client)
matrix_dir = os.environ.get("LEANCTX_MATRIX_DIR", "").strip()
if matrix_dir:
out = pathlib.Path(matrix_dir) / "conformance-python.json"
out.write_text(
json.dumps(
{
"sdk": "python",
"passed": card.passed,
"total": card.total,
"all_passed": card.all_passed,
"checks": [
{"name": c.name, "passed": c.passed, "detail": c.detail}
for c in card.checks
],
},
indent=2,
)
)
failed = [f"{c.name}: {c.detail}" for c in card.checks if not c.passed]
assert card.all_passed, failed