Skip to content

Commit 041678e

Browse files
committed
feat: implement Python SDK
1 parent 93f66fe commit 041678e

85 files changed

Lines changed: 8177 additions & 21 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎README.md‎

Lines changed: 167 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,46 +3,193 @@
33
[![CI](https://github.com/offering-protocol/odp-python/actions/workflows/ci.yml/badge.svg)](https://github.com/offering-protocol/odp-python/actions/workflows/ci.yml)
44
[![Python](https://img.shields.io/badge/Python-3.11%2B-3776AB?logo=python&logoColor=white)](https://www.python.org/)
55
[![PyPI](https://img.shields.io/pypi/v/offering-protocol)](https://pypi.org/project/offering-protocol/)
6+
[![Codecov](https://codecov.io/gh/offering-protocol/odp-python/graph/badge.svg)](https://codecov.io/gh/offering-protocol/odp-python)
67
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
78

89
Official Python software development kit for the
910
[Offering Discovery Protocol](https://www.offeringprotocol.org/), the open protocol for discovering
1011
Services and navigating their Offerings.
1112

12-
ODP separates Service discovery from catalog discovery. An Agent searches the canonical Directory
13-
for candidate Services, inspects each Service's live ODP document, and then navigates or searches
14-
that Service's Collections and Offerings.
13+
ODP separates two levels of discovery:
14+
15+
1. An Agent searches the canonical Directory for Services.
16+
2. The Agent inspects a Service's live ODP document and navigates that Service's Collections and
17+
Offerings.
18+
19+
The Directory does not copy every Service catalog. Catalog searches go directly to each Service.
1520

1621
## Installation
1722

1823
```sh
1924
python -m pip install offering-protocol
2025
```
2126

22-
The distribution provides one typed Python package with modules for each integration role:
27+
Python 3.11 or newer is required. The distribution provides one typed package with modules for each
28+
integration role:
29+
30+
| Goal | Module |
31+
| --- | --- |
32+
| Parse protocol models and validate normative documents | `offering_protocol.core` |
33+
| Search the canonical production or sandbox Directory | `offering_protocol.directory` |
34+
| Inspect Services and navigate their catalogs | `offering_protocol.agent` |
35+
| Publish an ODP Service | `offering_protocol.service` |
36+
37+
## Search the Directory
38+
39+
`DirectoryClient` uses the one canonical production Directory. Select `Environment.SANDBOX` when
40+
working against InFlow's sandbox.
41+
42+
```python
43+
import asyncio
44+
45+
from offering_protocol.directory import DirectoryClient, SearchRequest, ServiceFilters
46+
47+
48+
async def main() -> None:
49+
async with DirectoryClient() as directory:
50+
page = await directory.search(
51+
SearchRequest(
52+
query="indoor plants",
53+
filters=ServiceFilters(keywords=["plants"]),
54+
limit=20,
55+
)
56+
)
57+
for service in page.items:
58+
print(service.name, service.service_origin)
59+
60+
if page.next:
61+
next_page = await directory.continue_search(page.next)
62+
print(f"Next page contains {len(next_page.items)} Services")
63+
64+
65+
asyncio.run(main())
66+
```
67+
68+
Use `search_services()` when the application wants bounded automatic pagination. Use `suggest()` to
69+
discover keyword completions supported by the Directory.
70+
71+
## Inspect and navigate a Service
72+
73+
`ServiceClient` checks the Service document before calling an operation. Calling an operation the
74+
Service does not advertise raises `UnsupportedOperationError` before a catalog request is sent.
75+
76+
```python
77+
import asyncio
78+
79+
from offering_protocol.agent import ServiceClient
80+
from offering_protocol.core import OfferingSearchRequest, Representation
81+
82+
83+
async def main() -> None:
84+
async with ServiceClient("https://demo.inflowpay.ai") as service:
85+
inspection = await service.inspect()
86+
print(inspection.document.name)
87+
print([operation.name.value for operation in inspection.document.operations])
88+
89+
page = await service.search_offerings(
90+
OfferingSearchRequest(query="plant"),
91+
Representation.TERSE,
92+
)
93+
for offering in page.items:
94+
print(offering.id, offering.name, offering.price)
95+
96+
details = await service.get_offering_details(page.items[0].id)
97+
for action in details.actions:
98+
print(action.id, action.rel.value, action.authentication.value)
99+
100+
101+
asyncio.run(main())
102+
```
103+
104+
`get_offering_details()` resolves and validates an Offering's Attribute Schema, normalizes usable
105+
Actions, and reports non-fatal issues separately from the Offering. `resolve_action()` resolves a
106+
specific Action's HTTP or OpenAPI target and request schema. It never calls the target, enrolls,
107+
authenticates, or pays.
108+
109+
The Agent module also provides:
110+
111+
- Collection list, get, search, and bounded traversal operations.
112+
- Offering list, get, search, collection listing, continuation, and bounded traversal operations.
113+
- Effective inline and linked Filter and Sort definitions for Service and Collection scopes.
114+
- Directory-to-Service federated Offering discovery through `Agent`.
115+
- Conditional request and representation caching with injectable `Cache` and `Transport` protocols.
116+
117+
Default fallback cache lifetimes are four hours for Service documents, one hour for Collections,
118+
and five minutes for Offerings. HTTP cache directives take precedence. Provide distinct `transport`
119+
and `supporting_transport` instances when protocol resources and linked schemas require different
120+
credentials or network policy.
121+
122+
## Publish a Service
123+
124+
`Service` is framework-neutral. Adapt the incoming framework request to `Request`, call
125+
`Service.handle()`, and copy the returned status, headers, and body into the framework response.
126+
127+
```python
128+
from offering_protocol.core import Collection, Offering
129+
from offering_protocol.service import ServiceBuilder, StaticCatalog, StaticCatalogOptions
130+
131+
catalog = StaticCatalog(
132+
StaticCatalogOptions(
133+
collections=(Collection(id="plants", name="Plants", odp_version="1.0"),),
134+
offerings=(
135+
Offering(
136+
collection_ids=["plants"],
137+
description="A resilient indoor plant.",
138+
id="rubber-plant",
139+
name="Rubber Plant",
140+
odp_version="1.0",
141+
),
142+
),
143+
)
144+
)
145+
146+
service = (
147+
ServiceBuilder(
148+
name="Indica Flowers",
149+
description="An AI-enabled store for houseplants and plant care.",
150+
language="en",
151+
endpoint_base="/odp",
152+
)
153+
.keywords(["houseplants", "indoor-plants"])
154+
.website_url("https://example.com")
155+
.build(catalog)
156+
)
157+
```
158+
159+
Every Service integration must implement `list-offerings` and `get-offering`. `StaticCatalog` is the
160+
small-Service implementation: it adds Collection operations when Collections are provided and uses
161+
stateless, expiring continuations. Larger Services can implement the typed `Catalog` protocol over
162+
their existing indexed catalog and search infrastructure.
23163

24-
| Goal | Module |
25-
| --------------------------------------------- | ----------------------------- |
26-
| Work with protocol models and validation | `offering_protocol.core` |
27-
| Search the canonical Directory | `offering_protocol.directory` |
28-
| Discover Services and navigate their catalogs | `offering_protocol.agent` |
29-
| Publish an ODP Service | `offering_protocol.service` |
164+
Service responses are validated against the bundled normative schemas before they are returned.
165+
The handler enforces fixed operation paths and methods, ODP media types, request and response byte
166+
limits, local identifiers, page limits, and protocol Problem Details.
30167

31-
The dependency direction remains narrow: Core is transport-independent, Directory depends toward
32-
Core, Agent composes Core and Directory, and Service depends toward Core without depending on Agent
33-
behavior.
168+
See [examples/README.md](./examples/README.md) for a runnable Service and Agent.
34169

35170
## Protocol composition
36171

37-
ODP discovers what a Service offers and how an Agent can act on an Offering. A Service Document and
172+
ODP discovers what a Service offers and how an Agent can act on an Offering. A Service document and
38173
its Actions can advertise AEP enrollment and MPP or x402 payment requirements, but ODP does not
39174
create credentials, invoke Actions, or submit payments. Applications compose the appropriate
40-
enrollment and payment clients around an Action resolved through ODP.
175+
enrollment and payment client around an Action resolved through ODP.
176+
177+
## Errors and validation
178+
179+
Each role exposes typed errors:
180+
181+
- `OdpValidationError` includes deterministic schema and semantic issues.
182+
- `DirectoryError` and `DirectoryRequestError` describe canonical Directory failures.
183+
- `AgentError`, `ServiceRequestError`, and `UnsupportedOperationError` describe Agent-side failures.
184+
- `ServiceError`, `CatalogError`, and `RequestError` describe Service integration failures.
185+
186+
Protocol models preserve additive members in `model.additional` and round-trip them through
187+
`model.to_dict()`. Parsing remains strict for normative constraints and fields that prohibit unknown
188+
members.
41189

42190
## Development
43191

44-
Python 3.11 or newer and [uv](https://docs.astral.sh/uv/) are required. Install the locked development
45-
environment and run the complete merge gate with:
192+
Python 3.11 or newer and [uv](https://docs.astral.sh/uv/) are required.
46193

47194
```sh
48195
make sync
@@ -55,8 +202,9 @@ Format source files with:
55202
make format
56203
```
57204

58-
The merge gate checks formatting, linting, strict type checking, branch coverage, distribution
59-
metadata, and installation of the built wheel into a clean virtual environment.
205+
The merge gate checks formatting, linting, strict type checking, 100 percent line and branch
206+
coverage, distribution metadata, bundled runtime schemas, and installation of the built wheel into
207+
a clean virtual environment.
60208

61209
See [`odp-specs`](https://github.com/offering-protocol/odp-specs) for the normative draft, schemas,
62210
examples, and test vectors.

‎examples/README.md‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Runnable examples
2+
3+
The examples use the installed `offering-protocol` package and require no web framework.
4+
5+
From the repository root, start the sample Service:
6+
7+
```sh
8+
uv run python examples/service.py
9+
```
10+
11+
In another terminal, inspect it and list its Offerings:
12+
13+
```sh
14+
uv run python examples/agent.py
15+
```
16+
17+
The Agent example defaults to `http://127.0.0.1:4103`. Pass another Service origin as its first
18+
argument to inspect any ODP Service:
19+
20+
```sh
21+
uv run python examples/agent.py https://demo.inflowpay.ai
22+
```
23+
24+
`service.py` demonstrates the minimum Service integration: a framework adapter, `StaticCatalog`,
25+
`list-offerings`, and `get-offering`. It also includes a Collection so the Collection operations can
26+
be exercised. It is intentionally an in-memory example; production Services can implement the same
27+
typed `Catalog` protocol over their own data source.
28+
29+
`agent.py` prints the Service document, lists Collections and Offerings only when those operations
30+
are advertised, and fetches full details for the first Offering. It does not invoke an Action.

‎examples/agent.py‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
import sys
5+
6+
from offering_protocol.agent import ServiceClient
7+
from offering_protocol.core import Operation
8+
9+
10+
async def main() -> None:
11+
origin = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:4103"
12+
async with ServiceClient(origin) as service:
13+
inspection = await service.inspect()
14+
operations = {item.name for item in inspection.document.operations}
15+
16+
print("Service")
17+
print(f" Name: {inspection.document.name}")
18+
print(f" Description: {inspection.document.description}")
19+
print(f" Origin: {inspection.service_origin}")
20+
print(f" Operations: {', '.join(sorted(item.value for item in operations))}")
21+
22+
if Operation.LIST_COLLECTIONS in operations:
23+
collections = await service.list_collections()
24+
print("\nCollections")
25+
for collection in collections.items:
26+
print(f" {collection.id}: {collection.name}")
27+
28+
offerings = await service.list_offerings()
29+
print("\nOfferings")
30+
for offering in offerings.items:
31+
print(f" {offering.id}: {offering.name}")
32+
33+
if offerings.items:
34+
details = await service.get_offering_details(offerings.items[0].id)
35+
print("\nFirst Offering")
36+
print(f" Name: {details.offering.name}")
37+
print(f" Description: {details.offering.description}")
38+
print(f" Actions: {', '.join(action.id for action in details.actions) or 'None'}")
39+
for issue in details.issues:
40+
print(f" Issue: {issue.message}")
41+
42+
43+
asyncio.run(main())

‎examples/service.py‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
5+
from urllib.parse import urlsplit
6+
7+
from offering_protocol.core import Collection, Offering
8+
from offering_protocol.service import Request, ServiceBuilder, StaticCatalog, StaticCatalogOptions
9+
10+
catalog = StaticCatalog(
11+
StaticCatalogOptions(
12+
collections=(Collection(id="plants", name="Plants", odp_version="1.0"),),
13+
offerings=(
14+
Offering(
15+
collection_ids=["plants"],
16+
description="A resilient indoor plant with broad, glossy leaves.",
17+
id="rubber-plant",
18+
name="Rubber Plant",
19+
odp_version="1.0",
20+
),
21+
Offering(
22+
collection_ids=["plants"],
23+
description="A low-maintenance plant with upright patterned leaves.",
24+
id="snake-plant",
25+
name="Snake Plant",
26+
odp_version="1.0",
27+
),
28+
),
29+
)
30+
)
31+
32+
service = (
33+
ServiceBuilder(
34+
"Example Plant Store",
35+
"An ODP-enabled store for indoor plants.",
36+
"en",
37+
"/odp",
38+
)
39+
.keywords(["houseplants", "indoor-plants"])
40+
.build(catalog)
41+
)
42+
43+
44+
class Handler(BaseHTTPRequestHandler):
45+
def do_GET(self) -> None:
46+
self._handle()
47+
48+
def do_POST(self) -> None:
49+
self._handle()
50+
51+
def _handle(self) -> None:
52+
parsed = urlsplit(self.path)
53+
length = int(self.headers.get("content-length", "0"))
54+
response = asyncio.run(
55+
service.handle(
56+
Request(
57+
body=self.rfile.read(length),
58+
headers={name: value for name, value in self.headers.items()},
59+
method=self.command,
60+
path=parsed.path,
61+
query=parsed.query,
62+
)
63+
)
64+
)
65+
self.send_response(response.status)
66+
for name, value in response.headers.items():
67+
self.send_header(name, value)
68+
self.send_header("content-length", str(len(response.body)))
69+
self.end_headers()
70+
self.wfile.write(response.body)
71+
72+
def log_message(self, format: str, *args: object) -> None:
73+
print(f"{self.command} {self.path} - {format % args}")
74+
75+
76+
if __name__ == "__main__":
77+
server = ThreadingHTTPServer(("127.0.0.1", 4103), Handler)
78+
print("ODP Service listening at http://127.0.0.1:4103")
79+
try:
80+
server.serve_forever()
81+
except KeyboardInterrupt:
82+
pass
83+
finally:
84+
server.server_close()

0 commit comments

Comments
 (0)