render-lab-tasks-contentful 0.1.0__tar.gz

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 (24) hide show
  1. render_lab_tasks_contentful-0.1.0/.gitignore +11 -0
  2. render_lab_tasks_contentful-0.1.0/LICENSE +21 -0
  3. render_lab_tasks_contentful-0.1.0/PKG-INFO +85 -0
  4. render_lab_tasks_contentful-0.1.0/README.md +71 -0
  5. render_lab_tasks_contentful-0.1.0/pyproject.toml +23 -0
  6. render_lab_tasks_contentful-0.1.0/src/render_lab_tasks_contentful/__init__.py +1 -0
  7. render_lab_tasks_contentful-0.1.0/src/render_lab_tasks_contentful/_app.py +3 -0
  8. render_lab_tasks_contentful-0.1.0/src/render_lab_tasks_contentful/client.py +261 -0
  9. render_lab_tasks_contentful-0.1.0/src/render_lab_tasks_contentful/create_asset.py +18 -0
  10. render_lab_tasks_contentful-0.1.0/src/render_lab_tasks_contentful/create_entry.py +18 -0
  11. render_lab_tasks_contentful-0.1.0/src/render_lab_tasks_contentful/delete_entry.py +18 -0
  12. render_lab_tasks_contentful-0.1.0/src/render_lab_tasks_contentful/get_assets.py +18 -0
  13. render_lab_tasks_contentful-0.1.0/src/render_lab_tasks_contentful/get_content_types.py +18 -0
  14. render_lab_tasks_contentful-0.1.0/src/render_lab_tasks_contentful/get_entries.py +18 -0
  15. render_lab_tasks_contentful-0.1.0/src/render_lab_tasks_contentful/get_entry.py +18 -0
  16. render_lab_tasks_contentful-0.1.0/src/render_lab_tasks_contentful/publish_asset.py +18 -0
  17. render_lab_tasks_contentful-0.1.0/src/render_lab_tasks_contentful/publish_entry.py +18 -0
  18. render_lab_tasks_contentful-0.1.0/src/render_lab_tasks_contentful/py.typed +0 -0
  19. render_lab_tasks_contentful-0.1.0/src/render_lab_tasks_contentful/retry.py +4 -0
  20. render_lab_tasks_contentful-0.1.0/src/render_lab_tasks_contentful/tasks.py +38 -0
  21. render_lab_tasks_contentful-0.1.0/src/render_lab_tasks_contentful/types.py +129 -0
  22. render_lab_tasks_contentful-0.1.0/src/render_lab_tasks_contentful/unpublish_entry.py +18 -0
  23. render_lab_tasks_contentful-0.1.0/src/render_lab_tasks_contentful/update_entry.py +18 -0
  24. render_lab_tasks_contentful-0.1.0/src/render_lab_tasks_contentful/webhooks.py +89 -0
@@ -0,0 +1,11 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .mypy_cache/
6
+ .ruff_cache/
7
+ dist/
8
+ *.egg-info/
9
+ .env
10
+ .env.*
11
+ !.env.example
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Render Lab
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.
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.5
2
+ Name: render-lab-tasks-contentful
3
+ Version: 0.1.0
4
+ Summary: contentful tasks for Render Workflows
5
+ Project-URL: Repository, https://github.com/render-lab/render-tasks-python
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.12
9
+ Requires-Dist: httpx<0.29,>=0.28
10
+ Requires-Dist: render-lab-tasks-core<0.2,>=0.1.1
11
+ Requires-Dist: render-lab-triggers<0.2,>=0.1.0
12
+ Requires-Dist: render==1.0.1
13
+ Description-Content-Type: text/markdown
14
+
15
+ # render-lab-tasks-contentful
16
+
17
+ Python port of all 11 registered tasks in the pinned TypeScript pack at
18
+ `45f9c2d44bd28e01ae9813e0d2ff56ee6c533816`. Python 3.12+.
19
+
20
+ Install this distribution and its dependencies:
21
+
22
+ ```sh
23
+ pip install render-lab-tasks-contentful==0.1.0
24
+ ```
25
+
26
+ Import definitions from `render_lab_tasks_contentful.tasks`; combine `app` objects
27
+ with `Workflows.from_workflows(...)`. JSON fields retain TS spelling; functions
28
+ use snake_case. Package root imports are inert.
29
+
30
+ ## Tasks
31
+
32
+ | Registered name | Python export (also exposes `_impl`) |
33
+ | --- | --- |
34
+ | `contentful.createAsset` | `create_asset` |
35
+ | `contentful.createEntry` | `create_entry` |
36
+ | `contentful.deleteEntry` | `delete_entry` |
37
+ | `contentful.getAssets` | `get_assets` |
38
+ | `contentful.getContentTypes` | `get_content_types` |
39
+ | `contentful.getEntries` | `get_entries` |
40
+ | `contentful.getEntry` | `get_entry` |
41
+ | `contentful.publishAsset` | `publish_asset` |
42
+ | `contentful.publishEntry` | `publish_entry` |
43
+ | `contentful.unpublishEntry` | `unpublish_entry` |
44
+ | `contentful.updateEntry` | `update_entry` |
45
+
46
+ ## Dependencies and retries
47
+
48
+ Raw implementations accept context first and keyword-only `deps`. The `.client`
49
+ module exports typed `Port`, `Deps`, and `Client` classes. Inject a fake port, or
50
+ pass a caller-owned `httpx.AsyncClient` to `Client`. Default dependencies close
51
+ their client after one operation. Credentials resolve at first use. Transport
52
+ retries are disabled; `retry.py` owns durable policies. DTO types are in `.types`.
53
+ Requires core >=0.1.1.
54
+
55
+ ## Environment
56
+
57
+ | Variable | Contract |
58
+ | --- | --- |
59
+ | `CONTENTFUL_SPACE_ID` | Required space. |
60
+ | `CONTENTFUL_ENVIRONMENT` | Optional environment; defaults to `master`. |
61
+ | `CONTENTFUL_CDA_TOKEN` | Required for delivery reads: getEntries/getAssets. |
62
+ | `CONTENTFUL_CMA_TOKEN` | Required for management reads and writes. |
63
+
64
+ ## Verification and limitations
65
+
66
+ Delivery and management credentials are separate. DTOs flatten sys metadata and
67
+ preserve localized fields. Versioned writes and creates disable durable retries;
68
+ delete treats 404 as already deleted. Asset creation processes each requested
69
+ locale and rereads its version, but does not wait for processing completion.
70
+ Collection results at or above 4 MiB are rejected. Each read is a single page.
71
+
72
+ Shared fixtures execute the actual pinned TS implementations and compare Python
73
+ outputs and requests. Sanity's SDK calls are translated into separately asserted
74
+ REST requests. These are hermetic checks, not real account or hosted verification.
75
+ See [the testing backlog](https://github.com/render-lab/render-tasks-python/issues/1)
76
+ and the root verification tracker. Licensed under MIT.
77
+
78
+ ## Webhook adapter
79
+
80
+ `render_lab_tasks_contentful.webhooks.contentful_adapter(on_event, ...)` returns a
81
+ registration-free adapter for `render_lab_triggers`. The callback receives the
82
+ verified event and returns `{"task": "namespace.task", "args": [event]}` or `None`.
83
+ The signing credential `CONTENTFUL_WEBHOOK_SECRET` is read inside verification unless
84
+ provided explicitly. Importing this module does not register tasks. Signature
85
+ checks use the raw body; live delivery remains in the testing backlog.
@@ -0,0 +1,71 @@
1
+ # render-lab-tasks-contentful
2
+
3
+ Python port of all 11 registered tasks in the pinned TypeScript pack at
4
+ `45f9c2d44bd28e01ae9813e0d2ff56ee6c533816`. Python 3.12+.
5
+
6
+ Install this distribution and its dependencies:
7
+
8
+ ```sh
9
+ pip install render-lab-tasks-contentful==0.1.0
10
+ ```
11
+
12
+ Import definitions from `render_lab_tasks_contentful.tasks`; combine `app` objects
13
+ with `Workflows.from_workflows(...)`. JSON fields retain TS spelling; functions
14
+ use snake_case. Package root imports are inert.
15
+
16
+ ## Tasks
17
+
18
+ | Registered name | Python export (also exposes `_impl`) |
19
+ | --- | --- |
20
+ | `contentful.createAsset` | `create_asset` |
21
+ | `contentful.createEntry` | `create_entry` |
22
+ | `contentful.deleteEntry` | `delete_entry` |
23
+ | `contentful.getAssets` | `get_assets` |
24
+ | `contentful.getContentTypes` | `get_content_types` |
25
+ | `contentful.getEntries` | `get_entries` |
26
+ | `contentful.getEntry` | `get_entry` |
27
+ | `contentful.publishAsset` | `publish_asset` |
28
+ | `contentful.publishEntry` | `publish_entry` |
29
+ | `contentful.unpublishEntry` | `unpublish_entry` |
30
+ | `contentful.updateEntry` | `update_entry` |
31
+
32
+ ## Dependencies and retries
33
+
34
+ Raw implementations accept context first and keyword-only `deps`. The `.client`
35
+ module exports typed `Port`, `Deps`, and `Client` classes. Inject a fake port, or
36
+ pass a caller-owned `httpx.AsyncClient` to `Client`. Default dependencies close
37
+ their client after one operation. Credentials resolve at first use. Transport
38
+ retries are disabled; `retry.py` owns durable policies. DTO types are in `.types`.
39
+ Requires core >=0.1.1.
40
+
41
+ ## Environment
42
+
43
+ | Variable | Contract |
44
+ | --- | --- |
45
+ | `CONTENTFUL_SPACE_ID` | Required space. |
46
+ | `CONTENTFUL_ENVIRONMENT` | Optional environment; defaults to `master`. |
47
+ | `CONTENTFUL_CDA_TOKEN` | Required for delivery reads: getEntries/getAssets. |
48
+ | `CONTENTFUL_CMA_TOKEN` | Required for management reads and writes. |
49
+
50
+ ## Verification and limitations
51
+
52
+ Delivery and management credentials are separate. DTOs flatten sys metadata and
53
+ preserve localized fields. Versioned writes and creates disable durable retries;
54
+ delete treats 404 as already deleted. Asset creation processes each requested
55
+ locale and rereads its version, but does not wait for processing completion.
56
+ Collection results at or above 4 MiB are rejected. Each read is a single page.
57
+
58
+ Shared fixtures execute the actual pinned TS implementations and compare Python
59
+ outputs and requests. Sanity's SDK calls are translated into separately asserted
60
+ REST requests. These are hermetic checks, not real account or hosted verification.
61
+ See [the testing backlog](https://github.com/render-lab/render-tasks-python/issues/1)
62
+ and the root verification tracker. Licensed under MIT.
63
+
64
+ ## Webhook adapter
65
+
66
+ `render_lab_tasks_contentful.webhooks.contentful_adapter(on_event, ...)` returns a
67
+ registration-free adapter for `render_lab_triggers`. The callback receives the
68
+ verified event and returns `{"task": "namespace.task", "args": [event]}` or `None`.
69
+ The signing credential `CONTENTFUL_WEBHOOK_SECRET` is read inside verification unless
70
+ provided explicitly. Importing this module does not register tasks. Signature
71
+ checks use the raw body; live delivery remains in the testing backlog.
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27,<2"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "render-lab-tasks-contentful"
7
+ version = "0.1.0"
8
+ description = "contentful tasks for Render Workflows"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.12"
13
+ dependencies = ["render-lab-triggers>=0.1.0,<0.2", "render==1.0.1", "httpx>=0.28,<0.29", "render-lab-tasks-core>=0.1.1,<0.2"]
14
+
15
+ [project.urls]
16
+ Repository = "https://github.com/render-lab/render-tasks-python"
17
+
18
+ [tool.uv.sources]
19
+ render-lab-triggers = { workspace = true }
20
+ render-lab-tasks-core = { workspace = true }
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["src/render_lab_tasks_contentful"]
@@ -0,0 +1 @@
1
+ """Import .tasks explicitly to register durable tasks."""
@@ -0,0 +1,3 @@
1
+ from render import Workflows
2
+
3
+ app = Workflows()
@@ -0,0 +1,261 @@
1
+ """Injected vendor interface and one-attempt async HTTP adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import AsyncIterator, Mapping
7
+ from contextlib import asynccontextmanager
8
+ from dataclasses import dataclass
9
+ from typing import Any, Protocol, cast
10
+
11
+ import httpx
12
+ from render_lab_tasks_core.bounded import assert_json_limit
13
+ from render_lab_tasks_core.http import (
14
+ ApiError,
15
+ HttpClient,
16
+ enc,
17
+ js_string,
18
+ nullish,
19
+ required,
20
+ )
21
+
22
+ from . import types as t
23
+
24
+
25
+ def nonempty(value: str, message: str) -> None:
26
+ if not value.strip():
27
+ raise ValueError(message)
28
+
29
+
30
+ def obj(value: Any) -> dict[str, Any]:
31
+ return value if isinstance(value, dict) else {}
32
+
33
+
34
+ def number(value: Any, default: Any = None) -> Any:
35
+ return value if isinstance(value, (int, float)) and not isinstance(value, bool) else default
36
+
37
+
38
+ def document(value: Any, kind: str) -> dict[str, Any]:
39
+ row = obj(value)
40
+ sys = obj(row.get("sys"))
41
+ result = {
42
+ "id": nullish(sys.get("id"), ""),
43
+ "type": kind,
44
+ "version": nullish(sys.get("version"), nullish(sys.get("revision"), 0)),
45
+ "publishedVersion": number(sys.get("publishedVersion")),
46
+ "createdAt": sys.get("createdAt"),
47
+ "updatedAt": sys.get("updatedAt"),
48
+ "fields": obj(row.get("fields")),
49
+ }
50
+ if kind == "Entry":
51
+ result["contentType"] = obj(obj(sys.get("contentType")).get("sys")).get("id")
52
+ return result
53
+
54
+
55
+ def content_type(value: Any) -> dict[str, Any]:
56
+ row = obj(value)
57
+ fields = row.get("fields", [])
58
+ return {
59
+ "id": nullish(obj(row.get("sys")).get("id"), ""),
60
+ "name": row.get("name") if isinstance(row.get("name"), str) else "",
61
+ "displayField": row.get("displayField")
62
+ if isinstance(row.get("displayField"), str)
63
+ else None,
64
+ "fields": [
65
+ {
66
+ **{
67
+ k: f.get(k) if isinstance(f.get(k), str) else "" for k in ("id", "name", "type")
68
+ },
69
+ "required": f.get("required") is True,
70
+ "localized": f.get("localized") is True,
71
+ }
72
+ for f in map(obj, fields)
73
+ ]
74
+ if isinstance(fields, list)
75
+ else [],
76
+ }
77
+
78
+
79
+ class Port(Protocol):
80
+ async def create_asset(self, input: t.CreateAssetInput) -> t.AssetDTO: ...
81
+ async def create_entry(self, input: t.CreateEntryInput) -> t.EntryDTO: ...
82
+ async def delete_entry(self, input: t.DeleteEntryInput) -> t.DeleteEntryResult: ...
83
+ async def get_assets(self, input: t.GetAssetsInput) -> t.AssetCollection: ...
84
+ async def get_content_types(self, input: t.GetContentTypesInput) -> t.ContentTypeCollection: ...
85
+ async def get_entries(self, input: t.GetEntriesInput) -> t.EntryCollection: ...
86
+ async def get_entry(self, input: t.GetEntryInput) -> t.EntryDTO: ...
87
+ async def publish_asset(self, input: t.PublishAssetInput) -> t.AssetDTO: ...
88
+ async def publish_entry(self, input: t.PublishEntryInput) -> t.EntryDTO: ...
89
+ async def unpublish_entry(self, input: t.UnpublishEntryInput) -> t.EntryDTO: ...
90
+ async def update_entry(self, input: t.UpdateEntryInput) -> t.EntryDTO: ...
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class Deps:
95
+ contentful: Port
96
+
97
+
98
+ class Client:
99
+ def __init__(self, http: httpx.AsyncClient, env: Mapping[str, str] | None = None) -> None:
100
+ self.env = os.environ if env is None else env
101
+ self.http = http
102
+ self.cda = HttpClient(
103
+ http,
104
+ lambda: self._base("cdn.contentful.com"),
105
+ auth=lambda: self._auth(False),
106
+ label="Contentful CDA",
107
+ )
108
+ self.cma = HttpClient(
109
+ http,
110
+ lambda: self._base("api.contentful.com"),
111
+ auth=lambda: self._auth(True),
112
+ label="Contentful CMA",
113
+ )
114
+
115
+ def _base(self, host: str) -> str:
116
+ return (
117
+ "https://"
118
+ + host
119
+ + "/spaces/"
120
+ + enc(required(self.env, "CONTENTFUL_SPACE_ID"))
121
+ + "/environments/"
122
+ + enc(self.env.get("CONTENTFUL_ENVIRONMENT") or "master")
123
+ )
124
+
125
+ def _auth(self, management: bool) -> dict[str, str]:
126
+ result = {
127
+ "authorization": "Bearer "
128
+ + required(self.env, "CONTENTFUL_CMA_TOKEN" if management else "CONTENTFUL_CDA_TOKEN")
129
+ }
130
+ result["content-type" if management else "accept"] = (
131
+ "application/vnd.contentful.management.v1+json" if management else "application/json"
132
+ )
133
+ return result
134
+
135
+ async def _collection(
136
+ self, kind: str, input: Mapping[str, Any], management: bool = False
137
+ ) -> Any:
138
+ params = input.get("query", {})
139
+ suffix = "&".join(
140
+ enc(k) + "=" + enc(js_string(v)) for k, v in params.items() if v is not None
141
+ )
142
+ row = await (self.cma if management else self.cda).call(
143
+ "/" + kind + ("?" + suffix if suffix else "")
144
+ )
145
+ mapper = (
146
+ content_type
147
+ if kind == "content_types"
148
+ else lambda v: document(v, "Entry" if kind == "entries" else "Asset")
149
+ )
150
+ result = {key: number(row.get(key), 0) for key in ("total", "skip", "limit")}
151
+ result["items"] = (
152
+ [mapper(item) for item in row.get("items", [])]
153
+ if isinstance(row.get("items"), list)
154
+ else []
155
+ )
156
+ assert_json_limit("contentful." + kind, result, 4 * 1024 * 1024 - 1)
157
+ return result
158
+
159
+ async def get_entries(self, input: t.GetEntriesInput) -> t.EntryCollection:
160
+ return cast(t.EntryCollection, await self._collection("entries", input))
161
+
162
+ async def get_assets(self, input: t.GetAssetsInput) -> t.AssetCollection:
163
+ return cast(t.AssetCollection, await self._collection("assets", input))
164
+
165
+ async def get_content_types(self, input: t.GetContentTypesInput) -> t.ContentTypeCollection:
166
+ return cast(t.ContentTypeCollection, await self._collection("content_types", input, True))
167
+
168
+ async def get_entry(self, input: t.GetEntryInput) -> t.EntryDTO:
169
+ nonempty(input["id"], "contentful.getEntry requires a nonempty id.")
170
+ return cast(
171
+ t.EntryDTO, document(await self.cma.call("/entries/" + enc(input["id"])), "Entry")
172
+ )
173
+
174
+ async def create_entry(self, input: t.CreateEntryInput) -> t.EntryDTO:
175
+ nonempty(input["contentType"], "contentful.createEntry requires a contentType id.")
176
+ id = input.get("entryId")
177
+ row = await self.cma.call(
178
+ "/entries" + ("/" + enc(id) if id else ""),
179
+ method="PUT" if id else "POST",
180
+ headers={"x-contentful-content-type": input["contentType"]},
181
+ body={"fields": input["fields"]},
182
+ )
183
+ return cast(t.EntryDTO, document(row, "Entry"))
184
+
185
+ async def update_entry(self, input: t.UpdateEntryInput) -> t.EntryDTO:
186
+ nonempty(input["id"], "contentful.updateEntry requires a nonempty id.")
187
+ row = await self.cma.call(
188
+ "/entries/" + enc(input["id"]),
189
+ method="PUT",
190
+ headers={"x-contentful-version": js_string(input["version"])},
191
+ body={"fields": input["fields"]},
192
+ )
193
+ return cast(t.EntryDTO, document(row, "Entry"))
194
+
195
+ async def _publish(self, input: Mapping[str, Any], kind: str, method: str) -> Any:
196
+ nonempty(input["id"], "contentful publishing requires a nonempty id.")
197
+ row = await self.cma.call(
198
+ "/" + kind + "/" + enc(input["id"]) + "/published",
199
+ method=method,
200
+ headers={"x-contentful-version": js_string(input["version"])},
201
+ )
202
+ return document(row, "Entry" if kind == "entries" else "Asset")
203
+
204
+ async def publish_entry(self, input: t.PublishEntryInput) -> t.EntryDTO:
205
+ return cast(t.EntryDTO, await self._publish(input, "entries", "PUT"))
206
+
207
+ async def unpublish_entry(self, input: t.UnpublishEntryInput) -> t.EntryDTO:
208
+ return cast(t.EntryDTO, await self._publish(input, "entries", "DELETE"))
209
+
210
+ async def publish_asset(self, input: t.PublishAssetInput) -> t.AssetDTO:
211
+ return cast(t.AssetDTO, await self._publish(input, "assets", "PUT"))
212
+
213
+ async def delete_entry(self, input: t.DeleteEntryInput) -> t.DeleteEntryResult:
214
+ nonempty(input["id"], "contentful.deleteEntry requires a nonempty id.")
215
+ try:
216
+ await self.cma.call(
217
+ "/entries/" + enc(input["id"]), method="DELETE", response_type="text"
218
+ )
219
+ except ApiError as error:
220
+ if error.status != 404:
221
+ raise
222
+ return {"id": input["id"], "deleted": True}
223
+
224
+ async def create_asset(self, input: t.CreateAssetInput) -> t.AssetDTO:
225
+ file = input["fields"].get("file")
226
+ if not isinstance(file, dict):
227
+ raise ValueError(
228
+ "contentful.createAsset requires fields.file to be a locale-keyed object"
229
+ )
230
+ id = input.get("assetId")
231
+ row = await self.cma.call(
232
+ "/assets" + ("/" + enc(id) if id else ""),
233
+ method="PUT" if id else "POST",
234
+ body={"fields": input["fields"]},
235
+ )
236
+ for locale in input.get("processLocales", list(file)):
237
+ id = row["sys"]["id"]
238
+ await self.cma.call(
239
+ "/assets/" + enc(id) + "/files/" + enc(locale) + "/process",
240
+ method="PUT",
241
+ headers={"x-contentful-version": js_string(row["sys"].get("version", 0))},
242
+ response_type="text",
243
+ )
244
+ row = await self.cma.call("/assets/" + enc(id))
245
+ return cast(t.AssetDTO, document(row, "Asset"))
246
+
247
+
248
+ @asynccontextmanager
249
+ async def dependencies(deps: Deps | None) -> AsyncIterator[Deps]:
250
+ if deps is not None:
251
+ yield deps
252
+ else:
253
+ async with httpx.AsyncClient(
254
+ timeout=30, transport=httpx.AsyncHTTPTransport(retries=0)
255
+ ) as http:
256
+ yield Deps(contentful=Client(http))
257
+
258
+
259
+ ContentfulPort = Port
260
+ ContentfulClient = Client
261
+ ContentfulDeps = Deps
@@ -0,0 +1,18 @@
1
+ from render import TaskContext
2
+
3
+ from ._app import app
4
+ from .client import Deps, dependencies
5
+ from .retry import CONTENTFUL_NO_RETRY
6
+ from .types import AssetDTO, CreateAssetInput
7
+
8
+
9
+ async def create_asset_impl(
10
+ ctx: TaskContext, input: CreateAssetInput, *, deps: Deps | None = None
11
+ ) -> AssetDTO:
12
+ async with dependencies(deps) as resolved:
13
+ return await resolved.contentful.create_asset(input)
14
+
15
+
16
+ @app.task(name="contentful.createAsset", retry=CONTENTFUL_NO_RETRY)
17
+ async def create_asset(ctx: TaskContext, input: CreateAssetInput) -> AssetDTO:
18
+ return await create_asset_impl(ctx, input)
@@ -0,0 +1,18 @@
1
+ from render import TaskContext
2
+
3
+ from ._app import app
4
+ from .client import Deps, dependencies
5
+ from .retry import CONTENTFUL_NO_RETRY
6
+ from .types import CreateEntryInput, EntryDTO
7
+
8
+
9
+ async def create_entry_impl(
10
+ ctx: TaskContext, input: CreateEntryInput, *, deps: Deps | None = None
11
+ ) -> EntryDTO:
12
+ async with dependencies(deps) as resolved:
13
+ return await resolved.contentful.create_entry(input)
14
+
15
+
16
+ @app.task(name="contentful.createEntry", retry=CONTENTFUL_NO_RETRY)
17
+ async def create_entry(ctx: TaskContext, input: CreateEntryInput) -> EntryDTO:
18
+ return await create_entry_impl(ctx, input)
@@ -0,0 +1,18 @@
1
+ from render import TaskContext
2
+
3
+ from ._app import app
4
+ from .client import Deps, dependencies
5
+ from .retry import CONTENTFUL_RETRY
6
+ from .types import DeleteEntryInput, DeleteEntryResult
7
+
8
+
9
+ async def delete_entry_impl(
10
+ ctx: TaskContext, input: DeleteEntryInput, *, deps: Deps | None = None
11
+ ) -> DeleteEntryResult:
12
+ async with dependencies(deps) as resolved:
13
+ return await resolved.contentful.delete_entry(input)
14
+
15
+
16
+ @app.task(name="contentful.deleteEntry", retry=CONTENTFUL_RETRY)
17
+ async def delete_entry(ctx: TaskContext, input: DeleteEntryInput) -> DeleteEntryResult:
18
+ return await delete_entry_impl(ctx, input)
@@ -0,0 +1,18 @@
1
+ from render import TaskContext
2
+
3
+ from ._app import app
4
+ from .client import Deps, dependencies
5
+ from .retry import CONTENTFUL_RETRY
6
+ from .types import AssetCollection, GetAssetsInput
7
+
8
+
9
+ async def get_assets_impl(
10
+ ctx: TaskContext, input: GetAssetsInput, *, deps: Deps | None = None
11
+ ) -> AssetCollection:
12
+ async with dependencies(deps) as resolved:
13
+ return await resolved.contentful.get_assets(input)
14
+
15
+
16
+ @app.task(name="contentful.getAssets", retry=CONTENTFUL_RETRY)
17
+ async def get_assets(ctx: TaskContext, input: GetAssetsInput) -> AssetCollection:
18
+ return await get_assets_impl(ctx, input)
@@ -0,0 +1,18 @@
1
+ from render import TaskContext
2
+
3
+ from ._app import app
4
+ from .client import Deps, dependencies
5
+ from .retry import CONTENTFUL_RETRY
6
+ from .types import ContentTypeCollection, GetContentTypesInput
7
+
8
+
9
+ async def get_content_types_impl(
10
+ ctx: TaskContext, input: GetContentTypesInput, *, deps: Deps | None = None
11
+ ) -> ContentTypeCollection:
12
+ async with dependencies(deps) as resolved:
13
+ return await resolved.contentful.get_content_types(input)
14
+
15
+
16
+ @app.task(name="contentful.getContentTypes", retry=CONTENTFUL_RETRY)
17
+ async def get_content_types(ctx: TaskContext, input: GetContentTypesInput) -> ContentTypeCollection:
18
+ return await get_content_types_impl(ctx, input)
@@ -0,0 +1,18 @@
1
+ from render import TaskContext
2
+
3
+ from ._app import app
4
+ from .client import Deps, dependencies
5
+ from .retry import CONTENTFUL_RETRY
6
+ from .types import EntryCollection, GetEntriesInput
7
+
8
+
9
+ async def get_entries_impl(
10
+ ctx: TaskContext, input: GetEntriesInput, *, deps: Deps | None = None
11
+ ) -> EntryCollection:
12
+ async with dependencies(deps) as resolved:
13
+ return await resolved.contentful.get_entries(input)
14
+
15
+
16
+ @app.task(name="contentful.getEntries", retry=CONTENTFUL_RETRY)
17
+ async def get_entries(ctx: TaskContext, input: GetEntriesInput) -> EntryCollection:
18
+ return await get_entries_impl(ctx, input)
@@ -0,0 +1,18 @@
1
+ from render import TaskContext
2
+
3
+ from ._app import app
4
+ from .client import Deps, dependencies
5
+ from .retry import CONTENTFUL_RETRY
6
+ from .types import EntryDTO, GetEntryInput
7
+
8
+
9
+ async def get_entry_impl(
10
+ ctx: TaskContext, input: GetEntryInput, *, deps: Deps | None = None
11
+ ) -> EntryDTO:
12
+ async with dependencies(deps) as resolved:
13
+ return await resolved.contentful.get_entry(input)
14
+
15
+
16
+ @app.task(name="contentful.getEntry", retry=CONTENTFUL_RETRY)
17
+ async def get_entry(ctx: TaskContext, input: GetEntryInput) -> EntryDTO:
18
+ return await get_entry_impl(ctx, input)
@@ -0,0 +1,18 @@
1
+ from render import TaskContext
2
+
3
+ from ._app import app
4
+ from .client import Deps, dependencies
5
+ from .retry import CONTENTFUL_NO_RETRY
6
+ from .types import AssetDTO, PublishAssetInput
7
+
8
+
9
+ async def publish_asset_impl(
10
+ ctx: TaskContext, input: PublishAssetInput, *, deps: Deps | None = None
11
+ ) -> AssetDTO:
12
+ async with dependencies(deps) as resolved:
13
+ return await resolved.contentful.publish_asset(input)
14
+
15
+
16
+ @app.task(name="contentful.publishAsset", retry=CONTENTFUL_NO_RETRY)
17
+ async def publish_asset(ctx: TaskContext, input: PublishAssetInput) -> AssetDTO:
18
+ return await publish_asset_impl(ctx, input)
@@ -0,0 +1,18 @@
1
+ from render import TaskContext
2
+
3
+ from ._app import app
4
+ from .client import Deps, dependencies
5
+ from .retry import CONTENTFUL_NO_RETRY
6
+ from .types import EntryDTO, PublishEntryInput
7
+
8
+
9
+ async def publish_entry_impl(
10
+ ctx: TaskContext, input: PublishEntryInput, *, deps: Deps | None = None
11
+ ) -> EntryDTO:
12
+ async with dependencies(deps) as resolved:
13
+ return await resolved.contentful.publish_entry(input)
14
+
15
+
16
+ @app.task(name="contentful.publishEntry", retry=CONTENTFUL_NO_RETRY)
17
+ async def publish_entry(ctx: TaskContext, input: PublishEntryInput) -> EntryDTO:
18
+ return await publish_entry_impl(ctx, input)
@@ -0,0 +1,4 @@
1
+ from render import Retry
2
+
3
+ CONTENTFUL_NO_RETRY = Retry(max_retries=0, wait_duration_ms=0, backoff_scaling=1)
4
+ CONTENTFUL_RETRY = Retry(max_retries=3, wait_duration_ms=1_000, backoff_scaling=2)
@@ -0,0 +1,38 @@
1
+ from ._app import app
2
+ from .create_asset import create_asset, create_asset_impl
3
+ from .create_entry import create_entry, create_entry_impl
4
+ from .delete_entry import delete_entry, delete_entry_impl
5
+ from .get_assets import get_assets, get_assets_impl
6
+ from .get_content_types import get_content_types, get_content_types_impl
7
+ from .get_entries import get_entries, get_entries_impl
8
+ from .get_entry import get_entry, get_entry_impl
9
+ from .publish_asset import publish_asset, publish_asset_impl
10
+ from .publish_entry import publish_entry, publish_entry_impl
11
+ from .unpublish_entry import unpublish_entry, unpublish_entry_impl
12
+ from .update_entry import update_entry, update_entry_impl
13
+
14
+ __all__ = [
15
+ "app",
16
+ "create_asset",
17
+ "create_asset_impl",
18
+ "create_entry",
19
+ "create_entry_impl",
20
+ "delete_entry",
21
+ "delete_entry_impl",
22
+ "get_assets",
23
+ "get_assets_impl",
24
+ "get_content_types",
25
+ "get_content_types_impl",
26
+ "get_entries",
27
+ "get_entries_impl",
28
+ "get_entry",
29
+ "get_entry_impl",
30
+ "publish_asset",
31
+ "publish_asset_impl",
32
+ "publish_entry",
33
+ "publish_entry_impl",
34
+ "unpublish_entry",
35
+ "unpublish_entry_impl",
36
+ "update_entry",
37
+ "update_entry_impl",
38
+ ]
@@ -0,0 +1,129 @@
1
+ """JSON contracts ported from the pinned TypeScript pack."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal, NotRequired, TypedDict
6
+
7
+
8
+ class ContentTypeDTOFieldsItem(TypedDict):
9
+ id: str
10
+ name: str
11
+ type: str
12
+ required: bool
13
+ localized: bool
14
+
15
+
16
+ type JsonPrimitive = str | float | bool | None
17
+
18
+ type JsonValue = JsonPrimitive | list[JsonValue] | dict[str, JsonValue]
19
+
20
+ type JsonObject = dict[str, JsonValue]
21
+
22
+ type ContentfulQuery = dict[str, str | float | bool | list[str | float]]
23
+
24
+
25
+ class EntryDTO(TypedDict):
26
+ id: str
27
+ type: Literal["Entry"]
28
+ contentType: str | None
29
+ version: float
30
+ publishedVersion: float | None
31
+ createdAt: str | None
32
+ updatedAt: str | None
33
+ fields: JsonObject
34
+
35
+
36
+ class AssetDTO(TypedDict):
37
+ id: str
38
+ type: Literal["Asset"]
39
+ version: float
40
+ publishedVersion: float | None
41
+ createdAt: str | None
42
+ updatedAt: str | None
43
+ fields: JsonObject
44
+
45
+
46
+ class ContentTypeDTO(TypedDict):
47
+ id: str
48
+ name: str
49
+ displayField: str | None
50
+ fields: list[ContentTypeDTOFieldsItem]
51
+
52
+
53
+ class EntryCollection(TypedDict):
54
+ items: list[EntryDTO]
55
+ total: float
56
+ skip: float
57
+ limit: float
58
+
59
+
60
+ class AssetCollection(TypedDict):
61
+ items: list[AssetDTO]
62
+ total: float
63
+ skip: float
64
+ limit: float
65
+
66
+
67
+ class ContentTypeCollection(TypedDict):
68
+ items: list[ContentTypeDTO]
69
+ total: float
70
+ skip: float
71
+ limit: float
72
+
73
+
74
+ class GetEntriesInput(TypedDict):
75
+ query: NotRequired[ContentfulQuery]
76
+
77
+
78
+ class GetEntryInput(TypedDict):
79
+ id: str
80
+
81
+
82
+ class CreateEntryInput(TypedDict):
83
+ contentType: str
84
+ fields: JsonObject
85
+ entryId: NotRequired[str]
86
+
87
+
88
+ class UpdateEntryInput(TypedDict):
89
+ id: str
90
+ version: float
91
+ fields: JsonObject
92
+
93
+
94
+ class PublishEntryInput(TypedDict):
95
+ id: str
96
+ version: float
97
+
98
+
99
+ class UnpublishEntryInput(TypedDict):
100
+ id: str
101
+ version: float
102
+
103
+
104
+ class DeleteEntryInput(TypedDict):
105
+ id: str
106
+
107
+
108
+ class GetAssetsInput(TypedDict):
109
+ query: NotRequired[ContentfulQuery]
110
+
111
+
112
+ class CreateAssetInput(TypedDict):
113
+ fields: JsonObject
114
+ assetId: NotRequired[str]
115
+ processLocales: NotRequired[list[str]]
116
+
117
+
118
+ class PublishAssetInput(TypedDict):
119
+ id: str
120
+ version: float
121
+
122
+
123
+ class GetContentTypesInput(TypedDict):
124
+ query: NotRequired[ContentfulQuery]
125
+
126
+
127
+ class DeleteEntryResult(TypedDict):
128
+ id: str
129
+ deleted: Literal[True]
@@ -0,0 +1,18 @@
1
+ from render import TaskContext
2
+
3
+ from ._app import app
4
+ from .client import Deps, dependencies
5
+ from .retry import CONTENTFUL_NO_RETRY
6
+ from .types import EntryDTO, UnpublishEntryInput
7
+
8
+
9
+ async def unpublish_entry_impl(
10
+ ctx: TaskContext, input: UnpublishEntryInput, *, deps: Deps | None = None
11
+ ) -> EntryDTO:
12
+ async with dependencies(deps) as resolved:
13
+ return await resolved.contentful.unpublish_entry(input)
14
+
15
+
16
+ @app.task(name="contentful.unpublishEntry", retry=CONTENTFUL_NO_RETRY)
17
+ async def unpublish_entry(ctx: TaskContext, input: UnpublishEntryInput) -> EntryDTO:
18
+ return await unpublish_entry_impl(ctx, input)
@@ -0,0 +1,18 @@
1
+ from render import TaskContext
2
+
3
+ from ._app import app
4
+ from .client import Deps, dependencies
5
+ from .retry import CONTENTFUL_NO_RETRY
6
+ from .types import EntryDTO, UpdateEntryInput
7
+
8
+
9
+ async def update_entry_impl(
10
+ ctx: TaskContext, input: UpdateEntryInput, *, deps: Deps | None = None
11
+ ) -> EntryDTO:
12
+ async with dependencies(deps) as resolved:
13
+ return await resolved.contentful.update_entry(input)
14
+
15
+
16
+ @app.task(name="contentful.updateEntry", retry=CONTENTFUL_NO_RETRY)
17
+ async def update_entry(ctx: TaskContext, input: UpdateEntryInput) -> EntryDTO:
18
+ return await update_entry_impl(ctx, input)
@@ -0,0 +1,89 @@
1
+ """Registration-free contentful webhook verification and event mapping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import time
8
+ from collections.abc import Callable
9
+ from typing import TypedDict
10
+ from urllib.parse import quote
11
+
12
+ from render_lab_tasks_core.signatures import digest, safe_equal
13
+ from render_lab_triggers.types import Adapter, Json, WebhookContext, WebhookDispatch, WebhookRequest
14
+
15
+ type OnEvent = Callable[[dict[str, Json]], WebhookDispatch | None]
16
+
17
+
18
+ class CanonicalRequest(TypedDict):
19
+ method: str
20
+ path: str
21
+ headers: dict[str, str]
22
+ body: str
23
+
24
+
25
+ def compute_contentful_signature(
26
+ secret: str, request: CanonicalRequest, signed_header_names: list[str]
27
+ ) -> str:
28
+ normalized = {k.lower().strip(): v.strip() for k, v in request["headers"].items()}
29
+ picked = {
30
+ key: normalized[key]
31
+ for name in signed_header_names
32
+ if (key := name.lower().strip()) in normalized
33
+ }
34
+ picked["x-contentful-signed-headers"] = ",".join(sorted(picked))
35
+ headers = ";".join(f"{key}:{value}" for key, value in sorted(picked.items()))
36
+ parts = request["path"].split("?")
37
+ query = quote(parts[1], safe="~!*'()") if len(parts) > 1 and parts[1] else ""
38
+ path = parts[0] + ("?" + query if query else "")
39
+ path = quote(path, safe=";/?:@&=+$,#~!*'()-._")
40
+ return digest(secret, "\n".join([request["method"], path, headers, request["body"]]))
41
+
42
+
43
+ def verify_contentful_request(
44
+ secret: str,
45
+ request: CanonicalRequest,
46
+ ttl_seconds: float = 30,
47
+ now: Callable[[], float] | None = None,
48
+ ) -> bool:
49
+ headers = {k.lower().strip(): v.strip() for k, v in request["headers"].items()}
50
+ signature = headers.get("x-contentful-signature")
51
+ names = [name for name in headers.get("x-contentful-signed-headers", "").split(",") if name]
52
+ timestamp = re.match(r"^[+-]?\d+", headers.get("x-contentful-timestamp", ""))
53
+ if not signature or not names or timestamp is None:
54
+ return False
55
+ now_ms = (now or (lambda: time.time() * 1000))()
56
+ if ttl_seconds != 0 and now_ms - int(timestamp[0]) >= ttl_seconds * 1000:
57
+ return False
58
+ return safe_equal(signature, compute_contentful_signature(secret, request, names))
59
+
60
+
61
+ def contentful_adapter(
62
+ on_event: OnEvent,
63
+ *,
64
+ path: str,
65
+ secret: str | None = None,
66
+ method: str = "POST",
67
+ ttl_seconds: float = 30,
68
+ now: Callable[[], float] | None = None,
69
+ ) -> Adapter:
70
+ def verify(req: WebhookRequest) -> bool:
71
+ key = secret if secret is not None else os.getenv("CONTENTFUL_WEBHOOK_SECRET")
72
+ return bool(key) and verify_contentful_request(
73
+ key or "",
74
+ {"method": method, "path": path, "headers": req["headers"], "body": req["rawBody"]},
75
+ ttl_seconds,
76
+ now,
77
+ )
78
+
79
+ def map_event(ctx: WebhookContext) -> WebhookDispatch | None:
80
+ payload = ctx["body"] if ctx["body"] is not None else {}
81
+ headers = ctx["headers"]
82
+ return on_event(
83
+ {
84
+ "topic": headers.get("x-contentful-topic", headers.get("X-Contentful-Topic", "")),
85
+ "payload": payload,
86
+ }
87
+ )
88
+
89
+ return Adapter(verify, map_event)