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.
@@ -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
+ 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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+