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,8 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
build/
|
||||
dist/
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
@@ -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
|
||||
```
|
||||
@@ -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 ""
|
||||
@@ -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*"]
|
||||
@@ -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)
|
||||
@@ -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)}"
|
||||
)
|
||||
@@ -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("")
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user