opedd 0.1.0__py3-none-any.whl
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.
- opedd/__init__.py +173 -0
- opedd/_exceptions.py +80 -0
- opedd/_http.py +143 -0
- opedd/audit.py +68 -0
- opedd/compliance.py +88 -0
- opedd/content.py +50 -0
- opedd/feed.py +133 -0
- opedd/licenses.py +94 -0
- opedd-0.1.0.dist-info/METADATA +212 -0
- opedd-0.1.0.dist-info/RECORD +12 -0
- opedd-0.1.0.dist-info/WHEEL +4 -0
- opedd-0.1.0.dist-info/licenses/LICENSE +21 -0
opedd/__init__.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Official Python SDK for the Opedd content licensing API (buyer-side).
|
|
2
|
+
|
|
3
|
+
Quick start:
|
|
4
|
+
|
|
5
|
+
from opedd import Opedd
|
|
6
|
+
|
|
7
|
+
client = Opedd(buyer_token="opedd_buyer_live_...")
|
|
8
|
+
|
|
9
|
+
article = client.content.get("article-uuid")
|
|
10
|
+
print(article["title"], article["author"], article["word_count"])
|
|
11
|
+
|
|
12
|
+
See https://docs.opedd.com/cookbook.md for end-to-end walkthroughs.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from ._exceptions import (
|
|
21
|
+
OpeddAuthError,
|
|
22
|
+
OpeddError,
|
|
23
|
+
OpeddNotFoundError,
|
|
24
|
+
OpeddRateLimitError,
|
|
25
|
+
OpeddServerError,
|
|
26
|
+
OpeddValidationError,
|
|
27
|
+
)
|
|
28
|
+
from ._http import HttpClient
|
|
29
|
+
from .audit import AuditNamespace
|
|
30
|
+
from .compliance import ComplianceNamespace
|
|
31
|
+
from .content import ContentNamespace
|
|
32
|
+
from .feed import FeedNamespace
|
|
33
|
+
from .licenses import LicensesNamespace
|
|
34
|
+
|
|
35
|
+
__version__ = "0.1.0"
|
|
36
|
+
__schema_version__ = "phase-11-m4"
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"Opedd",
|
|
40
|
+
"OpeddAuthError",
|
|
41
|
+
"OpeddError",
|
|
42
|
+
"OpeddNotFoundError",
|
|
43
|
+
"OpeddRateLimitError",
|
|
44
|
+
"OpeddServerError",
|
|
45
|
+
"OpeddValidationError",
|
|
46
|
+
"__schema_version__",
|
|
47
|
+
"__version__",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Opedd:
|
|
52
|
+
"""Opedd buyer-side API client.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
buyer_token: An ``opedd_buyer_live_<32-hex>`` or ``opedd_buyer_test_<32-hex>``
|
|
56
|
+
token issued by ``POST /enterprise-auth``. Used for ``/content-delivery``
|
|
57
|
+
and ``/enterprise-license`` calls.
|
|
58
|
+
buyer_jwt: A Supabase session JWT for the buyer portal. Used for
|
|
59
|
+
``/buyer-audit`` and ``/buyer-compliance-report`` calls.
|
|
60
|
+
access_key: An ``eak_*`` enterprise access key. Some endpoints
|
|
61
|
+
(``/enterprise-license`` GET) accept this directly via ``?access_key=``
|
|
62
|
+
query param, no token exchange required.
|
|
63
|
+
base_url: Override the API base URL (default ``https://api.opedd.com``).
|
|
64
|
+
Useful for local testing or staging environments.
|
|
65
|
+
timeout: HTTP timeout in seconds. Default 300 (long enough for NDJSON streaming).
|
|
66
|
+
|
|
67
|
+
Notes:
|
|
68
|
+
At least one of ``buyer_token``, ``buyer_jwt``, or ``access_key`` must be
|
|
69
|
+
provided. Different endpoints accept different credentials — see method
|
|
70
|
+
docstrings for which credential each method requires.
|
|
71
|
+
|
|
72
|
+
Env-var fallbacks: ``OPEDD_BUYER_TOKEN``, ``OPEDD_BUYER_JWT``,
|
|
73
|
+
``OPEDD_ACCESS_KEY``, ``OPEDD_BASE_URL``.
|
|
74
|
+
|
|
75
|
+
Example:
|
|
76
|
+
|
|
77
|
+
client = Opedd(buyer_token="opedd_buyer_live_...")
|
|
78
|
+
article = client.content.get("uuid")
|
|
79
|
+
|
|
80
|
+
for row in client.feed.stream_ndjson(limit=5000):
|
|
81
|
+
train_model.ingest(row)
|
|
82
|
+
|
|
83
|
+
dossier = client.compliance.report(from_="2026-04-01", to="2026-04-30")
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
buyer_token: str | None = None,
|
|
89
|
+
buyer_jwt: str | None = None,
|
|
90
|
+
access_key: str | None = None,
|
|
91
|
+
base_url: str | None = None,
|
|
92
|
+
timeout: float = 300.0,
|
|
93
|
+
) -> None:
|
|
94
|
+
self.buyer_token = buyer_token or os.environ.get("OPEDD_BUYER_TOKEN")
|
|
95
|
+
self.buyer_jwt = buyer_jwt or os.environ.get("OPEDD_BUYER_JWT")
|
|
96
|
+
self.access_key = access_key or os.environ.get("OPEDD_ACCESS_KEY")
|
|
97
|
+
self.base_url = (base_url or os.environ.get("OPEDD_BASE_URL") or "https://api.opedd.com").rstrip("/")
|
|
98
|
+
self.timeout = timeout
|
|
99
|
+
|
|
100
|
+
if not any([self.buyer_token, self.buyer_jwt, self.access_key]):
|
|
101
|
+
raise OpeddError(
|
|
102
|
+
"Opedd client requires at least one of: buyer_token, buyer_jwt, access_key. "
|
|
103
|
+
"See https://docs.opedd.com/python-sdk.md for the credential matrix."
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
self._http = HttpClient(self.base_url, timeout=timeout)
|
|
107
|
+
|
|
108
|
+
self.content = ContentNamespace(self)
|
|
109
|
+
self.feed = FeedNamespace(self)
|
|
110
|
+
self.audit = AuditNamespace(self)
|
|
111
|
+
self.compliance = ComplianceNamespace(self)
|
|
112
|
+
self.licenses = LicensesNamespace(self)
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
def from_access_key(
|
|
116
|
+
cls,
|
|
117
|
+
access_key: str,
|
|
118
|
+
buyer_email: str,
|
|
119
|
+
base_url: str | None = None,
|
|
120
|
+
) -> Opedd:
|
|
121
|
+
"""Exchange an eak_* access key for a bearer token, return an authenticated client.
|
|
122
|
+
|
|
123
|
+
Calls ``POST /enterprise-auth`` under the hood. Useful when you only
|
|
124
|
+
have the access key emailed to you after license purchase.
|
|
125
|
+
"""
|
|
126
|
+
import httpx
|
|
127
|
+
|
|
128
|
+
base = (base_url or os.environ.get("OPEDD_BASE_URL") or "https://api.opedd.com").rstrip("/")
|
|
129
|
+
resp = httpx.post(
|
|
130
|
+
f"{base}/enterprise-auth",
|
|
131
|
+
json={"access_key": access_key, "buyer_email": buyer_email},
|
|
132
|
+
timeout=30.0,
|
|
133
|
+
)
|
|
134
|
+
if resp.status_code != 200:
|
|
135
|
+
raise OpeddAuthError(
|
|
136
|
+
f"access_key exchange failed: {resp.status_code} {resp.text}",
|
|
137
|
+
status_code=resp.status_code,
|
|
138
|
+
)
|
|
139
|
+
body = resp.json()
|
|
140
|
+
bearer_token = body.get("data", {}).get("bearer_token") or body.get("bearer_token")
|
|
141
|
+
if not bearer_token:
|
|
142
|
+
raise OpeddAuthError(
|
|
143
|
+
f"access_key exchange returned no bearer_token: {body}",
|
|
144
|
+
)
|
|
145
|
+
return cls(buyer_token=bearer_token, access_key=access_key, base_url=base_url)
|
|
146
|
+
|
|
147
|
+
def _bearer_headers(self) -> dict[str, str]:
|
|
148
|
+
"""Build Authorization headers for endpoints that accept buyer_token."""
|
|
149
|
+
if not self.buyer_token:
|
|
150
|
+
raise OpeddAuthError("This method requires buyer_token. Pass it at construction or set OPEDD_BUYER_TOKEN.")
|
|
151
|
+
return {"Authorization": f"Bearer {self.buyer_token}"}
|
|
152
|
+
|
|
153
|
+
def _jwt_headers(self) -> dict[str, str]:
|
|
154
|
+
"""Build Authorization headers for endpoints that accept Supabase JWT."""
|
|
155
|
+
if not self.buyer_jwt:
|
|
156
|
+
raise OpeddAuthError("This method requires buyer_jwt. Pass it at construction or set OPEDD_BUYER_JWT.")
|
|
157
|
+
return {"Authorization": f"Bearer {self.buyer_jwt}"}
|
|
158
|
+
|
|
159
|
+
def _access_key_params(self) -> dict[str, str]:
|
|
160
|
+
"""Build query params for endpoints that accept ?access_key=."""
|
|
161
|
+
if not self.access_key:
|
|
162
|
+
raise OpeddAuthError("This method requires access_key. Pass it at construction or set OPEDD_ACCESS_KEY.")
|
|
163
|
+
return {"access_key": self.access_key}
|
|
164
|
+
|
|
165
|
+
def close(self) -> None:
|
|
166
|
+
"""Close the underlying HTTP client connection pool."""
|
|
167
|
+
self._http.close()
|
|
168
|
+
|
|
169
|
+
def __enter__(self) -> Opedd:
|
|
170
|
+
return self
|
|
171
|
+
|
|
172
|
+
def __exit__(self, *args: Any) -> None:
|
|
173
|
+
self.close()
|
opedd/_exceptions.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Exception types raised by the Opedd SDK.
|
|
2
|
+
|
|
3
|
+
All Opedd-specific errors inherit from ``OpeddError``. Each subclass corresponds
|
|
4
|
+
to a backend HTTP status class so callers can catch precisely:
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
client.content.get(uuid)
|
|
8
|
+
except OpeddRateLimitError as e:
|
|
9
|
+
time.sleep(e.retry_after_seconds)
|
|
10
|
+
retry()
|
|
11
|
+
except OpeddNotFoundError:
|
|
12
|
+
log.warning("article gone; skipping")
|
|
13
|
+
except OpeddError as e:
|
|
14
|
+
# Catch-all for anything else
|
|
15
|
+
log.error(e)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class OpeddError(Exception):
|
|
24
|
+
"""Base class for all Opedd SDK errors."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
message: str,
|
|
29
|
+
*,
|
|
30
|
+
status_code: int | None = None,
|
|
31
|
+
request_id: str | None = None,
|
|
32
|
+
body: Any = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
self.message = message
|
|
36
|
+
self.status_code = status_code
|
|
37
|
+
self.request_id = request_id
|
|
38
|
+
self.body = body
|
|
39
|
+
|
|
40
|
+
def __str__(self) -> str:
|
|
41
|
+
parts = [self.message]
|
|
42
|
+
if self.status_code:
|
|
43
|
+
parts.append(f"(HTTP {self.status_code})")
|
|
44
|
+
if self.request_id:
|
|
45
|
+
parts.append(f"[request_id={self.request_id}]")
|
|
46
|
+
return " ".join(parts)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class OpeddAuthError(OpeddError):
|
|
50
|
+
"""401/403 — authentication or authorization failure."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class OpeddNotFoundError(OpeddError):
|
|
54
|
+
"""404 — resource not found."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class OpeddValidationError(OpeddError):
|
|
58
|
+
"""400/422 — request body or params malformed."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class OpeddRateLimitError(OpeddError):
|
|
62
|
+
"""429 — rate limit exceeded.
|
|
63
|
+
|
|
64
|
+
Attributes:
|
|
65
|
+
retry_after_seconds: Backend-provided retry hint (None if not surfaced).
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
message: str,
|
|
71
|
+
*,
|
|
72
|
+
retry_after_seconds: int | None = None,
|
|
73
|
+
**kwargs: Any,
|
|
74
|
+
) -> None:
|
|
75
|
+
super().__init__(message, **kwargs)
|
|
76
|
+
self.retry_after_seconds = retry_after_seconds
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class OpeddServerError(OpeddError):
|
|
80
|
+
"""5xx — server error. Usually safe to retry."""
|
opedd/_http.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""HTTP client wrapper that handles auth, response unwrapping, and error mapping.
|
|
2
|
+
|
|
3
|
+
Wire-format contract (mirrors backend convention):
|
|
4
|
+
- Successful response body is flat (no nested ``data.data`` envelope on most endpoints).
|
|
5
|
+
- Error response: ``{"success": false, "error": "..."}`` or ``{"success": false, "error": {"code": "...", "message": "...", "details": {...}}}``.
|
|
6
|
+
- ``X-Opedd-Request-Id`` header carried back for forensic correlation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from ._exceptions import (
|
|
18
|
+
OpeddAuthError,
|
|
19
|
+
OpeddError,
|
|
20
|
+
OpeddNotFoundError,
|
|
21
|
+
OpeddRateLimitError,
|
|
22
|
+
OpeddServerError,
|
|
23
|
+
OpeddValidationError,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class HttpClient:
|
|
28
|
+
"""Thin wrapper around httpx.Client that normalizes auth + errors."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, base_url: str, timeout: float = 300.0) -> None:
|
|
31
|
+
self.base_url = base_url.rstrip("/")
|
|
32
|
+
self.timeout = timeout
|
|
33
|
+
self._client = httpx.Client(timeout=timeout)
|
|
34
|
+
|
|
35
|
+
def get(
|
|
36
|
+
self,
|
|
37
|
+
path: str,
|
|
38
|
+
*,
|
|
39
|
+
params: dict[str, Any] | None = None,
|
|
40
|
+
headers: dict[str, str] | None = None,
|
|
41
|
+
) -> dict[str, Any]:
|
|
42
|
+
"""GET ``base_url + path``, return parsed JSON body. Raises on non-2xx."""
|
|
43
|
+
resp = self._client.get(
|
|
44
|
+
f"{self.base_url}{path}",
|
|
45
|
+
params=params,
|
|
46
|
+
headers=headers,
|
|
47
|
+
)
|
|
48
|
+
return self._parse(resp)
|
|
49
|
+
|
|
50
|
+
def post(
|
|
51
|
+
self,
|
|
52
|
+
path: str,
|
|
53
|
+
*,
|
|
54
|
+
json_body: dict[str, Any] | None = None,
|
|
55
|
+
params: dict[str, Any] | None = None,
|
|
56
|
+
headers: dict[str, str] | None = None,
|
|
57
|
+
) -> dict[str, Any]:
|
|
58
|
+
"""POST ``base_url + path``, return parsed JSON body. Raises on non-2xx."""
|
|
59
|
+
resp = self._client.post(
|
|
60
|
+
f"{self.base_url}{path}",
|
|
61
|
+
json=json_body,
|
|
62
|
+
params=params,
|
|
63
|
+
headers=headers,
|
|
64
|
+
)
|
|
65
|
+
return self._parse(resp)
|
|
66
|
+
|
|
67
|
+
def stream_ndjson(
|
|
68
|
+
self,
|
|
69
|
+
path: str,
|
|
70
|
+
*,
|
|
71
|
+
params: dict[str, Any] | None = None,
|
|
72
|
+
headers: dict[str, str] | None = None,
|
|
73
|
+
) -> Iterator[dict[str, Any]]:
|
|
74
|
+
"""GET ``base_url + path`` and yield each NDJSON line as a parsed dict.
|
|
75
|
+
|
|
76
|
+
Yields per-article rows AND the trailing ``_meta`` row (the caller is
|
|
77
|
+
expected to recognize ``"_meta" in row`` and act accordingly).
|
|
78
|
+
"""
|
|
79
|
+
with self._client.stream(
|
|
80
|
+
"GET",
|
|
81
|
+
f"{self.base_url}{path}",
|
|
82
|
+
params=params,
|
|
83
|
+
headers=headers,
|
|
84
|
+
) as resp:
|
|
85
|
+
if resp.status_code != 200:
|
|
86
|
+
resp.read()
|
|
87
|
+
self._raise_for_status(resp)
|
|
88
|
+
for line in resp.iter_lines():
|
|
89
|
+
if not line:
|
|
90
|
+
continue
|
|
91
|
+
yield json.loads(line)
|
|
92
|
+
|
|
93
|
+
def close(self) -> None:
|
|
94
|
+
self._client.close()
|
|
95
|
+
|
|
96
|
+
def _parse(self, resp: httpx.Response) -> dict[str, Any]:
|
|
97
|
+
if resp.status_code >= 400:
|
|
98
|
+
self._raise_for_status(resp)
|
|
99
|
+
try:
|
|
100
|
+
return resp.json()
|
|
101
|
+
except json.JSONDecodeError as e:
|
|
102
|
+
raise OpeddError(
|
|
103
|
+
f"Non-JSON response: {resp.text[:500]}",
|
|
104
|
+
status_code=resp.status_code,
|
|
105
|
+
) from e
|
|
106
|
+
|
|
107
|
+
def _raise_for_status(self, resp: httpx.Response) -> None:
|
|
108
|
+
"""Map backend error response to typed exception."""
|
|
109
|
+
request_id = resp.headers.get("X-Opedd-Request-Id")
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
body: dict[str, Any] = resp.json()
|
|
113
|
+
except (json.JSONDecodeError, ValueError):
|
|
114
|
+
body = {"error": resp.text[:500] or f"HTTP {resp.status_code}"}
|
|
115
|
+
|
|
116
|
+
error_field = body.get("error")
|
|
117
|
+
if isinstance(error_field, dict):
|
|
118
|
+
message = error_field.get("message") or error_field.get("code") or f"HTTP {resp.status_code}"
|
|
119
|
+
elif isinstance(error_field, str):
|
|
120
|
+
message = error_field
|
|
121
|
+
else:
|
|
122
|
+
message = body.get("message") or f"HTTP {resp.status_code}"
|
|
123
|
+
|
|
124
|
+
kwargs = {
|
|
125
|
+
"status_code": resp.status_code,
|
|
126
|
+
"request_id": request_id,
|
|
127
|
+
"body": body,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if resp.status_code in (401, 403):
|
|
131
|
+
raise OpeddAuthError(message, **kwargs)
|
|
132
|
+
if resp.status_code == 404:
|
|
133
|
+
raise OpeddNotFoundError(message, **kwargs)
|
|
134
|
+
if resp.status_code in (400, 422):
|
|
135
|
+
raise OpeddValidationError(message, **kwargs)
|
|
136
|
+
if resp.status_code == 429:
|
|
137
|
+
retry_after: int | None = None
|
|
138
|
+
if isinstance(body.get("retry_after_seconds"), int):
|
|
139
|
+
retry_after = body["retry_after_seconds"]
|
|
140
|
+
raise OpeddRateLimitError(message, retry_after_seconds=retry_after, **kwargs)
|
|
141
|
+
if resp.status_code >= 500:
|
|
142
|
+
raise OpeddServerError(message, **kwargs)
|
|
143
|
+
raise OpeddError(message, **kwargs)
|
opedd/audit.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""client.audit — per-event audit browse with on-chain inclusion proofs.
|
|
2
|
+
|
|
3
|
+
Endpoint: ``GET /buyer-audit``
|
|
4
|
+
|
|
5
|
+
Auth: Supabase JWT (buyer_jwt).
|
|
6
|
+
|
|
7
|
+
For procurement-defense-grade dossiers, use ``client.compliance.report()`` —
|
|
8
|
+
``/buyer-audit`` is the lighter-weight per-event browse endpoint.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from . import Opedd
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AuditNamespace:
|
|
20
|
+
"""Per-event audit access for licensed buyers."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, client: Opedd) -> None:
|
|
23
|
+
self._client = client
|
|
24
|
+
|
|
25
|
+
def events(
|
|
26
|
+
self,
|
|
27
|
+
*,
|
|
28
|
+
from_: str | None = None,
|
|
29
|
+
to: str | None = None,
|
|
30
|
+
event_type: str | None = None,
|
|
31
|
+
cursor: str | None = None,
|
|
32
|
+
limit: int = 100,
|
|
33
|
+
) -> dict[str, Any]:
|
|
34
|
+
"""Browse per-event audit rows.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
from_: ISO-8601 timestamp lower bound (inclusive).
|
|
38
|
+
to: ISO-8601 timestamp upper bound (inclusive).
|
|
39
|
+
event_type: Filter to a specific event class
|
|
40
|
+
(e.g., ``"content_access"``, ``"bulk_content_access"``,
|
|
41
|
+
``"compliance_report_generated"``).
|
|
42
|
+
cursor: Opaque cursor for pagination.
|
|
43
|
+
limit: Max rows per response. Default 100, max bounded by backend.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Dict containing ``events[]`` array, each row with ``event_id``,
|
|
47
|
+
``event_type``, ``retrieved_at``, ``article`` (when applicable),
|
|
48
|
+
``license_terms``, and ``attestation`` block (with ``inclusion_proof``
|
|
49
|
+
when ``blockchain_status='confirmed'``).
|
|
50
|
+
|
|
51
|
+
Window cap: 30 days (vs 90-day cap on ``client.compliance.report()``).
|
|
52
|
+
Rate limit: 100/min per buyer.
|
|
53
|
+
"""
|
|
54
|
+
params: dict[str, Any] = {"limit": limit}
|
|
55
|
+
if from_:
|
|
56
|
+
params["from"] = from_
|
|
57
|
+
if to:
|
|
58
|
+
params["to"] = to
|
|
59
|
+
if event_type:
|
|
60
|
+
params["event_type"] = event_type
|
|
61
|
+
if cursor:
|
|
62
|
+
params["cursor"] = cursor
|
|
63
|
+
|
|
64
|
+
return self._client._http.get(
|
|
65
|
+
"/buyer-audit",
|
|
66
|
+
params=params,
|
|
67
|
+
headers=self._client._jwt_headers(),
|
|
68
|
+
)
|
opedd/compliance.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""client.compliance — procurement-defense compliance dossiers.
|
|
2
|
+
|
|
3
|
+
Endpoint: ``GET /buyer-compliance-report``
|
|
4
|
+
|
|
5
|
+
Auth: Supabase JWT (buyer_jwt).
|
|
6
|
+
|
|
7
|
+
The dossier is the buyer-side procurement-defense artifact. Every retrieval
|
|
8
|
+
within the date range, mapped to license terms, with Tempo Merkle attestation
|
|
9
|
+
hashes inline + on-chain verification URLs. Audit-defensibility under
|
|
10
|
+
EU AI Act Article 53 + CDSM Article 4 + post-Anthropic-settlement scrutiny.
|
|
11
|
+
|
|
12
|
+
See https://docs.opedd.com/cookbook-compliance-dossier.md for end-to-end usage.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import TYPE_CHECKING, Any
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from . import Opedd
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ComplianceNamespace:
|
|
24
|
+
"""Procurement-defense compliance dossier access."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, client: Opedd) -> None:
|
|
27
|
+
self._client = client
|
|
28
|
+
|
|
29
|
+
def report(
|
|
30
|
+
self,
|
|
31
|
+
*,
|
|
32
|
+
from_: str,
|
|
33
|
+
to: str,
|
|
34
|
+
cursor: str | None = None,
|
|
35
|
+
) -> dict[str, Any]:
|
|
36
|
+
"""Generate a compliance dossier covering [from_, to].
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
from_: ISO-8601 timestamp lower bound (inclusive).
|
|
40
|
+
to: ISO-8601 timestamp upper bound (inclusive).
|
|
41
|
+
cursor: Opaque cursor for pagination across windows > the natural
|
|
42
|
+
response size.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Dict with these top-level keys:
|
|
46
|
+
|
|
47
|
+
- ``dossier_metadata.buyer`` — buyer identity
|
|
48
|
+
- ``dossier_metadata.window`` — confirmed window (echo of from/to)
|
|
49
|
+
- ``dossier_metadata.summary`` — total_retrievals, unique_articles,
|
|
50
|
+
unique_publishers, event_class_breakdown
|
|
51
|
+
- ``dossier_metadata.platform`` — schema_version, contract_version
|
|
52
|
+
- ``dossier_metadata.compliance_framework_anchors`` — boolean flags
|
|
53
|
+
for EU AI Act Article 53, CDSM Article 4(3), on-chain attestation,
|
|
54
|
+
TDM reservation; plus ``framework_documentation_url``
|
|
55
|
+
- ``retrievals[]`` — array of per-row dossier entries (25+ fields each)
|
|
56
|
+
- ``_meta.audit_event_id`` — references the self-audit row written
|
|
57
|
+
before the dossier was returned (chain-of-custody)
|
|
58
|
+
- ``_meta.next_cursor`` — pagination cursor or None
|
|
59
|
+
- ``_meta.schema_version`` — pinned to ``phase-11-m4``
|
|
60
|
+
- ``_meta.request_id``
|
|
61
|
+
|
|
62
|
+
Window cap: 90 days per request. For annual audits, paginate across 4
|
|
63
|
+
quarterly windows.
|
|
64
|
+
|
|
65
|
+
Rate limit: 30/min per buyer.
|
|
66
|
+
|
|
67
|
+
Self-audit invariant: every successful call writes one ``license_events``
|
|
68
|
+
row with ``event_type='compliance_report_generated'`` BEFORE returning
|
|
69
|
+
the dossier. The trailing ``_meta.audit_event_id`` references this row.
|
|
70
|
+
|
|
71
|
+
Bulk fan-out: ``bulk_content_access`` envelopes expand into N retrieval
|
|
72
|
+
rows by iterating the envelope's ``metadata.article_ids[]``. All N rows
|
|
73
|
+
share the same ``on_chain_attestation.merkle_root``
|
|
74
|
+
(``attestation_granularity: "envelope"``).
|
|
75
|
+
"""
|
|
76
|
+
params: dict[str, Any] = {
|
|
77
|
+
"from": from_,
|
|
78
|
+
"to": to,
|
|
79
|
+
"format": "json",
|
|
80
|
+
}
|
|
81
|
+
if cursor:
|
|
82
|
+
params["cursor"] = cursor
|
|
83
|
+
|
|
84
|
+
return self._client._http.get(
|
|
85
|
+
"/buyer-compliance-report",
|
|
86
|
+
params=params,
|
|
87
|
+
headers=self._client._jwt_headers(),
|
|
88
|
+
)
|
opedd/content.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""client.content — fetch licensed article content.
|
|
2
|
+
|
|
3
|
+
Endpoint: ``GET /content-delivery``
|
|
4
|
+
|
|
5
|
+
Auth: buyer_token (sandbox returns truncated content; live returns full).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from . import Opedd
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ContentNamespace:
|
|
17
|
+
"""Article retrieval for licensed buyers."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, client: Opedd) -> None:
|
|
20
|
+
self._client = client
|
|
21
|
+
|
|
22
|
+
def get(self, article_id: str) -> dict[str, Any]:
|
|
23
|
+
"""Fetch a single article by UUID.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
article_id: Article UUID (from publisher-directory or feed listings).
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Dict with at least these keys (Phase 11 M2 RAG-extended shape):
|
|
30
|
+
``id``, ``title``, ``content``, ``url``, ``published_at``, ``author``,
|
|
31
|
+
``language``, ``word_count``, ``content_hash``, ``image_urls``,
|
|
32
|
+
``canonical_url``, ``tags``, ``publisher``, ``sandbox`` (bool).
|
|
33
|
+
|
|
34
|
+
Note:
|
|
35
|
+
``content`` is truncated to 500 chars when ``sandbox=True``.
|
|
36
|
+
For full content, use a live ``opedd_buyer_live_*`` token.
|
|
37
|
+
|
|
38
|
+
NULL on optional fields (author / language / image_urls / canonical_url /
|
|
39
|
+
tags) means "data unavailable for this article," NOT "explicitly empty."
|
|
40
|
+
Treat as data-missing; do not interpret as anti-match.
|
|
41
|
+
"""
|
|
42
|
+
body = self._client._http.get(
|
|
43
|
+
"/content-delivery",
|
|
44
|
+
params={"article_id": article_id},
|
|
45
|
+
headers=self._client._bearer_headers(),
|
|
46
|
+
)
|
|
47
|
+
# /content-delivery returns flat shape (no nested data envelope on success)
|
|
48
|
+
if "data" in body and isinstance(body["data"], dict):
|
|
49
|
+
return body["data"]
|
|
50
|
+
return body
|
opedd/feed.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""client.feed — paginated + streaming access to licensed catalogs.
|
|
2
|
+
|
|
3
|
+
Endpoint: ``GET /enterprise-license``
|
|
4
|
+
|
|
5
|
+
Auth: ``?access_key=eak_*`` query param (no token exchange required).
|
|
6
|
+
|
|
7
|
+
Two methods:
|
|
8
|
+
- ``list(...)`` — returns a single dict response (up to ``limit`` articles).
|
|
9
|
+
- ``stream_ndjson(...)`` — generator yielding articles one at a time, suitable
|
|
10
|
+
for streaming bulk-ingest pipelines without holding the full corpus in memory.
|
|
11
|
+
|
|
12
|
+
The ``?since=`` parameter filters to articles with ``published_at > since`` —
|
|
13
|
+
the buyer-side delta-feed pattern. See
|
|
14
|
+
https://docs.opedd.com/cookbook-delta-feed-polling.md for the polling cookbook.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections.abc import Iterator
|
|
20
|
+
from typing import TYPE_CHECKING, Any
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from . import Opedd
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FeedNamespace:
|
|
27
|
+
"""Catalog feeds for licensed buyers."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, client: Opedd) -> None:
|
|
30
|
+
self._client = client
|
|
31
|
+
|
|
32
|
+
def list(
|
|
33
|
+
self,
|
|
34
|
+
*,
|
|
35
|
+
since: str | None = None,
|
|
36
|
+
cursor: str | None = None,
|
|
37
|
+
limit: int = 200,
|
|
38
|
+
) -> dict[str, Any]:
|
|
39
|
+
"""Fetch one page of licensed articles in JSON format.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
since: ISO-8601 timestamp. Returns articles with ``published_at > since``.
|
|
43
|
+
Optional; omit to get the full licensed catalog.
|
|
44
|
+
cursor: Opaque cursor from the previous response's ``next_cursor``.
|
|
45
|
+
limit: Max articles per response. Default 200, max 200 in JSON mode.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Dict shape:
|
|
49
|
+
{
|
|
50
|
+
"success": True,
|
|
51
|
+
"data": {
|
|
52
|
+
"articles": [{...}, ...],
|
|
53
|
+
"next_cursor": "opaque-string" | None,
|
|
54
|
+
"count": int,
|
|
55
|
+
"truncated": bool,
|
|
56
|
+
},
|
|
57
|
+
"_meta": {"schema_version": "...", "request_id": "..."},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
Use ``stream_ndjson`` for >200-article bulk ingest.
|
|
61
|
+
"""
|
|
62
|
+
params: dict[str, Any] = {
|
|
63
|
+
**self._client._access_key_params(),
|
|
64
|
+
"format": "json",
|
|
65
|
+
"limit": limit,
|
|
66
|
+
}
|
|
67
|
+
if since:
|
|
68
|
+
params["since"] = since
|
|
69
|
+
if cursor:
|
|
70
|
+
params["cursor"] = cursor
|
|
71
|
+
|
|
72
|
+
return self._client._http.get("/enterprise-license", params=params)
|
|
73
|
+
|
|
74
|
+
def stream_ndjson(
|
|
75
|
+
self,
|
|
76
|
+
*,
|
|
77
|
+
since: str | None = None,
|
|
78
|
+
cursor: str | None = None,
|
|
79
|
+
limit: int = 5000,
|
|
80
|
+
) -> Iterator[dict[str, Any]]:
|
|
81
|
+
"""Stream licensed articles as NDJSON, one dict per article.
|
|
82
|
+
|
|
83
|
+
Auto-paginates across multiple HTTP requests until ``_meta.truncated == false``.
|
|
84
|
+
Yields per-article dicts only; the ``_meta`` trailing row is consumed internally.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
since: ISO-8601 timestamp. Returns articles with ``published_at > since``.
|
|
88
|
+
cursor: Opaque cursor from a previous response's ``next_cursor``. Usually
|
|
89
|
+
omitted on first call; the generator handles cursor propagation.
|
|
90
|
+
limit: Max articles per HTTP request. Default 5000 (NDJSON cap).
|
|
91
|
+
Total returned is unbounded across pagination.
|
|
92
|
+
|
|
93
|
+
Yields:
|
|
94
|
+
One article dict per yield, matching the
|
|
95
|
+
``GET /enterprise-license?format=ndjson`` per-line shape.
|
|
96
|
+
|
|
97
|
+
Example:
|
|
98
|
+
|
|
99
|
+
for article in client.feed.stream_ndjson(since="2026-05-01", limit=5000):
|
|
100
|
+
train_model.ingest(article["content"], metadata=article)
|
|
101
|
+
|
|
102
|
+
Audit trail: every streamed article emits one ``usage_records`` row
|
|
103
|
+
(sentinel ``stripe_usage_record_id = 'bulk-export:<request_id>:<article_id>'``;
|
|
104
|
+
analytics-only, not metered-billable). One ``license_events`` envelope row
|
|
105
|
+
per HTTP call; the trailing ``_meta.audit_event_id`` is logged for forensic
|
|
106
|
+
correlation but not yielded.
|
|
107
|
+
"""
|
|
108
|
+
current_cursor = cursor
|
|
109
|
+
|
|
110
|
+
while True:
|
|
111
|
+
params: dict[str, Any] = {
|
|
112
|
+
**self._client._access_key_params(),
|
|
113
|
+
"format": "ndjson",
|
|
114
|
+
"limit": limit,
|
|
115
|
+
}
|
|
116
|
+
if since:
|
|
117
|
+
params["since"] = since
|
|
118
|
+
if current_cursor:
|
|
119
|
+
params["cursor"] = current_cursor
|
|
120
|
+
|
|
121
|
+
meta: dict[str, Any] | None = None
|
|
122
|
+
for row in self._client._http.stream_ndjson("/enterprise-license", params=params):
|
|
123
|
+
if "_meta" in row:
|
|
124
|
+
meta = row["_meta"]
|
|
125
|
+
continue
|
|
126
|
+
yield row
|
|
127
|
+
|
|
128
|
+
if not meta or not meta.get("truncated"):
|
|
129
|
+
return
|
|
130
|
+
next_cursor = meta.get("next_cursor")
|
|
131
|
+
if not next_cursor:
|
|
132
|
+
return
|
|
133
|
+
current_cursor = next_cursor
|
opedd/licenses.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""client.licenses — purchase + list enterprise licenses.
|
|
2
|
+
|
|
3
|
+
Endpoint: ``POST /enterprise-license`` (purchase), ``GET /buyer-account`` (list).
|
|
4
|
+
|
|
5
|
+
Auth: ``POST`` is unauthenticated (returns a Stripe ``client_secret`` for payment
|
|
6
|
+
completion); ``GET`` requires Supabase JWT.
|
|
7
|
+
|
|
8
|
+
For the full purchase flow (sandbox → catalog → license → access key →
|
|
9
|
+
bearer token), see https://docs.opedd.com/cookbook-rag-bulk-export.md.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from . import Opedd
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LicensesNamespace:
|
|
21
|
+
"""Enterprise license purchase + listing."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, client: Opedd) -> None:
|
|
24
|
+
self._client = client
|
|
25
|
+
|
|
26
|
+
def purchase(
|
|
27
|
+
self,
|
|
28
|
+
*,
|
|
29
|
+
publisher_ids: list[str],
|
|
30
|
+
buyer_email: str,
|
|
31
|
+
buyer_org: str,
|
|
32
|
+
billing_type: str = "annual",
|
|
33
|
+
license_tier: str = "rag",
|
|
34
|
+
duration_months: int = 12,
|
|
35
|
+
scope: str = "custom",
|
|
36
|
+
filter_rules: dict[str, Any] | None = None,
|
|
37
|
+
buyer_webhook_url: str | None = None,
|
|
38
|
+
) -> dict[str, Any]:
|
|
39
|
+
"""Create an enterprise license and return Stripe payment intent.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
publisher_ids: Array of publisher UUIDs. Required for ``scope='custom'``.
|
|
43
|
+
Ignored for ``scope='platform_wide'`` or ``scope='filtered'``
|
|
44
|
+
(resolved server-side from ``filter_rules`` / opted-in publishers).
|
|
45
|
+
buyer_email: Email to deliver the access key (eak_*) after payment.
|
|
46
|
+
buyer_org: Buyer organization name for billing + audit ledger.
|
|
47
|
+
billing_type: ``"annual"``, ``"monthly"``, or ``"annual_plus_monthly"``.
|
|
48
|
+
license_tier: ``"rag"`` (= ``ai_retrieval``), ``"training"``
|
|
49
|
+
(= ``ai_training``), ``"inference"`` (= ``ai_retrieval``),
|
|
50
|
+
``"full_ai"`` (= ``ai_retrieval`` + ``ai_training``).
|
|
51
|
+
duration_months: Length of license. Default 12.
|
|
52
|
+
scope: ``"custom"``, ``"platform_wide"``, or ``"filtered"`` (Phase 10).
|
|
53
|
+
filter_rules: Required when ``scope='filtered'``. See Phase 10 docs
|
|
54
|
+
for ``excluded_publisher_ids`` / ``direct_license_carveouts`` /
|
|
55
|
+
``categories`` / ``max_price_per_event`` schema.
|
|
56
|
+
buyer_webhook_url: Optional. HMAC-SHA256 signed webhook for
|
|
57
|
+
``content.published`` events on covered publishers.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
Dict with ``enterprise_license_id``, ``stripe_client_secret``,
|
|
61
|
+
``checkout_url``.
|
|
62
|
+
|
|
63
|
+
After payment completion, the buyer receives an ``eak_*`` access key
|
|
64
|
+
via email; use ``Opedd.from_access_key()`` to authenticate.
|
|
65
|
+
"""
|
|
66
|
+
body: dict[str, Any] = {
|
|
67
|
+
"publisher_ids": publisher_ids,
|
|
68
|
+
"buyer_email": buyer_email,
|
|
69
|
+
"buyer_org": buyer_org,
|
|
70
|
+
"billing_type": billing_type,
|
|
71
|
+
"license_tier": license_tier,
|
|
72
|
+
"duration_months": duration_months,
|
|
73
|
+
"scope": scope,
|
|
74
|
+
}
|
|
75
|
+
if filter_rules:
|
|
76
|
+
body["filter_rules"] = filter_rules
|
|
77
|
+
if buyer_webhook_url:
|
|
78
|
+
body["buyer_webhook_url"] = buyer_webhook_url
|
|
79
|
+
|
|
80
|
+
return self._client._http.post("/enterprise-license", json_body=body)
|
|
81
|
+
|
|
82
|
+
def list(self) -> dict[str, Any]:
|
|
83
|
+
"""List licenses owned by the authenticated buyer (Supabase JWT auth).
|
|
84
|
+
|
|
85
|
+
Returns the ``enterprise_buyers`` row + masked API key list. Plaintext
|
|
86
|
+
access keys are NEVER returned post-issuance; only ``key_prefix`` (12 chars).
|
|
87
|
+
|
|
88
|
+
For full mid-lifecycle license details (filter_rules, billing, payouts),
|
|
89
|
+
consult the buyer portal at opedd.com/buyer.
|
|
90
|
+
"""
|
|
91
|
+
return self._client._http.get(
|
|
92
|
+
"/buyer-account",
|
|
93
|
+
headers=self._client._jwt_headers(),
|
|
94
|
+
)
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: opedd
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the Opedd content licensing API (buyer-side)
|
|
5
|
+
Project-URL: Homepage, https://opedd.com
|
|
6
|
+
Project-URL: Documentation, https://docs.opedd.com
|
|
7
|
+
Project-URL: Repository, https://github.com/Opedd/opedd-python
|
|
8
|
+
Project-URL: Issues, https://github.com/Opedd/opedd-python/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/Opedd/opedd-python/blob/main/CHANGELOG.md
|
|
10
|
+
Author-email: Opedd <support@opedd.com>
|
|
11
|
+
License: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: ai-licensing,ai-training,content-licensing,opedd,rag
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: httpx>=0.27.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: mypy>=1.8.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest-httpx>=0.30.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff>=0.4.0; extra == 'dev'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# opedd
|
|
34
|
+
|
|
35
|
+
[](https://pypi.org/project/opedd/)
|
|
36
|
+
[](https://pypi.org/project/opedd/)
|
|
37
|
+
[](https://github.com/Opedd/opedd-python/blob/main/LICENSE)
|
|
38
|
+
|
|
39
|
+
Official Python SDK for the [Opedd](https://opedd.com) content licensing API (buyer-side).
|
|
40
|
+
|
|
41
|
+
Opedd is programmatic licensing infrastructure between AI buyers and publishers — rights, usage tracking, and payment. "Stripe for content licensing."
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install opedd
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Requires Python 3.10+.
|
|
50
|
+
|
|
51
|
+
## Quickstart
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from opedd import Opedd
|
|
55
|
+
|
|
56
|
+
client = Opedd(buyer_token="opedd_buyer_live_...")
|
|
57
|
+
|
|
58
|
+
# Fetch a single licensed article
|
|
59
|
+
article = client.content.get("article-uuid")
|
|
60
|
+
print(article["title"], article["author"], article["word_count"])
|
|
61
|
+
|
|
62
|
+
# Stream the full licensed catalog as NDJSON
|
|
63
|
+
client = Opedd(access_key="eak_xxx", buyer_email="eng@yourlab.com")
|
|
64
|
+
for row in client.feed.stream_ndjson(limit=5000):
|
|
65
|
+
train_model.ingest(row["content"], metadata=row)
|
|
66
|
+
|
|
67
|
+
# Pull a procurement-defense compliance dossier
|
|
68
|
+
client = Opedd(buyer_jwt="eyJhbGc...")
|
|
69
|
+
dossier = client.compliance.report(from_="2026-04-01", to="2026-04-30")
|
|
70
|
+
print(f"Retrievals: {dossier['dossier_metadata']['summary']['total_retrievals']}")
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
For end-to-end walkthroughs, see the [cookbook](https://docs.opedd.com/cookbook.md).
|
|
74
|
+
|
|
75
|
+
## Credentials
|
|
76
|
+
|
|
77
|
+
The SDK supports three credential types depending on which endpoint you call:
|
|
78
|
+
|
|
79
|
+
| Endpoint | Credential | Construction |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| `client.content.get(...)` | Bearer buyer token | `Opedd(buyer_token="opedd_buyer_live_...")` |
|
|
82
|
+
| `client.feed.list(...)` / `client.feed.stream_ndjson(...)` | Access key (query param) | `Opedd(access_key="eak_...")` |
|
|
83
|
+
| `client.audit.events(...)` | Supabase JWT | `Opedd(buyer_jwt="eyJhbGc...")` |
|
|
84
|
+
| `client.compliance.report(...)` | Supabase JWT | `Opedd(buyer_jwt="eyJhbGc...")` |
|
|
85
|
+
| `client.licenses.purchase(...)` | None (returns Stripe `client_secret`) | `Opedd(buyer_token="...")` |
|
|
86
|
+
| `client.licenses.list()` | Supabase JWT | `Opedd(buyer_jwt="eyJhbGc...")` |
|
|
87
|
+
|
|
88
|
+
Multiple credentials can be supplied at once and the SDK selects the correct one per endpoint.
|
|
89
|
+
|
|
90
|
+
### Env-var fallbacks
|
|
91
|
+
|
|
92
|
+
The constructor reads from these env vars when arguments are omitted:
|
|
93
|
+
|
|
94
|
+
- `OPEDD_BUYER_TOKEN`
|
|
95
|
+
- `OPEDD_BUYER_JWT`
|
|
96
|
+
- `OPEDD_ACCESS_KEY`
|
|
97
|
+
- `OPEDD_BASE_URL` (default `https://api.opedd.com`)
|
|
98
|
+
|
|
99
|
+
### Exchanging an access key for a bearer token
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
client = Opedd.from_access_key(
|
|
103
|
+
access_key="eak_xyz...",
|
|
104
|
+
buyer_email="eng@yourlab.com",
|
|
105
|
+
)
|
|
106
|
+
# client.buyer_token is now set; you can call /content-delivery
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## API surface
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
client.content.get(article_id)
|
|
113
|
+
|
|
114
|
+
client.feed.list(since=None, cursor=None, limit=200)
|
|
115
|
+
client.feed.stream_ndjson(since=None, cursor=None, limit=5000) # generator
|
|
116
|
+
|
|
117
|
+
client.audit.events(from_=None, to=None, event_type=None, cursor=None, limit=100)
|
|
118
|
+
|
|
119
|
+
client.compliance.report(from_, to, cursor=None)
|
|
120
|
+
|
|
121
|
+
client.licenses.purchase(publisher_ids, buyer_email, buyer_org, ...)
|
|
122
|
+
client.licenses.list()
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
All methods return dicts matching the backend wire format. See [docs.opedd.com](https://docs.opedd.com) for full response shapes.
|
|
126
|
+
|
|
127
|
+
## Error handling
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
from opedd import (
|
|
131
|
+
OpeddError,
|
|
132
|
+
OpeddAuthError,
|
|
133
|
+
OpeddNotFoundError,
|
|
134
|
+
OpeddRateLimitError,
|
|
135
|
+
OpeddServerError,
|
|
136
|
+
OpeddValidationError,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
article = client.content.get(uuid)
|
|
141
|
+
except OpeddRateLimitError as e:
|
|
142
|
+
time.sleep(e.retry_after_seconds or 60)
|
|
143
|
+
retry()
|
|
144
|
+
except OpeddAuthError:
|
|
145
|
+
refresh_token()
|
|
146
|
+
except OpeddNotFoundError:
|
|
147
|
+
log.warning("article gone; skipping")
|
|
148
|
+
except OpeddError as e:
|
|
149
|
+
log.error(f"{e} request_id={e.request_id}")
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Every error carries `status_code`, `request_id`, and `body` for forensic correlation.
|
|
153
|
+
|
|
154
|
+
## Schema version pinning
|
|
155
|
+
|
|
156
|
+
The SDK ships pinned to backend schema version **`phase-11-m4`** (`opedd.__schema_version__`).
|
|
157
|
+
|
|
158
|
+
When the backend bumps `X-Opedd-Schema-Version`, the SDK ships a follow-up release within 1 sprint per the [schema-pin invariant](https://github.com/Opedd/opedd-backend/blob/main/INVARIANTS.md#python-sdk--mcp-server-pin-to-backend-x-opedd-schema-version-phase-11-m6).
|
|
159
|
+
|
|
160
|
+
Additive field bumps are absorbed transparently (dict pass-through). Subtractive or rename bumps require an SDK release; ensure your `requirements.txt` pins to a tested version.
|
|
161
|
+
|
|
162
|
+
## Tests
|
|
163
|
+
|
|
164
|
+
Two test suites:
|
|
165
|
+
|
|
166
|
+
### Unit tests (run in CI, no live API calls)
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
pip install -e ".[dev]"
|
|
170
|
+
pytest tests/test_unit.py
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
29+ unit tests covering: client construction, credential precedence, env-var fallback, auth-header building per credential type, HTTP error mapping (401/403/404/400/422/429/5xx), NDJSON streaming + cursor pagination, all 5 namespaces' request shapes.
|
|
174
|
+
|
|
175
|
+
### Integration tests (manual, before each release)
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
export OPEDD_BUYER_JWT="..." # for /buyer-audit + /buyer-compliance-report
|
|
179
|
+
export OPEDD_BUYER_TOKEN="..." # for /content-delivery
|
|
180
|
+
export OPEDD_ACCESS_KEY="..." # for /enterprise-license GET feed
|
|
181
|
+
pytest --integration
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Live tests against `api.opedd.com`. Read-only. Skipped by default (require `--integration` flag).
|
|
185
|
+
|
|
186
|
+
Per [release discipline](./RELEASE.md), integration tests must pass locally before any version tag is pushed. CI does NOT run integration tests — per institutional risk discipline, autonomous CI runs against production state can pollute `usage_records`, distort metered-publisher payouts, and fire production API calls at scale.
|
|
187
|
+
|
|
188
|
+
## Development
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
git clone https://github.com/Opedd/opedd-python.git
|
|
192
|
+
cd opedd-python
|
|
193
|
+
pip install -e ".[dev]"
|
|
194
|
+
pytest tests/test_unit.py # unit only (default)
|
|
195
|
+
pytest --integration # unit + integration (requires env vars)
|
|
196
|
+
ruff check src/ tests/ # lint
|
|
197
|
+
mypy src/ # type-check
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## Contributing
|
|
201
|
+
|
|
202
|
+
Issues and PRs welcome at [github.com/Opedd/opedd-python](https://github.com/Opedd/opedd-python). For broader questions about Opedd as a platform, email [support@opedd.com](mailto:support@opedd.com).
|
|
203
|
+
|
|
204
|
+
## License
|
|
205
|
+
|
|
206
|
+
[MIT](./LICENSE)
|
|
207
|
+
|
|
208
|
+
## See also
|
|
209
|
+
|
|
210
|
+
- [docs.opedd.com](https://docs.opedd.com) — full API reference + cookbook
|
|
211
|
+
- [opedd-mcp](https://github.com/Opedd/opedd-mcp) — Model Context Protocol server for Claude Desktop / Cursor
|
|
212
|
+
- [opedd-backend](https://github.com/Opedd/opedd-backend) — backend implementation (private)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
opedd/__init__.py,sha256=pyN7WVjuW4di_8bhJdz9snqu3jvhyLfPe1xwlrLaw4w,6333
|
|
2
|
+
opedd/_exceptions.py,sha256=nT7gY-j1wXaMnoC_JKjvfBx0RUYOfrCCsId6nFnALUM,2052
|
|
3
|
+
opedd/_http.py,sha256=COCXOYeEabd_R0BA2YRdUR8LYy0vSRkXKZDjvFqSK5Q,4838
|
|
4
|
+
opedd/audit.py,sha256=qAU__LFJ8CYIW9Tw4uVZktplIzR9gNu7lqCBROp2FFQ,2150
|
|
5
|
+
opedd/compliance.py,sha256=xk7OVvdPW51Uh_49uxQ78JywFWRWWrlKNYUV5xETM2A,3333
|
|
6
|
+
opedd/content.py,sha256=WxeVaOpq5cQKDjQY_HdHR21kEdKc8Hahz64jYZ0V4y4,1754
|
|
7
|
+
opedd/feed.py,sha256=fPs8O8FsadBu_UWRj8AyOPhAbclA_ZNb9RRJzQhVOnE,4747
|
|
8
|
+
opedd/licenses.py,sha256=rNQ7S3Cy1c8sURvNl07vaf0Ui_fPDRXzFBKTifIMSww,3794
|
|
9
|
+
opedd-0.1.0.dist-info/METADATA,sha256=tje7FPxqcEE8uwuVka56RrZfCuTxmcUgLmEK2nRjlno,7773
|
|
10
|
+
opedd-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
11
|
+
opedd-0.1.0.dist-info/licenses/LICENSE,sha256=IbYpWsfu73IMY9ZCby-TdGlVibF7BIeNyeyhQwSVT5w,1062
|
|
12
|
+
opedd-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Opedd
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|