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 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
+ [![PyPI](https://img.shields.io/pypi/v/opedd.svg)](https://pypi.org/project/opedd/)
36
+ [![Python](https://img.shields.io/pypi/pyversions/opedd.svg)](https://pypi.org/project/opedd/)
37
+ [![License](https://img.shields.io/pypi/l/opedd.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.