durable-sync 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.
- durable_sync/__init__.py +26 -0
- durable_sync/activities.py +156 -0
- durable_sync/auth/__init__.py +8 -0
- durable_sync/auth/oauth/__init__.py +18 -0
- durable_sync/auth/oauth/flow.py +183 -0
- durable_sync/auth/oauth/refresh.py +58 -0
- durable_sync/auth/oauth/store.py +36 -0
- durable_sync/auth/oauth/token.py +36 -0
- durable_sync/auth/oauth/workflow.py +172 -0
- durable_sync/bootstrap.py +44 -0
- durable_sync/codec.py +80 -0
- durable_sync/config.py +35 -0
- durable_sync/connectors/__init__.py +14 -0
- durable_sync/connectors/asana/__init__.py +13 -0
- durable_sync/connectors/asana/destination.py +213 -0
- durable_sync/connectors/content.py +80 -0
- durable_sync/connectors/contentful/__init__.py +25 -0
- durable_sync/connectors/contentful/api.py +285 -0
- durable_sync/connectors/contentful/bootstrap.py +102 -0
- durable_sync/connectors/contentful/describe.py +61 -0
- durable_sync/connectors/contentful/destination.py +145 -0
- durable_sync/connectors/contentful/encode.py +49 -0
- durable_sync/connectors/contentful/introspect.py +69 -0
- durable_sync/connectors/contentful/mcp.py +95 -0
- durable_sync/connectors/contentful/mcp_destination.py +137 -0
- durable_sync/connectors/contentful/oauth.py +27 -0
- durable_sync/connectors/contentful/prove.py +51 -0
- durable_sync/connectors/contentful/source.py +192 -0
- durable_sync/connectors/contentful/start.py +46 -0
- durable_sync/connectors/contentful/store.py +25 -0
- durable_sync/connectors/contentful/token.py +13 -0
- durable_sync/connectors/contentful/token_check.py +42 -0
- durable_sync/connectors/github/__init__.py +33 -0
- durable_sync/connectors/github/api.py +169 -0
- durable_sync/connectors/github/source.py +230 -0
- durable_sync/connectors/luma/__init__.py +20 -0
- durable_sync/connectors/luma/api.py +121 -0
- durable_sync/connectors/luma/destination.py +128 -0
- durable_sync/connectors/luma/source.py +155 -0
- durable_sync/connectors/multi.py +78 -0
- durable_sync/connectors/notion/__init__.py +20 -0
- durable_sync/connectors/notion/bootstrap.py +97 -0
- durable_sync/connectors/notion/client.py +133 -0
- durable_sync/connectors/notion/destination.py +270 -0
- durable_sync/connectors/notion/oauth.py +25 -0
- durable_sync/connectors/notion/prove.py +57 -0
- durable_sync/connectors/notion/source.py +136 -0
- durable_sync/connectors/notion/start.py +46 -0
- durable_sync/connectors/notion/store.py +25 -0
- durable_sync/connectors/notion/token.py +13 -0
- durable_sync/connectors/youtube/__init__.py +13 -0
- durable_sync/connectors/youtube/api.py +122 -0
- durable_sync/connectors/youtube/source.py +152 -0
- durable_sync/core.py +210 -0
- durable_sync/env.py +55 -0
- durable_sync/http.py +71 -0
- durable_sync/linkstore.py +88 -0
- durable_sync/route.py +86 -0
- durable_sync/temporal_client.py +48 -0
- durable_sync/transport/__init__.py +12 -0
- durable_sync/transport/mcp.py +77 -0
- durable_sync/worker.py +109 -0
- durable_sync/workflows/__init__.py +9 -0
- durable_sync/workflows/sync.py +208 -0
- durable_sync-0.1.0.dist-info/METADATA +310 -0
- durable_sync-0.1.0.dist-info/RECORD +69 -0
- durable_sync-0.1.0.dist-info/WHEEL +5 -0
- durable_sync-0.1.0.dist-info/licenses/LICENSE +21 -0
- durable_sync-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Contentful API helpers — pure async HTTP + pure transforms. No Temporal, no
|
|
2
|
+
config globals; everything Contentful-specific (space, tokens, locale) rides on a
|
|
3
|
+
`ContentfulSpace` passed in.
|
|
4
|
+
|
|
5
|
+
Two access modes, chosen by which token is set, both yielding the SAME flattened
|
|
6
|
+
`(entry, authors)` shape so the normalizer is identical regardless of mode:
|
|
7
|
+
|
|
8
|
+
- **CDA (preferred):** the read-only Delivery API. Needs just a delivery token, no
|
|
9
|
+
admin. Returns only *published* entries, flattened to the default locale, with
|
|
10
|
+
linked author entries resolved inline under `includes.Entry`.
|
|
11
|
+
- **CMA (fallback, and the only way to see in-process drafts):** the Management
|
|
12
|
+
API + a self-serve PAT. Returns ALL entries (incl. drafts) with per-locale field
|
|
13
|
+
maps and NO link resolution — so we flatten locales, mark publish state, and
|
|
14
|
+
resolve authors against a one-time `person` index. The PAT is write-capable;
|
|
15
|
+
prefer CDA when you can.
|
|
16
|
+
|
|
17
|
+
Docs:
|
|
18
|
+
https://www.contentful.com/developers/docs/references/content-delivery-api/
|
|
19
|
+
https://www.contentful.com/developers/docs/references/content-management-api/
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import logging
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
import httpx
|
|
28
|
+
|
|
29
|
+
from durable_sync.core import DestinationHTTPError
|
|
30
|
+
from durable_sync.http import request_with_retry
|
|
31
|
+
|
|
32
|
+
CDA_BASE_URL = "https://cdn.contentful.com"
|
|
33
|
+
CMA_BASE_URL = "https://api.contentful.com"
|
|
34
|
+
PAGE_LIMIT = 100
|
|
35
|
+
log = logging.getLogger("durable_sync.connectors.contentful")
|
|
36
|
+
|
|
37
|
+
# --- Content-model seam -----------------------------------------------------
|
|
38
|
+
# These field names depend on the Contentful content model, so they live in one
|
|
39
|
+
# place. `person` typically exposes `name` but NO email, so author matching falls
|
|
40
|
+
# back to NAME. `authorOverwriteText` (read in source.py) covers community authors
|
|
41
|
+
# that have no `person` entry.
|
|
42
|
+
_AUTHOR_LINK_FIELDS = ("authors", "author", "presenters", "createdBy")
|
|
43
|
+
_AUTHOR_NAME_FIELDS = ("name", "fullName", "displayName")
|
|
44
|
+
_AUTHOR_EMAIL_FIELDS = ("email", "emailAddress")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class ContentfulSpace:
|
|
49
|
+
"""Connection facts for one space/environment. `delivery_token` selects CDA
|
|
50
|
+
(preferred); else `cma_token` selects the CMA fallback."""
|
|
51
|
+
space_id: str
|
|
52
|
+
environment: str = "master"
|
|
53
|
+
default_locale: str = "en-US"
|
|
54
|
+
delivery_token: str = ""
|
|
55
|
+
cma_token: str = ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _entries_url(base: str, space: ContentfulSpace) -> str:
|
|
59
|
+
return f"{base}/spaces/{space.space_id}/environments/{space.environment}/entries"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def iter_entries_page(
|
|
63
|
+
client: httpx.AsyncClient, space: ContentfulSpace, content_type: str, after_iso: str, *, skip: int = 0
|
|
64
|
+
) -> tuple[list[tuple[dict[str, Any], list[dict[str, Any]]]], int | None]:
|
|
65
|
+
"""ONE page of (entry, authors) for a content type, updated on/after `after_iso`.
|
|
66
|
+
Returns (pairs, next_skip) where next_skip is the offset for the next page or
|
|
67
|
+
None when exhausted — the cursor the spine threads through
|
|
68
|
+
`ContentfulSource.fetch_page`. Routes to the CMA fallback only without a CDA token."""
|
|
69
|
+
if space.delivery_token:
|
|
70
|
+
return await _cda_page(client, space, content_type, after_iso, skip)
|
|
71
|
+
if space.cma_token:
|
|
72
|
+
return await _cma_page(client, space, content_type, after_iso, skip)
|
|
73
|
+
raise RuntimeError(
|
|
74
|
+
"Contentful: set a delivery token (preferred) or a CMA token "
|
|
75
|
+
"(ContentfulConfig.delivery_token_env / cma_token_env)."
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def iter_entries(
|
|
80
|
+
client: httpx.AsyncClient, space: ContentfulSpace, content_type: str, after_iso: str
|
|
81
|
+
) -> list[tuple[dict[str, Any], list[dict[str, Any]]]]:
|
|
82
|
+
"""All (entry, authors) for a content type — drains iter_entries_page. For
|
|
83
|
+
non-Temporal callers; the spine pages directly."""
|
|
84
|
+
out: list[tuple[dict, list[dict]]] = []
|
|
85
|
+
skip = 0
|
|
86
|
+
while True:
|
|
87
|
+
pairs, next_skip = await iter_entries_page(client, space, content_type, after_iso, skip=skip)
|
|
88
|
+
out.extend(pairs)
|
|
89
|
+
if next_skip is None:
|
|
90
|
+
return out
|
|
91
|
+
skip = next_skip
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _next_skip(skip: int, n_items: int, total: int) -> int | None:
|
|
95
|
+
"""Offset for the next page, or None when this page exhausted the result set."""
|
|
96
|
+
return skip + n_items if (n_items and skip + n_items < total) else None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# --- CDA (Delivery API) -----------------------------------------------------
|
|
100
|
+
|
|
101
|
+
async def _cda_page(client, space, content_type, after_iso, skip):
|
|
102
|
+
params: dict[str, Any] = {
|
|
103
|
+
"content_type": content_type,
|
|
104
|
+
# Window on UPDATED-at, not created-at: catches entries recently published
|
|
105
|
+
# (publishing bumps updatedAt, even for old entries) AND recently-edited
|
|
106
|
+
# drafts. Overlap is idempotent-safe.
|
|
107
|
+
"sys.updatedAt[gte]": after_iso,
|
|
108
|
+
"order": "-sys.updatedAt",
|
|
109
|
+
"limit": PAGE_LIMIT,
|
|
110
|
+
"skip": skip,
|
|
111
|
+
"include": 1, # pull linked author entries into `includes`
|
|
112
|
+
}
|
|
113
|
+
data = await _get(client, CDA_BASE_URL, space, space.delivery_token, params)
|
|
114
|
+
items = data.get("items", [])
|
|
115
|
+
author_index = _index_includes(data)
|
|
116
|
+
pairs: list[tuple[dict, list[dict]]] = []
|
|
117
|
+
for entry in items:
|
|
118
|
+
entry["_published"] = True # CDA only ever returns published entries
|
|
119
|
+
pairs.append((entry, _resolve_authors(entry, author_index)))
|
|
120
|
+
return pairs, _next_skip(skip, len(items), data.get("total", 0))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _index_includes(data: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
|
124
|
+
"""{sys.id: entry} for linked entries returned (already flat) in includes."""
|
|
125
|
+
includes = data.get("includes", {}).get("Entry", [])
|
|
126
|
+
return {e.get("sys", {}).get("id"): e for e in includes if e.get("sys", {}).get("id")}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# --- CMA (Management API) fallback ------------------------------------------
|
|
130
|
+
|
|
131
|
+
async def _cma_page(client, space, content_type, after_iso, skip):
|
|
132
|
+
"""Like _cda_page, but the CMA returns drafts + per-locale fields + no link
|
|
133
|
+
resolution — so flatten locales, mark publish state, and resolve via a person
|
|
134
|
+
index. NOTE: the person index is (re)loaded per page since activities are
|
|
135
|
+
stateless; CDA (the preferred path) avoids this. We keep drafts: in-process
|
|
136
|
+
items are still worth indexing."""
|
|
137
|
+
person_index = await _load_person_index_cma(client, space)
|
|
138
|
+
params: dict[str, Any] = {
|
|
139
|
+
"content_type": content_type,
|
|
140
|
+
"sys.updatedAt[gte]": after_iso,
|
|
141
|
+
"order": "-sys.updatedAt",
|
|
142
|
+
"limit": PAGE_LIMIT,
|
|
143
|
+
"skip": skip,
|
|
144
|
+
}
|
|
145
|
+
data = await _get(client, CMA_BASE_URL, space, space.cma_token, params)
|
|
146
|
+
items = data.get("items", [])
|
|
147
|
+
pairs: list[tuple[dict, list[dict]]] = []
|
|
148
|
+
for raw in items:
|
|
149
|
+
entry = _flatten_entry(raw, space.default_locale)
|
|
150
|
+
entry["_published"] = _is_published(raw)
|
|
151
|
+
pairs.append((entry, _resolve_authors(entry, person_index)))
|
|
152
|
+
return pairs, _next_skip(skip, len(items), data.get("total", 0))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
async def _load_person_index_cma(client, space) -> dict[str, dict[str, Any]]:
|
|
156
|
+
"""{person id: {"fields": <flattened>}} for every person (CMA has no includes)."""
|
|
157
|
+
index: dict[str, dict[str, Any]] = {}
|
|
158
|
+
skip = 0
|
|
159
|
+
while True:
|
|
160
|
+
data = await _get(client, CMA_BASE_URL, space, space.cma_token,
|
|
161
|
+
{"content_type": "person", "limit": PAGE_LIMIT, "skip": skip})
|
|
162
|
+
items = data.get("items", [])
|
|
163
|
+
for raw in items:
|
|
164
|
+
pid = raw.get("sys", {}).get("id")
|
|
165
|
+
if pid:
|
|
166
|
+
index[pid] = _flatten_entry(raw, space.default_locale)
|
|
167
|
+
skip += len(items)
|
|
168
|
+
if not items or skip >= data.get("total", 0):
|
|
169
|
+
return index
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _is_published(raw: dict[str, Any]) -> bool:
|
|
173
|
+
"""A CMA entry is currently published iff it has a publishedVersion. Pure."""
|
|
174
|
+
return raw.get("sys", {}).get("publishedVersion") is not None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _flatten_entry(raw: dict[str, Any], locale: str) -> dict[str, Any]:
|
|
178
|
+
"""Collapse CMA per-locale field maps ({"en-US": v}) to the default locale,
|
|
179
|
+
leaving the same flat shape the CDA returns. Pure (no IO)."""
|
|
180
|
+
fields = {k: _pick_locale(v, locale) for k, v in raw.get("fields", {}).items()}
|
|
181
|
+
return {"sys": raw.get("sys", {}), "fields": fields}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _pick_locale(value: Any, locale: str) -> Any:
|
|
185
|
+
"""Pick `locale` from a CMA per-locale field map; fall back to the first locale
|
|
186
|
+
present. Non-dict values pass through. Pure."""
|
|
187
|
+
if isinstance(value, dict):
|
|
188
|
+
if locale in value:
|
|
189
|
+
return value[locale]
|
|
190
|
+
for v in value.values(): # single non-default locale
|
|
191
|
+
return v
|
|
192
|
+
return None
|
|
193
|
+
return value
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# --- Shared -----------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
async def _get(client, base: str, space: ContentfulSpace, token: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
199
|
+
r = await request_with_retry(
|
|
200
|
+
client, "GET", _entries_url(base, space),
|
|
201
|
+
headers={"Authorization": f"Bearer {token}"}, params=params,
|
|
202
|
+
)
|
|
203
|
+
r.raise_for_status()
|
|
204
|
+
return r.json()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _resolve_authors(entry: dict[str, Any], author_index: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
|
|
208
|
+
"""Resolve an entry's author link(s) to [{name, email}, ...]. Pure (dict only).
|
|
209
|
+
|
|
210
|
+
Handles a single link or an array of links. Unresolvable links are skipped.
|
|
211
|
+
`person` has no email, so email is typically "" and matching falls back to
|
|
212
|
+
name. Expects a FLAT entry (CDA native; CMA via _flatten_entry)."""
|
|
213
|
+
raw = next((entry.get("fields", {}).get(f) for f in _AUTHOR_LINK_FIELDS
|
|
214
|
+
if entry.get("fields", {}).get(f)), None)
|
|
215
|
+
links = raw if isinstance(raw, list) else [raw]
|
|
216
|
+
|
|
217
|
+
authors: list[dict[str, Any]] = []
|
|
218
|
+
for link in links:
|
|
219
|
+
if not isinstance(link, dict):
|
|
220
|
+
continue
|
|
221
|
+
person = author_index.get(link.get("sys", {}).get("id"), {})
|
|
222
|
+
pf = person.get("fields", {})
|
|
223
|
+
name = next((pf[f] for f in _AUTHOR_NAME_FIELDS if pf.get(f)), "")
|
|
224
|
+
email = next((pf[f] for f in _AUTHOR_EMAIL_FIELDS if pf.get(f)), "")
|
|
225
|
+
if name or email:
|
|
226
|
+
authors.append({"name": name, "email": email})
|
|
227
|
+
return authors
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# --- write side: CMA only (the Delivery API is read-only) -------------------
|
|
231
|
+
# Verify against the CMA docs — writes are versioned (optimistic locking) and
|
|
232
|
+
# locale-wrapped: https://www.contentful.com/developers/docs/references/content-management-api/
|
|
233
|
+
|
|
234
|
+
def cma_entries_url(space: ContentfulSpace) -> str:
|
|
235
|
+
return f"{CMA_BASE_URL}/spaces/{space.space_id}/environments/{space.environment}/entries"
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
async def _cma(client: httpx.AsyncClient, method: str, url: str, *, headers=None, json=None) -> dict[str, Any]:
|
|
239
|
+
"""A CMA call; raise with status text so is_auth_error classifies a 401.
|
|
240
|
+
The client carries the Bearer + content-type headers (set in connect)."""
|
|
241
|
+
r = await request_with_retry(client, method, url, headers=headers, json=json)
|
|
242
|
+
if r.status_code >= 400:
|
|
243
|
+
raise DestinationHTTPError(
|
|
244
|
+
r.status_code, f"Contentful {method} {url.rsplit('/', 1)[-1]} -> {r.status_code}: {r.text[:600]}"
|
|
245
|
+
)
|
|
246
|
+
return r.json() if r.content else {}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
async def entry_version_or_none(
|
|
250
|
+
client: httpx.AsyncClient, space: ContentfulSpace, entry_id: str
|
|
251
|
+
) -> int | None:
|
|
252
|
+
"""Current sys.version of an entry, or None if it doesn't exist (404). Lets the
|
|
253
|
+
idempotent upsert decide create-vs-update at a client-chosen id."""
|
|
254
|
+
r = await request_with_retry(client, "GET", f"{cma_entries_url(space)}/{entry_id}")
|
|
255
|
+
if r.status_code == 404:
|
|
256
|
+
return None
|
|
257
|
+
if r.status_code >= 400:
|
|
258
|
+
raise DestinationHTTPError(
|
|
259
|
+
r.status_code, f"Contentful GET {entry_id} -> {r.status_code}: {r.text[:600]}"
|
|
260
|
+
)
|
|
261
|
+
return r.json().get("sys", {}).get("version", 0)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
async def upsert_entry(
|
|
265
|
+
client: httpx.AsyncClient, space: ContentfulSpace, entry_id: str,
|
|
266
|
+
content_type: str, fields: dict[str, Any], *, version: int | None,
|
|
267
|
+
) -> int:
|
|
268
|
+
"""Idempotent create-or-update at a CLIENT-CHOSEN id via PUT /entries/{id}.
|
|
269
|
+
`version is None` -> create (sends the content-type header); an int -> update
|
|
270
|
+
with the optimistic-lock header. Returns the new sys.version. Because the id is
|
|
271
|
+
deterministic (see encode.deterministic_entry_id), a retried create can't
|
|
272
|
+
duplicate — it updates the entry the first attempt already made."""
|
|
273
|
+
headers = (
|
|
274
|
+
{"X-Contentful-Content-Type": content_type} if version is None
|
|
275
|
+
else {"X-Contentful-Version": str(version)}
|
|
276
|
+
)
|
|
277
|
+
data = await _cma(client, "PUT", f"{cma_entries_url(space)}/{entry_id}",
|
|
278
|
+
headers=headers, json={"fields": fields})
|
|
279
|
+
return data.get("sys", {}).get("version", (version or 0) + 1)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
async def publish_entry(client: httpx.AsyncClient, space: ContentfulSpace, entry_id: str, *, version: int) -> None:
|
|
283
|
+
"""Publish an entry at `version` (else it stays a draft, CMA-only-visible)."""
|
|
284
|
+
await _cma(client, "PUT", f"{cma_entries_url(space)}/{entry_id}/published",
|
|
285
|
+
headers={"X-Contentful-Version": str(version)})
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""One-time interactive OAuth bootstrap for Contentful's MCP server. Run once, as
|
|
2
|
+
yourself, in a browser:
|
|
3
|
+
|
|
4
|
+
PYTHONPATH=. python -m durable_sync.connectors.contentful.bootstrap
|
|
5
|
+
|
|
6
|
+
No CMA token, no org admin, no SSO workaround: this self-registers an OAuth client
|
|
7
|
+
(RFC 7591) and authorizes as *you* through your org's SSO — so it gets the access
|
|
8
|
+
a static personal token can't. Saves the refresh token + client_id locally (see
|
|
9
|
+
store.py) so the headless path (prove.py, then OAuthTokenWorkflow) can mint access
|
|
10
|
+
tokens unattended.
|
|
11
|
+
|
|
12
|
+
(Near-identical to the Notion bootstrap — once we wire a second provider for real,
|
|
13
|
+
this OAuth-CLI flow is worth generalizing into auth/oauth/.)
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import http.server
|
|
18
|
+
import threading
|
|
19
|
+
import urllib.parse
|
|
20
|
+
import webbrowser
|
|
21
|
+
|
|
22
|
+
from durable_sync.connectors.contentful import oauth, store
|
|
23
|
+
|
|
24
|
+
_PORT = 8788
|
|
25
|
+
REDIRECT_URI = f"http://localhost:{_PORT}/callback"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _CallbackHandler(http.server.BaseHTTPRequestHandler):
|
|
29
|
+
result: dict[str, str] = {}
|
|
30
|
+
|
|
31
|
+
def do_GET(self) -> None: # noqa: N802 (stdlib naming)
|
|
32
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
33
|
+
if parsed.path != "/callback":
|
|
34
|
+
self.send_response(404)
|
|
35
|
+
self.end_headers()
|
|
36
|
+
return
|
|
37
|
+
params = urllib.parse.parse_qs(parsed.query)
|
|
38
|
+
_CallbackHandler.result = {k: v[0] for k, v in params.items()}
|
|
39
|
+
self.send_response(200)
|
|
40
|
+
self.send_header("Content-Type", "text/html")
|
|
41
|
+
self.end_headers()
|
|
42
|
+
self.wfile.write(
|
|
43
|
+
b"<html><body><h2>durable-sync: Contentful authorized.</h2>"
|
|
44
|
+
b"You can close this tab and return to the terminal.</body></html>"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def log_message(self, *args: object) -> None: # silence default logging
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _wait_for_callback() -> dict[str, str]:
|
|
52
|
+
server = http.server.HTTPServer(("localhost", _PORT), _CallbackHandler)
|
|
53
|
+
thread = threading.Thread(target=server.handle_request) # one request, then stop
|
|
54
|
+
thread.start()
|
|
55
|
+
thread.join()
|
|
56
|
+
server.server_close()
|
|
57
|
+
return _CallbackHandler.result
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def main() -> None:
|
|
61
|
+
print("Discovering Contentful MCP OAuth endpoints...")
|
|
62
|
+
endpoints = oauth.discover()
|
|
63
|
+
|
|
64
|
+
print("Registering an OAuth client (dynamic, no admin needed)...")
|
|
65
|
+
client = oauth.register_client(endpoints["registration_endpoint"], REDIRECT_URI)
|
|
66
|
+
client_id = client["client_id"]
|
|
67
|
+
|
|
68
|
+
verifier, challenge = oauth.gen_pkce()
|
|
69
|
+
state = oauth.new_state()
|
|
70
|
+
url = oauth.build_authorize_url(
|
|
71
|
+
endpoints["authorization_endpoint"], client_id, REDIRECT_URI, challenge, state
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
print(f"\nOpening your browser to authorize as yourself (via SSO):\n {url}\n")
|
|
75
|
+
webbrowser.open(url)
|
|
76
|
+
print(f"Waiting for the redirect to {REDIRECT_URI} ...")
|
|
77
|
+
cb = _wait_for_callback()
|
|
78
|
+
|
|
79
|
+
if cb.get("state") != state:
|
|
80
|
+
raise SystemExit("State mismatch — aborting (possible CSRF).")
|
|
81
|
+
if "code" not in cb:
|
|
82
|
+
raise SystemExit(f"No authorization code in callback: {cb}")
|
|
83
|
+
|
|
84
|
+
print("Exchanging authorization code for tokens...")
|
|
85
|
+
tokens = oauth.exchange_code(
|
|
86
|
+
endpoints["token_endpoint"], client_id, cb["code"], REDIRECT_URI, verifier
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
store.save({
|
|
90
|
+
"client_id": client_id,
|
|
91
|
+
"token_endpoint": endpoints["token_endpoint"],
|
|
92
|
+
"refresh_token": tokens["refresh_token"],
|
|
93
|
+
})
|
|
94
|
+
print(
|
|
95
|
+
f"\nSaved credentials to {store.path()}.\n"
|
|
96
|
+
f"Next: list the Contentful MCP tools (and prove headless minting) with\n"
|
|
97
|
+
f" PYTHONPATH=. python -m durable_sync.connectors.contentful.prove"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
if __name__ == "__main__":
|
|
102
|
+
main()
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Dump Contentful MCP usage + the input schemas of the tools we'll build against,
|
|
2
|
+
so the connector is written to the real shapes (not guessed).
|
|
3
|
+
|
|
4
|
+
PYTHONPATH=. python -m durable_sync.connectors.contentful.describe
|
|
5
|
+
|
|
6
|
+
Prints get_initial_context (most MCP servers expect it first — it documents usage
|
|
7
|
+
and your space/environment) and the inputSchema of the read/write/schema tools.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import json
|
|
13
|
+
|
|
14
|
+
from durable_sync.connectors.contentful import oauth, store
|
|
15
|
+
from durable_sync.transport.mcp import open_session
|
|
16
|
+
|
|
17
|
+
# The tools the source/destination/schema layers will use.
|
|
18
|
+
_KEY_TOOLS = [
|
|
19
|
+
"search_entries", "get_entry", "resolve_entry_references",
|
|
20
|
+
"create_entry", "update_entry", "publish_entry",
|
|
21
|
+
"list_content_types", "get_content_type",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def _describe(access_token: str) -> None:
|
|
26
|
+
async def token_provider() -> str:
|
|
27
|
+
return access_token
|
|
28
|
+
async with open_session(oauth.MCP_ENDPOINT, token_provider) as session:
|
|
29
|
+
print("=== get_initial_context ===")
|
|
30
|
+
try:
|
|
31
|
+
print(await session.call("get_initial_context", {}))
|
|
32
|
+
except Exception as e: # noqa: BLE001 — discovery, surface whatever it says
|
|
33
|
+
print(f"(error calling get_initial_context: {e})")
|
|
34
|
+
print()
|
|
35
|
+
|
|
36
|
+
by_name = {t.name: t for t in await session.tools()}
|
|
37
|
+
for name in _KEY_TOOLS:
|
|
38
|
+
tool = by_name.get(name)
|
|
39
|
+
if tool is None:
|
|
40
|
+
print(f"=== {name}: NOT FOUND ===\n")
|
|
41
|
+
continue
|
|
42
|
+
print(f"=== {name} ===")
|
|
43
|
+
if tool.description:
|
|
44
|
+
print(tool.description.strip()[:400])
|
|
45
|
+
print(json.dumps(tool.inputSchema, indent=2))
|
|
46
|
+
print()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def main() -> None:
|
|
50
|
+
creds = store.load()
|
|
51
|
+
if not creds:
|
|
52
|
+
raise SystemExit("No credentials — run connectors.contentful.bootstrap first.")
|
|
53
|
+
tokens = oauth.refresh_access_token(creds["token_endpoint"], creds["client_id"], creds["refresh_token"])
|
|
54
|
+
if tokens.get("refresh_token"):
|
|
55
|
+
creds["refresh_token"] = tokens["refresh_token"]
|
|
56
|
+
store.save(creds)
|
|
57
|
+
asyncio.run(_describe(tokens["access_token"]))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
if __name__ == "__main__":
|
|
61
|
+
main()
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""ContentfulDestination — create/update Contentful entries from neutral Records.
|
|
2
|
+
|
|
3
|
+
The write half of the Contentful connector (e.g. cross-posting from Notion).
|
|
4
|
+
Writes go through the **CMA** (the Delivery API is read-only), which means:
|
|
5
|
+
* a CMA token (write-capable),
|
|
6
|
+
* locale-wrapped field values ({field: {locale: value}}),
|
|
7
|
+
* versioned updates (fetch sys.version, send it as the optimistic-lock header),
|
|
8
|
+
* an explicit publish step (else entries stay drafts).
|
|
9
|
+
|
|
10
|
+
Idempotency: the entry id is a DETERMINISTIC function of the source primary_key
|
|
11
|
+
(`deterministic_entry_id`), and creates go through `PUT /entries/{id}` (Contentful
|
|
12
|
+
lets you choose the id). So a create that's retried after a crash re-derives the
|
|
13
|
+
same id and UPDATES that entry rather than duplicating it — at-least-once safe
|
|
14
|
+
without trusting the LinkStore to have been written. The injected `LinkStore` is
|
|
15
|
+
still used for query_existing_ids (to route known rows straight to update), but it
|
|
16
|
+
is now just an optimization: even an empty/lost store can't cause duplicates.
|
|
17
|
+
|
|
18
|
+
NOTE: the CMA shape here follows the docs but has not been run against a live
|
|
19
|
+
space — verify field-locale wrapping, the PUT-with-id create, versioning, and
|
|
20
|
+
publish before relying on it. The pure encoding + id derivation are unit-tested;
|
|
21
|
+
the HTTP request sequence is unit-tested against a fake client but not live.
|
|
22
|
+
|
|
23
|
+
Requires the `contentful` extra.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
import datetime as dt
|
|
29
|
+
import os
|
|
30
|
+
from contextlib import asynccontextmanager
|
|
31
|
+
from typing import Any, AsyncIterator
|
|
32
|
+
|
|
33
|
+
import httpx
|
|
34
|
+
|
|
35
|
+
from durable_sync.core import Record, auth_error_in_chain
|
|
36
|
+
from durable_sync.linkstore import LinkStore
|
|
37
|
+
from durable_sync.connectors.contentful import api
|
|
38
|
+
from durable_sync.connectors.contentful.api import ContentfulSpace
|
|
39
|
+
from durable_sync.connectors.contentful.encode import deterministic_entry_id, encode_fields
|
|
40
|
+
|
|
41
|
+
_CMA_CONTENT_TYPE = "application/vnd.contentful.management.v1+json"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ContentfulDestination:
|
|
45
|
+
name = "contentful"
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
*,
|
|
50
|
+
space_id: str,
|
|
51
|
+
content_type: str, # the content-type id to create entries as
|
|
52
|
+
field_map: dict[str, str], # neutral property name -> CMA field id
|
|
53
|
+
link_store: LinkStore, # REQUIRED — correspondence lives outside Contentful
|
|
54
|
+
cma_token_env: str = "CONTENTFUL_CMA_TOKEN",
|
|
55
|
+
environment: str = "master",
|
|
56
|
+
default_locale: str = "en-US",
|
|
57
|
+
create_only_properties: set[str] | None = None,
|
|
58
|
+
publish: bool = False, # publish on write, or leave as draft
|
|
59
|
+
pacing_seconds: float = 0.0,
|
|
60
|
+
):
|
|
61
|
+
self.space_id = space_id
|
|
62
|
+
self.content_type = content_type
|
|
63
|
+
self.field_map = field_map
|
|
64
|
+
self.link_store = link_store
|
|
65
|
+
self.cma_token_env = cma_token_env
|
|
66
|
+
self.environment = environment
|
|
67
|
+
self.default_locale = default_locale
|
|
68
|
+
self.create_only_properties = create_only_properties or set()
|
|
69
|
+
self.publish = publish
|
|
70
|
+
self.pacing_seconds = pacing_seconds
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def configured(self) -> bool:
|
|
74
|
+
return bool(self.space_id and os.environ.get(self.cma_token_env))
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def config_hint(self) -> str:
|
|
78
|
+
return f"Contentful space id / {self.cma_token_env} unset"
|
|
79
|
+
|
|
80
|
+
def _space(self) -> ContentfulSpace:
|
|
81
|
+
return ContentfulSpace(
|
|
82
|
+
space_id=self.space_id, environment=self.environment,
|
|
83
|
+
default_locale=self.default_locale,
|
|
84
|
+
cma_token=os.environ.get(self.cma_token_env, ""),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
@asynccontextmanager
|
|
88
|
+
async def connect(self) -> AsyncIterator["_ContentfulSession"]:
|
|
89
|
+
headers = {
|
|
90
|
+
"Authorization": f"Bearer {os.environ.get(self.cma_token_env, '')}",
|
|
91
|
+
"Content-Type": _CMA_CONTENT_TYPE,
|
|
92
|
+
}
|
|
93
|
+
async with httpx.AsyncClient(headers=headers, timeout=30) as client:
|
|
94
|
+
yield _ContentfulSession(client, self)
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def is_auth_error(err: BaseException) -> bool:
|
|
98
|
+
return auth_error_in_chain(err)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class _ContentfulSession:
|
|
102
|
+
def __init__(self, client: httpx.AsyncClient, dest: ContentfulDestination):
|
|
103
|
+
self._client = client
|
|
104
|
+
self._d = dest
|
|
105
|
+
self._space = dest._space()
|
|
106
|
+
|
|
107
|
+
async def query_existing_ids(self) -> dict[str, str]:
|
|
108
|
+
return await self._d.link_store.get_all()
|
|
109
|
+
|
|
110
|
+
async def create(self, record: Record, synced_at: dt.datetime) -> bool:
|
|
111
|
+
# Idempotent: the entry id is a pure function of primary_key, so a retried
|
|
112
|
+
# create (after a crash) re-derives the SAME id and updates the entry the
|
|
113
|
+
# first attempt made instead of duplicating it. We still record the link so
|
|
114
|
+
# query_existing_ids routes future syncs straight to update().
|
|
115
|
+
entry_id = deterministic_entry_id(record.primary_key)
|
|
116
|
+
await self._upsert(entry_id, record, creating=True)
|
|
117
|
+
await self._d.link_store.put(record.primary_key, entry_id)
|
|
118
|
+
await self._pace()
|
|
119
|
+
return True
|
|
120
|
+
|
|
121
|
+
async def update(self, existing_id: str, record: Record, synced_at: dt.datetime) -> bool:
|
|
122
|
+
await self._upsert(existing_id, record, creating=False)
|
|
123
|
+
await self._pace()
|
|
124
|
+
return True
|
|
125
|
+
|
|
126
|
+
async def _upsert(self, entry_id: str, record: Record, *, creating: bool) -> None:
|
|
127
|
+
fields = _encode_fields(self._d, record, creating=creating)
|
|
128
|
+
version = await api.entry_version_or_none(self._client, self._space, entry_id)
|
|
129
|
+
new_version = await api.upsert_entry(
|
|
130
|
+
self._client, self._space, entry_id, self._d.content_type, fields, version=version
|
|
131
|
+
)
|
|
132
|
+
if self._d.publish:
|
|
133
|
+
await api.publish_entry(self._client, self._space, entry_id, version=new_version)
|
|
134
|
+
|
|
135
|
+
async def _pace(self) -> None:
|
|
136
|
+
if self._d.pacing_seconds > 0:
|
|
137
|
+
await asyncio.sleep(self._d.pacing_seconds)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _encode_fields(dest: ContentfulDestination, record: Record, *, creating: bool = True) -> dict[str, Any]:
|
|
141
|
+
"""Neutral Record -> CMA `fields`. Thin wrapper over the shared encoder."""
|
|
142
|
+
return encode_fields(
|
|
143
|
+
record, field_map=dest.field_map, default_locale=dest.default_locale,
|
|
144
|
+
create_only_properties=dest.create_only_properties, creating=creating,
|
|
145
|
+
)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Neutral Record -> Contentful CMA `fields` ({field id: {locale: value}}).
|
|
2
|
+
|
|
3
|
+
Shared by both the REST destination and the MCP destination — the wire shape is
|
|
4
|
+
identical (the MCP create_entry/update_entry tools take the same locale-wrapped
|
|
5
|
+
fields object), so the encoding lives here once. Pure (no IO).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from durable_sync.core import Record
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def deterministic_entry_id(primary_key: str) -> str:
|
|
16
|
+
"""A stable Contentful entry id derived purely from the source primary_key.
|
|
17
|
+
|
|
18
|
+
Contentful lets you CREATE at a client-chosen id (PUT /entries/{id}), and entry
|
|
19
|
+
ids are [A-Za-z0-9_-]{1,64}. A sha256 hex digest (64 chars, charset-safe) fits
|
|
20
|
+
exactly and is collision-free in practice — so the same Record always maps to
|
|
21
|
+
the same entry, which makes create idempotent: a retry after a crashed create
|
|
22
|
+
re-derives the SAME id and updates that entry instead of making a duplicate
|
|
23
|
+
(and an empty/lost LinkStore can no longer cause duplicates either)."""
|
|
24
|
+
return hashlib.sha256(primary_key.encode("utf-8")).hexdigest() # 64 hex chars
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def encode_fields(
|
|
28
|
+
record: Record,
|
|
29
|
+
*,
|
|
30
|
+
field_map: dict[str, str],
|
|
31
|
+
default_locale: str,
|
|
32
|
+
create_only_properties: frozenset[str] | set[str] = frozenset(),
|
|
33
|
+
creating: bool = True,
|
|
34
|
+
) -> dict[str, Any]:
|
|
35
|
+
"""Map each property through `field_map` (neutral name -> CMA field id),
|
|
36
|
+
locale-wrapping the value. Unmapped properties and Nones are dropped (Contentful
|
|
37
|
+
has a fixed content model); on update, create-only properties are skipped so
|
|
38
|
+
human edits in Contentful survive."""
|
|
39
|
+
out: dict[str, Any] = {}
|
|
40
|
+
for prop, value in record.properties.items():
|
|
41
|
+
if value is None:
|
|
42
|
+
continue
|
|
43
|
+
if not creating and prop in create_only_properties:
|
|
44
|
+
continue
|
|
45
|
+
field_id = field_map.get(prop)
|
|
46
|
+
if not field_id:
|
|
47
|
+
continue
|
|
48
|
+
out[field_id] = {default_locale: value}
|
|
49
|
+
return out
|