contextgraph-sdk 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- contextgraph_sdk-0.1.0/PKG-INFO +112 -0
- contextgraph_sdk-0.1.0/README.md +102 -0
- contextgraph_sdk-0.1.0/contextgraph_sdk/__init__.py +34 -0
- contextgraph_sdk-0.1.0/contextgraph_sdk/budget.py +20 -0
- contextgraph_sdk-0.1.0/contextgraph_sdk/http.py +162 -0
- contextgraph_sdk-0.1.0/contextgraph_sdk/provider.py +131 -0
- contextgraph_sdk-0.1.0/contextgraph_sdk/py.typed +0 -0
- contextgraph_sdk-0.1.0/contextgraph_sdk/types.py +127 -0
- contextgraph_sdk-0.1.0/contextgraph_sdk.egg-info/PKG-INFO +112 -0
- contextgraph_sdk-0.1.0/contextgraph_sdk.egg-info/SOURCES.txt +12 -0
- contextgraph_sdk-0.1.0/contextgraph_sdk.egg-info/dependency_links.txt +1 -0
- contextgraph_sdk-0.1.0/contextgraph_sdk.egg-info/top_level.txt +1 -0
- contextgraph_sdk-0.1.0/pyproject.toml +23 -0
- contextgraph_sdk-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: contextgraph-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Zero-dependency Python SDK for building conformant Context Graph Protocol providers.
|
|
5
|
+
License: MIT OR Apache-2.0
|
|
6
|
+
Project-URL: Homepage, https://github.com/macanderson/context-graph-protocol
|
|
7
|
+
Keywords: context-graph-protocol,cgp,context,provider,sdk
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# contextgraph-sdk — Python
|
|
12
|
+
|
|
13
|
+
A zero-dependency (stdlib-only) Python SDK for building **conformant** Context
|
|
14
|
+
Graph Protocol providers. Implement one small interface, hand it to the runtime,
|
|
15
|
+
and you have a provider that speaks the line-oriented JSON wire over stdio and
|
|
16
|
+
passes the same conformance suite that judges the Rust reference provider.
|
|
17
|
+
|
|
18
|
+
> Third independent implementation (after Rust and TypeScript); passes the full
|
|
19
|
+
> conformance suite. See [`sdk/README.md`](../README.md) for the whole picture.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
> **Not yet published to PyPI.** The command below does not resolve yet — see
|
|
24
|
+
> [`sdk/PUBLISHING.md`](../PUBLISHING.md) for the publish checklist and
|
|
25
|
+
> current status. Until then, install from a checkout: `pip install -e
|
|
26
|
+
> sdk/python` from the repository root.
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
pip install contextgraph-sdk
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Write a provider
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from contextgraph_sdk import run_stdio_provider, budget_tokens
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MyDocsProvider:
|
|
39
|
+
def info(self):
|
|
40
|
+
# Nothing leaves the machine -> declare the honest local-only egress scope.
|
|
41
|
+
return {
|
|
42
|
+
"name": "my-docs-provider",
|
|
43
|
+
"version": "0.1.0",
|
|
44
|
+
"data_flow": {"reads": True, "writes": False, "egress": False,
|
|
45
|
+
"egress_scopes": ["local-only"]},
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
def capabilities(self):
|
|
49
|
+
return {"query": {"kinds": ["doc"]}, "correlation": True, "verify": True}
|
|
50
|
+
|
|
51
|
+
def query(self, query):
|
|
52
|
+
content = "Install the binding, then implement the required methods."
|
|
53
|
+
return {
|
|
54
|
+
"frames": [{
|
|
55
|
+
"id": "doc:1", "kind": "doc", "title": "Getting started",
|
|
56
|
+
"content": content,
|
|
57
|
+
"content_digest": "sha256:" + ("11" * 32),
|
|
58
|
+
"score": 0.9,
|
|
59
|
+
# token_cost MUST equal ceil(utf8_len(content)/4).
|
|
60
|
+
"token_cost": budget_tokens(content),
|
|
61
|
+
"valid_from": "2026-01-01T00:00:00Z",
|
|
62
|
+
"provenance": [{"type": "file", "uri": "file:///docs/start.md",
|
|
63
|
+
"range": "L1-10", "digest": "sha256:" + ("11" * 32)}],
|
|
64
|
+
"citation_label": "start.md L1-10", "relations": [],
|
|
65
|
+
}],
|
|
66
|
+
"truncated": False,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
run_stdio_provider(MyDocsProvider())
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`verify` is optional. The runtime handles the whole lifecycle — handshake, query
|
|
74
|
+
(echoing the correlation `id`), verify, shutdown — and stays alive with a typed
|
|
75
|
+
error on a malformed line rather than crashing.
|
|
76
|
+
|
|
77
|
+
## Host it over HTTP
|
|
78
|
+
|
|
79
|
+
The same provider runs behind a single POST endpoint (the streamable-HTTP
|
|
80
|
+
transport, SPEC.md §3) via a WSGI app — runnable on the stdlib server or any WSGI
|
|
81
|
+
host (gunicorn, Flask):
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from wsgiref.simple_server import make_server
|
|
85
|
+
from contextgraph_sdk import make_wsgi_app
|
|
86
|
+
|
|
87
|
+
make_server("127.0.0.1", 8788, make_wsgi_app(MyDocsProvider())).serve_forever()
|
|
88
|
+
# Flask: app.wsgi_app = make_wsgi_app(provider)
|
|
89
|
+
# FastAPI (ASGI): reply with respond_to_body(provider, await request.body()) in your route
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`handle_envelope(provider, envelope)` is the transport-free state machine if you
|
|
93
|
+
want to wire it into a framework yourself. A runnable HTTP example lives at
|
|
94
|
+
`examples/example_docs_http.py`; confirm it green with
|
|
95
|
+
`contextgraph-inspect http http://127.0.0.1:8788` (the `malformed-input-tolerance`,
|
|
96
|
+
`embedding-fingerprint`, and `correlation` probes report *skipped* over HTTP —
|
|
97
|
+
they inspect raw framing this transport doesn't expose).
|
|
98
|
+
|
|
99
|
+
## Prove it conformant
|
|
100
|
+
|
|
101
|
+
From the repository root, with the Rust bins built:
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
cargo build --workspace --bins
|
|
105
|
+
./.github/scripts/conformance-external.sh -- python3 sdk/python/examples/example_docs.py
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
A green run is the machine-checkable claim that your provider honors the protocol.
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
MIT OR Apache-2.0, matching the Context Graph Protocol crates.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# contextgraph-sdk — Python
|
|
2
|
+
|
|
3
|
+
A zero-dependency (stdlib-only) Python SDK for building **conformant** Context
|
|
4
|
+
Graph Protocol providers. Implement one small interface, hand it to the runtime,
|
|
5
|
+
and you have a provider that speaks the line-oriented JSON wire over stdio and
|
|
6
|
+
passes the same conformance suite that judges the Rust reference provider.
|
|
7
|
+
|
|
8
|
+
> Third independent implementation (after Rust and TypeScript); passes the full
|
|
9
|
+
> conformance suite. See [`sdk/README.md`](../README.md) for the whole picture.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
> **Not yet published to PyPI.** The command below does not resolve yet — see
|
|
14
|
+
> [`sdk/PUBLISHING.md`](../PUBLISHING.md) for the publish checklist and
|
|
15
|
+
> current status. Until then, install from a checkout: `pip install -e
|
|
16
|
+
> sdk/python` from the repository root.
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
pip install contextgraph-sdk
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Write a provider
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from contextgraph_sdk import run_stdio_provider, budget_tokens
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MyDocsProvider:
|
|
29
|
+
def info(self):
|
|
30
|
+
# Nothing leaves the machine -> declare the honest local-only egress scope.
|
|
31
|
+
return {
|
|
32
|
+
"name": "my-docs-provider",
|
|
33
|
+
"version": "0.1.0",
|
|
34
|
+
"data_flow": {"reads": True, "writes": False, "egress": False,
|
|
35
|
+
"egress_scopes": ["local-only"]},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
def capabilities(self):
|
|
39
|
+
return {"query": {"kinds": ["doc"]}, "correlation": True, "verify": True}
|
|
40
|
+
|
|
41
|
+
def query(self, query):
|
|
42
|
+
content = "Install the binding, then implement the required methods."
|
|
43
|
+
return {
|
|
44
|
+
"frames": [{
|
|
45
|
+
"id": "doc:1", "kind": "doc", "title": "Getting started",
|
|
46
|
+
"content": content,
|
|
47
|
+
"content_digest": "sha256:" + ("11" * 32),
|
|
48
|
+
"score": 0.9,
|
|
49
|
+
# token_cost MUST equal ceil(utf8_len(content)/4).
|
|
50
|
+
"token_cost": budget_tokens(content),
|
|
51
|
+
"valid_from": "2026-01-01T00:00:00Z",
|
|
52
|
+
"provenance": [{"type": "file", "uri": "file:///docs/start.md",
|
|
53
|
+
"range": "L1-10", "digest": "sha256:" + ("11" * 32)}],
|
|
54
|
+
"citation_label": "start.md L1-10", "relations": [],
|
|
55
|
+
}],
|
|
56
|
+
"truncated": False,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
run_stdio_provider(MyDocsProvider())
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`verify` is optional. The runtime handles the whole lifecycle — handshake, query
|
|
64
|
+
(echoing the correlation `id`), verify, shutdown — and stays alive with a typed
|
|
65
|
+
error on a malformed line rather than crashing.
|
|
66
|
+
|
|
67
|
+
## Host it over HTTP
|
|
68
|
+
|
|
69
|
+
The same provider runs behind a single POST endpoint (the streamable-HTTP
|
|
70
|
+
transport, SPEC.md §3) via a WSGI app — runnable on the stdlib server or any WSGI
|
|
71
|
+
host (gunicorn, Flask):
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from wsgiref.simple_server import make_server
|
|
75
|
+
from contextgraph_sdk import make_wsgi_app
|
|
76
|
+
|
|
77
|
+
make_server("127.0.0.1", 8788, make_wsgi_app(MyDocsProvider())).serve_forever()
|
|
78
|
+
# Flask: app.wsgi_app = make_wsgi_app(provider)
|
|
79
|
+
# FastAPI (ASGI): reply with respond_to_body(provider, await request.body()) in your route
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`handle_envelope(provider, envelope)` is the transport-free state machine if you
|
|
83
|
+
want to wire it into a framework yourself. A runnable HTTP example lives at
|
|
84
|
+
`examples/example_docs_http.py`; confirm it green with
|
|
85
|
+
`contextgraph-inspect http http://127.0.0.1:8788` (the `malformed-input-tolerance`,
|
|
86
|
+
`embedding-fingerprint`, and `correlation` probes report *skipped* over HTTP —
|
|
87
|
+
they inspect raw framing this transport doesn't expose).
|
|
88
|
+
|
|
89
|
+
## Prove it conformant
|
|
90
|
+
|
|
91
|
+
From the repository root, with the Rust bins built:
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
cargo build --workspace --bins
|
|
95
|
+
./.github/scripts/conformance-external.sh -- python3 sdk/python/examples/example_docs.py
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
A green run is the machine-checkable claim that your provider honors the protocol.
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
|
|
102
|
+
MIT OR Apache-2.0, matching the Context Graph Protocol crates.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""``contextgraph-sdk`` — a zero-dependency Python SDK for building conformant
|
|
2
|
+
Context Graph Protocol providers.
|
|
3
|
+
|
|
4
|
+
from contextgraph_sdk import run_stdio_provider, budget_tokens
|
|
5
|
+
|
|
6
|
+
class MyProvider:
|
|
7
|
+
def info(self):
|
|
8
|
+
return {"name": "my-provider", "version": "0.1.0",
|
|
9
|
+
"data_flow": {"reads": True, "writes": False, "egress": False,
|
|
10
|
+
"egress_scopes": ["local-only"]}}
|
|
11
|
+
def capabilities(self):
|
|
12
|
+
return {"query": {"kinds": ["doc"]}, "correlation": True}
|
|
13
|
+
def query(self, query):
|
|
14
|
+
return {"frames": [], "truncated": False}
|
|
15
|
+
|
|
16
|
+
run_stdio_provider(MyProvider())
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from .budget import BYTES_PER_BUDGET_TOKEN, budget_tokens
|
|
20
|
+
from .http import handle_envelope, make_wsgi_app, respond_to_body
|
|
21
|
+
from .provider import Provider, ProviderError, run_stdio_provider
|
|
22
|
+
from .types import PROTOCOL_VERSION
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"PROTOCOL_VERSION",
|
|
26
|
+
"BYTES_PER_BUDGET_TOKEN",
|
|
27
|
+
"budget_tokens",
|
|
28
|
+
"Provider",
|
|
29
|
+
"ProviderError",
|
|
30
|
+
"run_stdio_provider",
|
|
31
|
+
"handle_envelope",
|
|
32
|
+
"respond_to_body",
|
|
33
|
+
"make_wsgi_app",
|
|
34
|
+
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Canonical token accounting (SPEC.md B3).
|
|
2
|
+
|
|
3
|
+
A budget token is an accounting unit, not a real tokenizer:
|
|
4
|
+
``budget_tokens(content) = ceil(utf8_byte_length(content) / 4)``. A conforming
|
|
5
|
+
provider's ``ContextFrame.token_cost`` MUST equal this for the frame's inline
|
|
6
|
+
content (a ``reference`` frame carries no content, so its required cost is 0).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
|
|
13
|
+
BYTES_PER_BUDGET_TOKEN = 4
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def budget_tokens(content: str | None) -> int:
|
|
17
|
+
"""The canonical budget-token cost of a piece of frame content."""
|
|
18
|
+
if not content:
|
|
19
|
+
return 0
|
|
20
|
+
return math.ceil(len(content.encode("utf-8")) / BYTES_PER_BUDGET_TOKEN)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""The HTTP adapter: host a provider behind a single POST endpoint, speaking the
|
|
2
|
+
same Context Graph Protocol wire as :func:`run_stdio_provider` -- the "streamable
|
|
3
|
+
HTTP" transport (``SPEC.md`` §3). The host POSTs one envelope as the request body
|
|
4
|
+
and expects one envelope back as the response body; :func:`handle_envelope` is
|
|
5
|
+
that request/response state machine, framework-agnostic so it drops under Flask,
|
|
6
|
+
FastAPI, or any WSGI server.
|
|
7
|
+
|
|
8
|
+
The one deliberate difference from stdio: an HTTP provider is a long-lived server
|
|
9
|
+
reached by many independent hosts, so a ``shutdown`` envelope ends *that
|
|
10
|
+
exchange* -- it never calls :func:`sys.exit`. (``contextgraph-inspect http`` in
|
|
11
|
+
fact handshakes and shuts down twice per run: once to probe, once to run the
|
|
12
|
+
conformance suite. A server that exited on the first shutdown could not answer
|
|
13
|
+
the second handshake.)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
from typing import Any, Callable, Iterable
|
|
20
|
+
|
|
21
|
+
from .provider import Provider, ProviderError
|
|
22
|
+
from .types import PROTOCOL_VERSION
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def handle_envelope(provider: Provider, envelope: dict[str, Any]) -> dict[str, Any] | None:
|
|
26
|
+
"""Drive one request ``envelope`` through ``provider`` and return the one
|
|
27
|
+
response envelope -- or ``None`` for a ``shutdown`` (and for any
|
|
28
|
+
host->provider envelope a provider must ignore), which has no reply body.
|
|
29
|
+
|
|
30
|
+
This is the whole protocol state machine, transport-free: hand it a decoded
|
|
31
|
+
envelope from whatever web framework you use and serialize what it returns.
|
|
32
|
+
It mirrors :func:`run_stdio_provider`'s per-line handling exactly --
|
|
33
|
+
including echoing a ``query``'s correlation ``id`` (H4) and catching a
|
|
34
|
+
:class:`ProviderError` into a coded ``error`` envelope (§E1) -- minus the
|
|
35
|
+
process lifecycle.
|
|
36
|
+
"""
|
|
37
|
+
kind = envelope.get("type")
|
|
38
|
+
|
|
39
|
+
if kind == "handshake":
|
|
40
|
+
return {
|
|
41
|
+
"type": "handshake_ack",
|
|
42
|
+
"protocol_version": PROTOCOL_VERSION,
|
|
43
|
+
"provider": provider.info(),
|
|
44
|
+
"capabilities": provider.capabilities(),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if kind == "query":
|
|
48
|
+
echoed = envelope.get("id")
|
|
49
|
+
try:
|
|
50
|
+
result = provider.query(envelope["query"])
|
|
51
|
+
except ProviderError as error:
|
|
52
|
+
# A deliberate, coded refusal of a request the provider can't
|
|
53
|
+
# honestly serve (§E1): an error envelope, not frames.
|
|
54
|
+
reply: dict[str, Any] = {"type": "error", "message": error.message}
|
|
55
|
+
if error.code is not None:
|
|
56
|
+
reply["code"] = error.code
|
|
57
|
+
else:
|
|
58
|
+
reply = {"type": "frames", "result": result}
|
|
59
|
+
# Echo the correlation id so the host can match reply to request (H4).
|
|
60
|
+
if echoed is not None:
|
|
61
|
+
reply["id"] = echoed
|
|
62
|
+
return reply
|
|
63
|
+
|
|
64
|
+
if kind == "verify":
|
|
65
|
+
verify = getattr(provider, "verify", None)
|
|
66
|
+
if callable(verify):
|
|
67
|
+
response = verify(envelope["request"])
|
|
68
|
+
else:
|
|
69
|
+
# No verify support: vouch for nothing; the host re-queries.
|
|
70
|
+
response = {
|
|
71
|
+
"verdicts": [
|
|
72
|
+
{"frame": frame, "status": "unknown"}
|
|
73
|
+
for frame in envelope["request"]["frames"]
|
|
74
|
+
]
|
|
75
|
+
}
|
|
76
|
+
return {"type": "verified", "response": response}
|
|
77
|
+
|
|
78
|
+
# `shutdown` ends the exchange but keeps the server alive for the next host;
|
|
79
|
+
# handshake_ack / frames / verified / error are host->provider-invalid. Both
|
|
80
|
+
# have no reply body.
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def respond_to_body(provider: Provider, raw_body: str | bytes) -> tuple[int, str]:
|
|
85
|
+
"""Decode one raw request body, drive it through :func:`handle_envelope`, and
|
|
86
|
+
return ``(status, body)`` to send back. Use this when your framework hands
|
|
87
|
+
you the raw body (a FastAPI/Flask route): respond with the status and the
|
|
88
|
+
JSON string.
|
|
89
|
+
|
|
90
|
+
A body that is not a valid CGP envelope is answered ``400`` with a coded
|
|
91
|
+
``error`` envelope rather than crashing -- the HTTP mirror of the stdio
|
|
92
|
+
``malformed-input-tolerance`` guarantee.
|
|
93
|
+
"""
|
|
94
|
+
if isinstance(raw_body, (bytes, bytearray)):
|
|
95
|
+
raw_body = raw_body.decode("utf-8", "replace")
|
|
96
|
+
try:
|
|
97
|
+
envelope = json.loads(raw_body)
|
|
98
|
+
except (json.JSONDecodeError, ValueError):
|
|
99
|
+
return 400, json.dumps(
|
|
100
|
+
{
|
|
101
|
+
"type": "error",
|
|
102
|
+
"code": "bad_request",
|
|
103
|
+
"message": "request body was not a valid CGP envelope",
|
|
104
|
+
},
|
|
105
|
+
separators=(",", ":"),
|
|
106
|
+
)
|
|
107
|
+
if not isinstance(envelope, dict):
|
|
108
|
+
return 400, json.dumps(
|
|
109
|
+
{
|
|
110
|
+
"type": "error",
|
|
111
|
+
"code": "bad_request",
|
|
112
|
+
"message": "request body was not a CGP envelope object",
|
|
113
|
+
},
|
|
114
|
+
separators=(",", ":"),
|
|
115
|
+
)
|
|
116
|
+
reply = handle_envelope(provider, envelope)
|
|
117
|
+
# `shutdown` (and ignored inputs) has no reply body: 204 No Content.
|
|
118
|
+
if reply is None:
|
|
119
|
+
return 204, ""
|
|
120
|
+
return 200, json.dumps(reply, separators=(",", ":"))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def make_wsgi_app(
|
|
124
|
+
provider: Provider,
|
|
125
|
+
) -> Callable[[dict[str, Any], Callable[..., Any]], Iterable[bytes]]:
|
|
126
|
+
"""Build a WSGI application that answers the CGP protocol on one endpoint --
|
|
127
|
+
the zero-config path, dependency-free and runnable under the stdlib's
|
|
128
|
+
``wsgiref.simple_server`` or any production WSGI server (gunicorn, uWSGI):
|
|
129
|
+
|
|
130
|
+
::
|
|
131
|
+
|
|
132
|
+
from wsgiref.simple_server import make_server
|
|
133
|
+
make_server("127.0.0.1", 8788, make_wsgi_app(provider)).serve_forever()
|
|
134
|
+
|
|
135
|
+
It also mounts under Flask (``app.wsgi_app = make_wsgi_app(provider)``) and,
|
|
136
|
+
since it reads the raw ``wsgi.input`` itself, needs no body parser. Under an
|
|
137
|
+
ASGI framework like FastAPI, call :func:`respond_to_body` with the request
|
|
138
|
+
body inside your route instead.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
def app(
|
|
142
|
+
environ: dict[str, Any],
|
|
143
|
+
start_response: Callable[..., Any],
|
|
144
|
+
) -> Iterable[bytes]:
|
|
145
|
+
try:
|
|
146
|
+
length = int(environ.get("CONTENT_LENGTH") or 0)
|
|
147
|
+
except (TypeError, ValueError):
|
|
148
|
+
length = 0
|
|
149
|
+
raw_body = environ["wsgi.input"].read(length) if length > 0 else b""
|
|
150
|
+
status_code, body = respond_to_body(provider, raw_body)
|
|
151
|
+
payload = body.encode("utf-8")
|
|
152
|
+
reason = {200: "OK", 204: "No Content", 400: "Bad Request"}.get(status_code, "OK")
|
|
153
|
+
start_response(
|
|
154
|
+
f"{status_code} {reason}",
|
|
155
|
+
[
|
|
156
|
+
("Content-Type", "application/json"),
|
|
157
|
+
("Content-Length", str(len(payload))),
|
|
158
|
+
],
|
|
159
|
+
)
|
|
160
|
+
return [payload]
|
|
161
|
+
|
|
162
|
+
return app
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""The provider runtime.
|
|
2
|
+
|
|
3
|
+
Implement :class:`Provider` (info / capabilities / query, and optionally verify),
|
|
4
|
+
hand it to :func:`run_stdio_provider`, and you have a conformant Context Graph
|
|
5
|
+
Protocol provider speaking the line-oriented JSON wire over stdio. The runtime
|
|
6
|
+
drives the full lifecycle — handshake, query (echoing the correlation ``id``),
|
|
7
|
+
verify, shutdown — and stays alive with a typed error on a malformed line rather
|
|
8
|
+
than crashing (the ``malformed-input-tolerance`` guarantee).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
from typing import Any, Protocol, runtime_checkable
|
|
16
|
+
|
|
17
|
+
from .types import PROTOCOL_VERSION
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@runtime_checkable
|
|
21
|
+
class Provider(Protocol):
|
|
22
|
+
"""A Context Graph Protocol provider.
|
|
23
|
+
|
|
24
|
+
``query`` is mandatory. ``verify`` is optional — a provider that omits it is
|
|
25
|
+
treated as unable to vouch for its frames, and the host re-queries.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def info(self) -> dict[str, Any]:
|
|
29
|
+
"""Identity and data-flow posture, reported at handshake."""
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
def capabilities(self) -> dict[str, Any]:
|
|
33
|
+
"""What this provider can do, negotiated at handshake."""
|
|
34
|
+
...
|
|
35
|
+
|
|
36
|
+
def query(self, query: dict[str, Any]) -> dict[str, Any]:
|
|
37
|
+
"""Answer a retrieval request with budgeted, provenance-carrying frames."""
|
|
38
|
+
...
|
|
39
|
+
|
|
40
|
+
# def verify(self, request: dict[str, Any]) -> dict[str, Any]: ... # optional
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ProviderError(Exception):
|
|
44
|
+
"""A protocol-level error a provider raises from ``query`` to reply with an
|
|
45
|
+
``error`` envelope carrying a machine-readable ``code`` instead of frames.
|
|
46
|
+
|
|
47
|
+
This is how a provider refuses a request it cannot honestly serve — e.g.
|
|
48
|
+
rejecting a query embedding whose length contradicts its declared
|
|
49
|
+
``embeddings_fingerprint`` dimension with ``bad_request`` (``SPEC.md`` §E1).
|
|
50
|
+
The runtime catches it, echoes the request's correlation ``id``, and writes
|
|
51
|
+
``{"type": "error", "code": code, "message": message}``.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, message: str, code: str | None = None) -> None:
|
|
55
|
+
super().__init__(message)
|
|
56
|
+
self.message = message
|
|
57
|
+
self.code = code
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _write(envelope: dict[str, Any]) -> None:
|
|
61
|
+
sys.stdout.write(json.dumps(envelope, separators=(",", ":")) + "\n")
|
|
62
|
+
sys.stdout.flush()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def run_stdio_provider(provider: Provider) -> None:
|
|
66
|
+
"""Run ``provider`` as a stdio child process — the shape the reference host
|
|
67
|
+
and the conformance suite drive. One envelope per line in and out, until a
|
|
68
|
+
``shutdown`` (or EOF)."""
|
|
69
|
+
while True:
|
|
70
|
+
line = sys.stdin.readline()
|
|
71
|
+
if not line: # EOF / broken pipe: the host is gone.
|
|
72
|
+
break
|
|
73
|
+
stripped = line.strip()
|
|
74
|
+
if not stripped:
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
envelope = json.loads(stripped)
|
|
79
|
+
except json.JSONDecodeError:
|
|
80
|
+
# Malformed line: stay alive and say so with a code, don't crash.
|
|
81
|
+
_write(
|
|
82
|
+
{
|
|
83
|
+
"type": "error",
|
|
84
|
+
"code": "bad_request",
|
|
85
|
+
"message": "line was not a valid CGP envelope",
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
kind = envelope.get("type")
|
|
91
|
+
if kind == "handshake":
|
|
92
|
+
_write(
|
|
93
|
+
{
|
|
94
|
+
"type": "handshake_ack",
|
|
95
|
+
"protocol_version": PROTOCOL_VERSION,
|
|
96
|
+
"provider": provider.info(),
|
|
97
|
+
"capabilities": provider.capabilities(),
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
elif kind == "query":
|
|
101
|
+
# Echo the correlation id so the host can match reply to request (H4).
|
|
102
|
+
echoed = envelope.get("id")
|
|
103
|
+
try:
|
|
104
|
+
result = provider.query(envelope["query"])
|
|
105
|
+
except ProviderError as error:
|
|
106
|
+
# The provider refused a request it can't honestly serve (§E1):
|
|
107
|
+
# reply with a coded error envelope, not frames.
|
|
108
|
+
reply: dict[str, Any] = {"type": "error", "message": error.message}
|
|
109
|
+
if error.code is not None:
|
|
110
|
+
reply["code"] = error.code
|
|
111
|
+
else:
|
|
112
|
+
reply = {"type": "frames", "result": result}
|
|
113
|
+
if echoed is not None:
|
|
114
|
+
reply["id"] = echoed
|
|
115
|
+
_write(reply)
|
|
116
|
+
elif kind == "verify":
|
|
117
|
+
verify = getattr(provider, "verify", None)
|
|
118
|
+
if callable(verify):
|
|
119
|
+
response = verify(envelope["request"])
|
|
120
|
+
else:
|
|
121
|
+
# No verify support: vouch for nothing; the host re-queries.
|
|
122
|
+
response = {
|
|
123
|
+
"verdicts": [
|
|
124
|
+
{"frame": frame, "status": "unknown"}
|
|
125
|
+
for frame in envelope["request"]["frames"]
|
|
126
|
+
]
|
|
127
|
+
}
|
|
128
|
+
_write({"type": "verified", "response": response})
|
|
129
|
+
elif kind == "shutdown":
|
|
130
|
+
sys.exit(0)
|
|
131
|
+
# handshake_ack / frames / verified / error are host->provider-invalid; ignore.
|
|
File without changes
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Context Graph Protocol wire types, mirrored from the JSON Schema.
|
|
2
|
+
|
|
3
|
+
These are ``TypedDict``s: at runtime a frame or envelope is a plain ``dict`` (the
|
|
4
|
+
wire is JSON), and these give editors and type-checkers the exact shape. Keys
|
|
5
|
+
typed ``Optional``/absent are simply omitted when you have no value — the SDK
|
|
6
|
+
never emits explicit ``null`` for them.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Literal, TypedDict
|
|
12
|
+
|
|
13
|
+
PROTOCOL_VERSION = "contextgraph/1.0-draft"
|
|
14
|
+
|
|
15
|
+
FrameKind = Literal["snippet", "symbol", "fact", "doc", "memory", "episode", "graph"]
|
|
16
|
+
Representation = Literal["full", "compact", "reference"]
|
|
17
|
+
ContentFidelity = Literal["exact", "normalized", "summarized", "omitted"]
|
|
18
|
+
VerdictStatus = Literal["valid", "stale", "gone", "unknown"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Provenance(TypedDict, total=False):
|
|
22
|
+
type: str # required; the wire name for "kind"
|
|
23
|
+
uri: str
|
|
24
|
+
range: str
|
|
25
|
+
digest: str
|
|
26
|
+
method: str
|
|
27
|
+
by: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Relation(TypedDict, total=False):
|
|
31
|
+
rel: str # required
|
|
32
|
+
target_uri: str # required
|
|
33
|
+
display_name: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ContentRef(TypedDict, total=False):
|
|
37
|
+
provider_id: str # required
|
|
38
|
+
uri: str # required
|
|
39
|
+
expires_at: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ContextFrame(TypedDict, total=False):
|
|
43
|
+
id: str # required
|
|
44
|
+
kind: FrameKind # required
|
|
45
|
+
title: str # required
|
|
46
|
+
content: str # absent for reference frames
|
|
47
|
+
content_digest: str
|
|
48
|
+
uri: str
|
|
49
|
+
representation: Representation
|
|
50
|
+
content_fidelity: ContentFidelity
|
|
51
|
+
canonical_content_hash: str
|
|
52
|
+
content_ref: ContentRef
|
|
53
|
+
transform: dict[str, str]
|
|
54
|
+
minimum_content_fidelity: ContentFidelity
|
|
55
|
+
inline_content_requirement: Literal["required", "resolvable_reference_allowed"]
|
|
56
|
+
score: float # required
|
|
57
|
+
token_cost: int # required; ceil(utf8_len(content)/4)
|
|
58
|
+
canonical_token_cost: int
|
|
59
|
+
tokenizer_ref: str
|
|
60
|
+
valid_from: str
|
|
61
|
+
valid_to: str
|
|
62
|
+
recorded_at: str
|
|
63
|
+
provenance: list[Provenance]
|
|
64
|
+
citation_label: str
|
|
65
|
+
embedding: dict[str, Any]
|
|
66
|
+
relations: list[Relation]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ContextQuery(TypedDict, total=False):
|
|
70
|
+
goal: str # required
|
|
71
|
+
query_text: str
|
|
72
|
+
embedding: list[float]
|
|
73
|
+
kinds: list[FrameKind]
|
|
74
|
+
anchors: list[str]
|
|
75
|
+
max_frames: int # required
|
|
76
|
+
max_tokens: int # required
|
|
77
|
+
as_of: str
|
|
78
|
+
representation_preferences: list[Representation]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ContextQueryResult(TypedDict, total=False):
|
|
82
|
+
frames: list[ContextFrame] # required
|
|
83
|
+
truncated: bool # required
|
|
84
|
+
dropped_estimate: int
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class DataFlow(TypedDict, total=False):
|
|
88
|
+
reads: bool # required
|
|
89
|
+
writes: bool # required
|
|
90
|
+
egress: bool # required
|
|
91
|
+
egress_scopes: list[str]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class ProviderInfo(TypedDict, total=False):
|
|
95
|
+
name: str # required
|
|
96
|
+
version: str # required
|
|
97
|
+
data_flow: DataFlow # required
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class Capabilities(TypedDict, total=False):
|
|
101
|
+
query: dict[str, Any] # required, e.g. {"kinds": ["doc"]}
|
|
102
|
+
correlation: bool
|
|
103
|
+
graph: bool
|
|
104
|
+
embeddings_fingerprint: str | None
|
|
105
|
+
verify: bool
|
|
106
|
+
representations: list[Representation]
|
|
107
|
+
resolve: bool
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class FrameId(TypedDict, total=False):
|
|
111
|
+
provider_id: str # required
|
|
112
|
+
frame_id: str # required
|
|
113
|
+
content_digest: str
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class FrameVerdict(TypedDict, total=False):
|
|
117
|
+
frame: FrameId # required
|
|
118
|
+
status: VerdictStatus # required
|
|
119
|
+
replacement_digest: str
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class VerifyRequest(TypedDict, total=False):
|
|
123
|
+
frames: list[FrameId] # required
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class VerifyResponse(TypedDict, total=False):
|
|
127
|
+
verdicts: list[FrameVerdict] # required
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: contextgraph-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Zero-dependency Python SDK for building conformant Context Graph Protocol providers.
|
|
5
|
+
License: MIT OR Apache-2.0
|
|
6
|
+
Project-URL: Homepage, https://github.com/macanderson/context-graph-protocol
|
|
7
|
+
Keywords: context-graph-protocol,cgp,context,provider,sdk
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# contextgraph-sdk — Python
|
|
12
|
+
|
|
13
|
+
A zero-dependency (stdlib-only) Python SDK for building **conformant** Context
|
|
14
|
+
Graph Protocol providers. Implement one small interface, hand it to the runtime,
|
|
15
|
+
and you have a provider that speaks the line-oriented JSON wire over stdio and
|
|
16
|
+
passes the same conformance suite that judges the Rust reference provider.
|
|
17
|
+
|
|
18
|
+
> Third independent implementation (after Rust and TypeScript); passes the full
|
|
19
|
+
> conformance suite. See [`sdk/README.md`](../README.md) for the whole picture.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
> **Not yet published to PyPI.** The command below does not resolve yet — see
|
|
24
|
+
> [`sdk/PUBLISHING.md`](../PUBLISHING.md) for the publish checklist and
|
|
25
|
+
> current status. Until then, install from a checkout: `pip install -e
|
|
26
|
+
> sdk/python` from the repository root.
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
pip install contextgraph-sdk
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Write a provider
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from contextgraph_sdk import run_stdio_provider, budget_tokens
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MyDocsProvider:
|
|
39
|
+
def info(self):
|
|
40
|
+
# Nothing leaves the machine -> declare the honest local-only egress scope.
|
|
41
|
+
return {
|
|
42
|
+
"name": "my-docs-provider",
|
|
43
|
+
"version": "0.1.0",
|
|
44
|
+
"data_flow": {"reads": True, "writes": False, "egress": False,
|
|
45
|
+
"egress_scopes": ["local-only"]},
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
def capabilities(self):
|
|
49
|
+
return {"query": {"kinds": ["doc"]}, "correlation": True, "verify": True}
|
|
50
|
+
|
|
51
|
+
def query(self, query):
|
|
52
|
+
content = "Install the binding, then implement the required methods."
|
|
53
|
+
return {
|
|
54
|
+
"frames": [{
|
|
55
|
+
"id": "doc:1", "kind": "doc", "title": "Getting started",
|
|
56
|
+
"content": content,
|
|
57
|
+
"content_digest": "sha256:" + ("11" * 32),
|
|
58
|
+
"score": 0.9,
|
|
59
|
+
# token_cost MUST equal ceil(utf8_len(content)/4).
|
|
60
|
+
"token_cost": budget_tokens(content),
|
|
61
|
+
"valid_from": "2026-01-01T00:00:00Z",
|
|
62
|
+
"provenance": [{"type": "file", "uri": "file:///docs/start.md",
|
|
63
|
+
"range": "L1-10", "digest": "sha256:" + ("11" * 32)}],
|
|
64
|
+
"citation_label": "start.md L1-10", "relations": [],
|
|
65
|
+
}],
|
|
66
|
+
"truncated": False,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
run_stdio_provider(MyDocsProvider())
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`verify` is optional. The runtime handles the whole lifecycle — handshake, query
|
|
74
|
+
(echoing the correlation `id`), verify, shutdown — and stays alive with a typed
|
|
75
|
+
error on a malformed line rather than crashing.
|
|
76
|
+
|
|
77
|
+
## Host it over HTTP
|
|
78
|
+
|
|
79
|
+
The same provider runs behind a single POST endpoint (the streamable-HTTP
|
|
80
|
+
transport, SPEC.md §3) via a WSGI app — runnable on the stdlib server or any WSGI
|
|
81
|
+
host (gunicorn, Flask):
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from wsgiref.simple_server import make_server
|
|
85
|
+
from contextgraph_sdk import make_wsgi_app
|
|
86
|
+
|
|
87
|
+
make_server("127.0.0.1", 8788, make_wsgi_app(MyDocsProvider())).serve_forever()
|
|
88
|
+
# Flask: app.wsgi_app = make_wsgi_app(provider)
|
|
89
|
+
# FastAPI (ASGI): reply with respond_to_body(provider, await request.body()) in your route
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`handle_envelope(provider, envelope)` is the transport-free state machine if you
|
|
93
|
+
want to wire it into a framework yourself. A runnable HTTP example lives at
|
|
94
|
+
`examples/example_docs_http.py`; confirm it green with
|
|
95
|
+
`contextgraph-inspect http http://127.0.0.1:8788` (the `malformed-input-tolerance`,
|
|
96
|
+
`embedding-fingerprint`, and `correlation` probes report *skipped* over HTTP —
|
|
97
|
+
they inspect raw framing this transport doesn't expose).
|
|
98
|
+
|
|
99
|
+
## Prove it conformant
|
|
100
|
+
|
|
101
|
+
From the repository root, with the Rust bins built:
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
cargo build --workspace --bins
|
|
105
|
+
./.github/scripts/conformance-external.sh -- python3 sdk/python/examples/example_docs.py
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
A green run is the machine-checkable claim that your provider honors the protocol.
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
MIT OR Apache-2.0, matching the Context Graph Protocol crates.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
contextgraph_sdk/__init__.py
|
|
4
|
+
contextgraph_sdk/budget.py
|
|
5
|
+
contextgraph_sdk/http.py
|
|
6
|
+
contextgraph_sdk/provider.py
|
|
7
|
+
contextgraph_sdk/py.typed
|
|
8
|
+
contextgraph_sdk/types.py
|
|
9
|
+
contextgraph_sdk.egg-info/PKG-INFO
|
|
10
|
+
contextgraph_sdk.egg-info/SOURCES.txt
|
|
11
|
+
contextgraph_sdk.egg-info/dependency_links.txt
|
|
12
|
+
contextgraph_sdk.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
contextgraph_sdk
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "contextgraph-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Zero-dependency Python SDK for building conformant Context Graph Protocol providers."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT OR Apache-2.0" }
|
|
12
|
+
keywords = ["context-graph-protocol", "cgp", "context", "provider", "sdk"]
|
|
13
|
+
dependencies = []
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Homepage = "https://github.com/macanderson/context-graph-protocol"
|
|
17
|
+
|
|
18
|
+
[tool.setuptools.packages.find]
|
|
19
|
+
where = ["."]
|
|
20
|
+
include = ["contextgraph_sdk*"]
|
|
21
|
+
|
|
22
|
+
[tool.setuptools.package-data]
|
|
23
|
+
contextgraph_sdk = ["py.typed"]
|