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.
Files changed (69) hide show
  1. durable_sync/__init__.py +26 -0
  2. durable_sync/activities.py +156 -0
  3. durable_sync/auth/__init__.py +8 -0
  4. durable_sync/auth/oauth/__init__.py +18 -0
  5. durable_sync/auth/oauth/flow.py +183 -0
  6. durable_sync/auth/oauth/refresh.py +58 -0
  7. durable_sync/auth/oauth/store.py +36 -0
  8. durable_sync/auth/oauth/token.py +36 -0
  9. durable_sync/auth/oauth/workflow.py +172 -0
  10. durable_sync/bootstrap.py +44 -0
  11. durable_sync/codec.py +80 -0
  12. durable_sync/config.py +35 -0
  13. durable_sync/connectors/__init__.py +14 -0
  14. durable_sync/connectors/asana/__init__.py +13 -0
  15. durable_sync/connectors/asana/destination.py +213 -0
  16. durable_sync/connectors/content.py +80 -0
  17. durable_sync/connectors/contentful/__init__.py +25 -0
  18. durable_sync/connectors/contentful/api.py +285 -0
  19. durable_sync/connectors/contentful/bootstrap.py +102 -0
  20. durable_sync/connectors/contentful/describe.py +61 -0
  21. durable_sync/connectors/contentful/destination.py +145 -0
  22. durable_sync/connectors/contentful/encode.py +49 -0
  23. durable_sync/connectors/contentful/introspect.py +69 -0
  24. durable_sync/connectors/contentful/mcp.py +95 -0
  25. durable_sync/connectors/contentful/mcp_destination.py +137 -0
  26. durable_sync/connectors/contentful/oauth.py +27 -0
  27. durable_sync/connectors/contentful/prove.py +51 -0
  28. durable_sync/connectors/contentful/source.py +192 -0
  29. durable_sync/connectors/contentful/start.py +46 -0
  30. durable_sync/connectors/contentful/store.py +25 -0
  31. durable_sync/connectors/contentful/token.py +13 -0
  32. durable_sync/connectors/contentful/token_check.py +42 -0
  33. durable_sync/connectors/github/__init__.py +33 -0
  34. durable_sync/connectors/github/api.py +169 -0
  35. durable_sync/connectors/github/source.py +230 -0
  36. durable_sync/connectors/luma/__init__.py +20 -0
  37. durable_sync/connectors/luma/api.py +121 -0
  38. durable_sync/connectors/luma/destination.py +128 -0
  39. durable_sync/connectors/luma/source.py +155 -0
  40. durable_sync/connectors/multi.py +78 -0
  41. durable_sync/connectors/notion/__init__.py +20 -0
  42. durable_sync/connectors/notion/bootstrap.py +97 -0
  43. durable_sync/connectors/notion/client.py +133 -0
  44. durable_sync/connectors/notion/destination.py +270 -0
  45. durable_sync/connectors/notion/oauth.py +25 -0
  46. durable_sync/connectors/notion/prove.py +57 -0
  47. durable_sync/connectors/notion/source.py +136 -0
  48. durable_sync/connectors/notion/start.py +46 -0
  49. durable_sync/connectors/notion/store.py +25 -0
  50. durable_sync/connectors/notion/token.py +13 -0
  51. durable_sync/connectors/youtube/__init__.py +13 -0
  52. durable_sync/connectors/youtube/api.py +122 -0
  53. durable_sync/connectors/youtube/source.py +152 -0
  54. durable_sync/core.py +210 -0
  55. durable_sync/env.py +55 -0
  56. durable_sync/http.py +71 -0
  57. durable_sync/linkstore.py +88 -0
  58. durable_sync/route.py +86 -0
  59. durable_sync/temporal_client.py +48 -0
  60. durable_sync/transport/__init__.py +12 -0
  61. durable_sync/transport/mcp.py +77 -0
  62. durable_sync/worker.py +109 -0
  63. durable_sync/workflows/__init__.py +9 -0
  64. durable_sync/workflows/sync.py +208 -0
  65. durable_sync-0.1.0.dist-info/METADATA +310 -0
  66. durable_sync-0.1.0.dist-info/RECORD +69 -0
  67. durable_sync-0.1.0.dist-info/WHEEL +5 -0
  68. durable_sync-0.1.0.dist-info/licenses/LICENSE +21 -0
  69. 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