render-lab-tasks-cloudflare 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 (21) hide show
  1. render_lab_tasks_cloudflare-0.1.0/.gitignore +11 -0
  2. render_lab_tasks_cloudflare-0.1.0/LICENSE +21 -0
  3. render_lab_tasks_cloudflare-0.1.0/PKG-INFO +69 -0
  4. render_lab_tasks_cloudflare-0.1.0/README.md +56 -0
  5. render_lab_tasks_cloudflare-0.1.0/pyproject.toml +22 -0
  6. render_lab_tasks_cloudflare-0.1.0/src/render_lab_tasks_cloudflare/__init__.py +1 -0
  7. render_lab_tasks_cloudflare-0.1.0/src/render_lab_tasks_cloudflare/_app.py +3 -0
  8. render_lab_tasks_cloudflare-0.1.0/src/render_lab_tasks_cloudflare/client.py +180 -0
  9. render_lab_tasks_cloudflare-0.1.0/src/render_lab_tasks_cloudflare/delete_dns_record.py +18 -0
  10. render_lab_tasks_cloudflare-0.1.0/src/render_lab_tasks_cloudflare/deploy_worker.py +18 -0
  11. render_lab_tasks_cloudflare-0.1.0/src/render_lab_tasks_cloudflare/dns_upsert.py +18 -0
  12. render_lab_tasks_cloudflare-0.1.0/src/render_lab_tasks_cloudflare/list_dns_records.py +18 -0
  13. render_lab_tasks_cloudflare-0.1.0/src/render_lab_tasks_cloudflare/purge_cache.py +18 -0
  14. render_lab_tasks_cloudflare-0.1.0/src/render_lab_tasks_cloudflare/py.typed +0 -0
  15. render_lab_tasks_cloudflare-0.1.0/src/render_lab_tasks_cloudflare/retry.py +3 -0
  16. render_lab_tasks_cloudflare-0.1.0/src/render_lab_tasks_cloudflare/tasks.py +26 -0
  17. render_lab_tasks_cloudflare-0.1.0/src/render_lab_tasks_cloudflare/types.py +88 -0
  18. render_lab_tasks_cloudflare-0.1.0/src/render_lab_tasks_cloudflare/workers_kv_get.py +18 -0
  19. render_lab_tasks_cloudflare-0.1.0/src/render_lab_tasks_cloudflare/workers_kv_put.py +18 -0
  20. render_lab_tasks_cloudflare-0.1.0/tests/test_contracts.py +8 -0
  21. render_lab_tasks_cloudflare-0.1.0/tests/test_upload.py +54 -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,69 @@
1
+ Metadata-Version: 2.5
2
+ Name: render-lab-tasks-cloudflare
3
+ Version: 0.1.0
4
+ Summary: cloudflare 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.0
11
+ Requires-Dist: render==1.0.1
12
+ Description-Content-Type: text/markdown
13
+
14
+ # render-lab-tasks-cloudflare
15
+
16
+ Python port of all 7 registered tasks in the pinned TypeScript pack at
17
+ `45f9c2d44bd28e01ae9813e0d2ff56ee6c533816`. Python 3.12+.
18
+
19
+ From the repository, install this pack and its supporting core together:
20
+
21
+ ```sh
22
+ pip install render-lab-tasks-cloudflare==0.1.0
23
+ ```
24
+
25
+ Import task definitions from `render_lab_tasks_cloudflare.tasks`. Combine its `app` with
26
+ other pack apps using `Workflows.from_workflows(...)` and call tasks with
27
+ `await ctx.run(task, input)`. Package roots do not register tasks. JSON fields
28
+ retain the TS spelling; Python functions use snake_case.
29
+
30
+ ## Tasks
31
+
32
+ | Registered name | Python export (also exposes `_impl`) |
33
+ | --- | --- |
34
+ | `cloudflare.deleteDnsRecord` | `delete_dns_record` |
35
+ | `cloudflare.deployWorker` | `deploy_worker` |
36
+ | `cloudflare.dnsUpsert` | `dns_upsert` |
37
+ | `cloudflare.listDnsRecords` | `list_dns_records` |
38
+ | `cloudflare.purgeCache` | `purge_cache` |
39
+ | `cloudflare.workersKvGet` | `workers_kv_get` |
40
+ | `cloudflare.workersKvPut` | `workers_kv_put` |
41
+
42
+ ## Dependencies and retries
43
+
44
+ Raw implementations accept context first and keyword-only `deps`. The `.client`
45
+ module exports typed `Port`, `Deps`, and `Client` classes (also vendor-named aliases).
46
+ Inject a fake port, or pass a caller-owned `httpx.AsyncClient` to `Client` for custom
47
+ transport/configuration. Default dependencies close their HTTP clients after the
48
+ operation. Credentials resolve at first use. HTTP and SDK retries are disabled;
49
+ `retry.py` owns durable retry settings. DTO types live in `.types`.
50
+
51
+ ## Environment
52
+
53
+ | Variable | Contract |
54
+ | --- | --- |
55
+ | `CLOUDFLARE_API_TOKEN` | Required on API calls. |
56
+ | `CLOUDFLARE_ZONE_ID` | Default zone when zoneId is omitted. |
57
+ | `CLOUDFLARE_ACCOUNT_ID` | Default account when accountId is omitted. |
58
+ | `CLOUDFLARE_KV_NAMESPACE_ID` | Default KV namespace when namespaceId is omitted. |
59
+
60
+ ## Verification and limitations
61
+
62
+ DNS upsert is a read-then-write operation and is not atomic. Worker upload sends a multipart ES module (`worker.js`) plus JSON metadata, correcting the pinned TS request (ADR-0007). Supply a single JavaScript ES module in `script`. Bindings, assets, routes, and compatibility dates are outside this input surface; Cloudflare applies its default compatibility date. Live Worker validation remains pending. KV GET returns null on 404 and preserves empty text values.
63
+
64
+ Shared JSON fixtures compare the Python task output with the actual pinned TS
65
+ implementation. REST fixtures also compare HTTP requests; HubSpot and Linear
66
+ fixtures compare source SDK arguments with HTTP payloads/GraphQL variables.
67
+ No live vendor calls were made for this batch. Registered-task parity does not
68
+ claim live readiness or all supporting TS exports. See the root verification
69
+ tracker and docs/porting-batches.md. Licensed under MIT.
@@ -0,0 +1,56 @@
1
+ # render-lab-tasks-cloudflare
2
+
3
+ Python port of all 7 registered tasks in the pinned TypeScript pack at
4
+ `45f9c2d44bd28e01ae9813e0d2ff56ee6c533816`. Python 3.12+.
5
+
6
+ From the repository, install this pack and its supporting core together:
7
+
8
+ ```sh
9
+ pip install render-lab-tasks-cloudflare==0.1.0
10
+ ```
11
+
12
+ Import task definitions from `render_lab_tasks_cloudflare.tasks`. Combine its `app` with
13
+ other pack apps using `Workflows.from_workflows(...)` and call tasks with
14
+ `await ctx.run(task, input)`. Package roots do not register tasks. JSON fields
15
+ retain the TS spelling; Python functions use snake_case.
16
+
17
+ ## Tasks
18
+
19
+ | Registered name | Python export (also exposes `_impl`) |
20
+ | --- | --- |
21
+ | `cloudflare.deleteDnsRecord` | `delete_dns_record` |
22
+ | `cloudflare.deployWorker` | `deploy_worker` |
23
+ | `cloudflare.dnsUpsert` | `dns_upsert` |
24
+ | `cloudflare.listDnsRecords` | `list_dns_records` |
25
+ | `cloudflare.purgeCache` | `purge_cache` |
26
+ | `cloudflare.workersKvGet` | `workers_kv_get` |
27
+ | `cloudflare.workersKvPut` | `workers_kv_put` |
28
+
29
+ ## Dependencies and retries
30
+
31
+ Raw implementations accept context first and keyword-only `deps`. The `.client`
32
+ module exports typed `Port`, `Deps`, and `Client` classes (also vendor-named aliases).
33
+ Inject a fake port, or pass a caller-owned `httpx.AsyncClient` to `Client` for custom
34
+ transport/configuration. Default dependencies close their HTTP clients after the
35
+ operation. Credentials resolve at first use. HTTP and SDK retries are disabled;
36
+ `retry.py` owns durable retry settings. DTO types live in `.types`.
37
+
38
+ ## Environment
39
+
40
+ | Variable | Contract |
41
+ | --- | --- |
42
+ | `CLOUDFLARE_API_TOKEN` | Required on API calls. |
43
+ | `CLOUDFLARE_ZONE_ID` | Default zone when zoneId is omitted. |
44
+ | `CLOUDFLARE_ACCOUNT_ID` | Default account when accountId is omitted. |
45
+ | `CLOUDFLARE_KV_NAMESPACE_ID` | Default KV namespace when namespaceId is omitted. |
46
+
47
+ ## Verification and limitations
48
+
49
+ DNS upsert is a read-then-write operation and is not atomic. Worker upload sends a multipart ES module (`worker.js`) plus JSON metadata, correcting the pinned TS request (ADR-0007). Supply a single JavaScript ES module in `script`. Bindings, assets, routes, and compatibility dates are outside this input surface; Cloudflare applies its default compatibility date. Live Worker validation remains pending. KV GET returns null on 404 and preserves empty text values.
50
+
51
+ Shared JSON fixtures compare the Python task output with the actual pinned TS
52
+ implementation. REST fixtures also compare HTTP requests; HubSpot and Linear
53
+ fixtures compare source SDK arguments with HTTP payloads/GraphQL variables.
54
+ No live vendor calls were made for this batch. Registered-task parity does not
55
+ claim live readiness or all supporting TS exports. See the root verification
56
+ tracker and docs/porting-batches.md. Licensed under MIT.
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27,<2"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "render-lab-tasks-cloudflare"
7
+ version = "0.1.0"
8
+ description = "cloudflare tasks for Render Workflows"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.12"
13
+ dependencies = ["render==1.0.1", "httpx>=0.28,<0.29", "render-lab-tasks-core>=0.1.0,<0.2"]
14
+
15
+ [project.urls]
16
+ Repository = "https://github.com/render-lab/render-tasks-python"
17
+
18
+ [tool.uv.sources]
19
+ render-lab-tasks-core = { workspace = true }
20
+
21
+ [tool.hatch.build.targets.wheel]
22
+ packages = ["src/render_lab_tasks_cloudflare"]
@@ -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,180 @@
1
+ """Injected vendor interface and one-attempt async HTTP adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from collections.abc import AsyncIterator, Mapping
8
+ from contextlib import asynccontextmanager
9
+ from dataclasses import dataclass
10
+ from typing import Any, Protocol
11
+
12
+ import httpx
13
+ from render_lab_tasks_core.http import ApiError, HttpClient, enc, nullish, pick, query, required
14
+
15
+ from . import types as t
16
+
17
+
18
+ class Port(Protocol):
19
+ async def delete_dns_record(self, input: t.DeleteDnsRecordInput) -> t.DeleteDnsRecordResult: ...
20
+ async def deploy_worker(self, input: t.DeployWorkerInput) -> t.DeployWorkerResult: ...
21
+ async def dns_upsert(self, input: t.DnsUpsertInput) -> t.DnsUpsertResult: ...
22
+ async def list_dns_records(self, input: t.ListDnsRecordsInput) -> list[t.DnsRecord]: ...
23
+ async def purge_cache(self, input: t.PurgeCacheInput) -> t.PurgeCacheResult: ...
24
+ async def workers_kv_get(self, input: t.WorkersKvGetInput) -> t.WorkersKvGetResult: ...
25
+ async def workers_kv_put(self, input: t.WorkersKvPutInput) -> t.WorkersKvPutResult: ...
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Deps:
30
+ cloudflare: Port
31
+
32
+
33
+ class Client:
34
+ def __init__(self, http: httpx.AsyncClient, env: Mapping[str, str] | None = None) -> None:
35
+ self.env = os.environ if env is None else env
36
+ self.http = HttpClient(
37
+ http,
38
+ "https://api.cloudflare.com/client/v4",
39
+ auth=lambda: {"authorization": "Bearer " + required(self.env, "CLOUDFLARE_API_TOKEN")},
40
+ label="Cloudflare API",
41
+ )
42
+
43
+ def _id(self, input: Mapping[str, Any], field: str, env: str) -> str:
44
+ value = nullish(input.get(field), self.env.get(env))
45
+ if not value:
46
+ raise ValueError(f"A {field} is required: pass {{ {field} }} or set {env}.")
47
+ return str(value)
48
+
49
+ def _zone(self, input: Mapping[str, Any]) -> str:
50
+ return self._id(input, "zoneId", "CLOUDFLARE_ZONE_ID")
51
+
52
+ def _kv(self, input: Mapping[str, Any]) -> str:
53
+ account = self._id(input, "accountId", "CLOUDFLARE_ACCOUNT_ID")
54
+ namespace = self._id(input, "namespaceId", "CLOUDFLARE_KV_NAMESPACE_ID")
55
+ return f"/accounts/{account}/storage/kv/namespaces/{namespace}/values/{enc(input['key'])}"
56
+
57
+ async def _call(self, path: str, **kwargs: Any) -> Any:
58
+ data = await self.http.call(path, **kwargs)
59
+ if isinstance(data, dict) and data.get("success") is False:
60
+ detail = "; ".join(
61
+ nullish(e.get("message"), "") for e in data.get("errors", []) if isinstance(e, dict)
62
+ )
63
+ raise RuntimeError(f"Cloudflare API {path} failed: {detail}".strip())
64
+ return data
65
+
66
+ async def purge_cache(self, input: t.PurgeCacheInput) -> Any:
67
+ zone = self._zone(input)
68
+ body = (
69
+ {"files": input["files"]}
70
+ if input.get("files")
71
+ else {"purge_everything": nullish(input.get("everything"), True)}
72
+ )
73
+ await self._call(f"/zones/{zone}/purge_cache", method="POST", body=body)
74
+ return {"zoneId": zone, "purged": True}
75
+
76
+ async def dns_upsert(self, input: t.DnsUpsertInput) -> Any:
77
+ path = f"/zones/{self._zone(input)}/dns_records"
78
+ existing = await self._call(path + "?" + query(pick(input, "type", "name")))
79
+ rows = (existing or {}).get("result")
80
+ found = rows[0] if isinstance(rows, list) and rows else None
81
+ body = {
82
+ **pick(input, "type", "name", "content"),
83
+ "ttl": nullish(input.get("ttl"), 1),
84
+ "proxied": nullish(input.get("proxied"), False),
85
+ }
86
+ data = await self._call(
87
+ path + (f"/{found['id']}" if found is not None else ""),
88
+ method="PUT" if found is not None else "POST",
89
+ body=body,
90
+ )
91
+ result = (data or {}).get("result") or {}
92
+ return {
93
+ "recordId": nullish(result.get("id"), ""),
94
+ "name": nullish(result.get("name"), input["name"]),
95
+ }
96
+
97
+ async def deploy_worker(self, input: t.DeployWorkerInput) -> Any:
98
+ account = self._id(input, "accountId", "CLOUDFLARE_ACCOUNT_ID")
99
+ path = f"/accounts/{account}/workers/scripts/{enc(input['scriptName'])}"
100
+ # Use HTTPX's multipart encoder; only the script content comes from the caller.
101
+ upload = httpx.Request(
102
+ "PUT",
103
+ "https://api.cloudflare.com/client/v4" + path,
104
+ files={
105
+ "metadata": (
106
+ None,
107
+ json.dumps({"main_module": "worker.js"}),
108
+ "application/json",
109
+ ),
110
+ "worker.js": ("worker.js", input["script"], "application/javascript+module"),
111
+ },
112
+ )
113
+ await self._call(
114
+ path,
115
+ method="PUT",
116
+ raw_body=upload.read().decode("utf-8"),
117
+ headers={"content-type": upload.headers["content-type"]},
118
+ )
119
+ return {"scriptName": input["scriptName"], "deployed": True}
120
+
121
+ async def list_dns_records(self, input: t.ListDnsRecordsInput) -> Any:
122
+ params = query({k: input[k] for k in ("type", "name") if input.get(k)})
123
+ data = await self._call(
124
+ f"/zones/{self._zone(input)}/dns_records" + ("?" + params if params else "")
125
+ )
126
+ rows = (data or {}).get("result")
127
+ return (
128
+ [
129
+ {
130
+ **{k: nullish(r.get(k), "") for k in ("id", "type", "name", "content")},
131
+ "proxied": nullish(r.get("proxied"), False),
132
+ }
133
+ for r in rows
134
+ ]
135
+ if isinstance(rows, list)
136
+ else []
137
+ )
138
+
139
+ async def delete_dns_record(self, input: t.DeleteDnsRecordInput) -> Any:
140
+ data = await self._call(
141
+ f"/zones/{self._zone(input)}/dns_records/{input['recordId']}", method="DELETE"
142
+ )
143
+ return {
144
+ "id": nullish(((data or {}).get("result") or {}).get("id"), input["recordId"]),
145
+ "deleted": True,
146
+ }
147
+
148
+ async def workers_kv_put(self, input: t.WorkersKvPutInput) -> Any:
149
+ await self._call(
150
+ self._kv(input),
151
+ method="PUT",
152
+ raw_body=input["value"],
153
+ headers={"content-type": "text/plain"},
154
+ )
155
+ return {"key": input["key"], "stored": True}
156
+
157
+ async def workers_kv_get(self, input: t.WorkersKvGetInput) -> Any:
158
+ try:
159
+ value = await self.http.call(self._kv(input), response_type="text")
160
+ except ApiError as error:
161
+ if error.status != 404:
162
+ raise
163
+ value = None
164
+ return {"key": input["key"], "value": value}
165
+
166
+
167
+ @asynccontextmanager
168
+ async def dependencies(deps: Deps | None) -> AsyncIterator[Deps]:
169
+ if deps is not None:
170
+ yield deps
171
+ else:
172
+ async with httpx.AsyncClient(
173
+ timeout=30, transport=httpx.AsyncHTTPTransport(retries=0)
174
+ ) as http:
175
+ yield Deps(cloudflare=Client(http))
176
+
177
+
178
+ CloudflarePort = Port
179
+ CloudflareClient = Client
180
+ CloudflareDeps = 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 CLOUDFLARE_RETRY
6
+ from .types import DeleteDnsRecordInput, DeleteDnsRecordResult
7
+
8
+
9
+ async def delete_dns_record_impl(
10
+ ctx: TaskContext, input: DeleteDnsRecordInput, *, deps: Deps | None = None
11
+ ) -> DeleteDnsRecordResult:
12
+ async with dependencies(deps) as resolved:
13
+ return await resolved.cloudflare.delete_dns_record(input)
14
+
15
+
16
+ @app.task(name="cloudflare.deleteDnsRecord", retry=CLOUDFLARE_RETRY)
17
+ async def delete_dns_record(ctx: TaskContext, input: DeleteDnsRecordInput) -> DeleteDnsRecordResult:
18
+ return await delete_dns_record_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 CLOUDFLARE_RETRY
6
+ from .types import DeployWorkerInput, DeployWorkerResult
7
+
8
+
9
+ async def deploy_worker_impl(
10
+ ctx: TaskContext, input: DeployWorkerInput, *, deps: Deps | None = None
11
+ ) -> DeployWorkerResult:
12
+ async with dependencies(deps) as resolved:
13
+ return await resolved.cloudflare.deploy_worker(input)
14
+
15
+
16
+ @app.task(name="cloudflare.deployWorker", retry=CLOUDFLARE_RETRY)
17
+ async def deploy_worker(ctx: TaskContext, input: DeployWorkerInput) -> DeployWorkerResult:
18
+ return await deploy_worker_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 CLOUDFLARE_RETRY
6
+ from .types import DnsUpsertInput, DnsUpsertResult
7
+
8
+
9
+ async def dns_upsert_impl(
10
+ ctx: TaskContext, input: DnsUpsertInput, *, deps: Deps | None = None
11
+ ) -> DnsUpsertResult:
12
+ async with dependencies(deps) as resolved:
13
+ return await resolved.cloudflare.dns_upsert(input)
14
+
15
+
16
+ @app.task(name="cloudflare.dnsUpsert", retry=CLOUDFLARE_RETRY)
17
+ async def dns_upsert(ctx: TaskContext, input: DnsUpsertInput) -> DnsUpsertResult:
18
+ return await dns_upsert_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 CLOUDFLARE_RETRY
6
+ from .types import DnsRecord, ListDnsRecordsInput
7
+
8
+
9
+ async def list_dns_records_impl(
10
+ ctx: TaskContext, input: ListDnsRecordsInput, *, deps: Deps | None = None
11
+ ) -> list[DnsRecord]:
12
+ async with dependencies(deps) as resolved:
13
+ return await resolved.cloudflare.list_dns_records(input)
14
+
15
+
16
+ @app.task(name="cloudflare.listDnsRecords", retry=CLOUDFLARE_RETRY)
17
+ async def list_dns_records(ctx: TaskContext, input: ListDnsRecordsInput) -> list[DnsRecord]:
18
+ return await list_dns_records_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 CLOUDFLARE_RETRY
6
+ from .types import PurgeCacheInput, PurgeCacheResult
7
+
8
+
9
+ async def purge_cache_impl(
10
+ ctx: TaskContext, input: PurgeCacheInput, *, deps: Deps | None = None
11
+ ) -> PurgeCacheResult:
12
+ async with dependencies(deps) as resolved:
13
+ return await resolved.cloudflare.purge_cache(input)
14
+
15
+
16
+ @app.task(name="cloudflare.purgeCache", retry=CLOUDFLARE_RETRY)
17
+ async def purge_cache(ctx: TaskContext, input: PurgeCacheInput) -> PurgeCacheResult:
18
+ return await purge_cache_impl(ctx, input)
@@ -0,0 +1,3 @@
1
+ from render import Retry
2
+
3
+ CLOUDFLARE_RETRY = Retry(max_retries=3, wait_duration_ms=1000, backoff_scaling=2)
@@ -0,0 +1,26 @@
1
+ from ._app import app
2
+ from .delete_dns_record import delete_dns_record, delete_dns_record_impl
3
+ from .deploy_worker import deploy_worker, deploy_worker_impl
4
+ from .dns_upsert import dns_upsert, dns_upsert_impl
5
+ from .list_dns_records import list_dns_records, list_dns_records_impl
6
+ from .purge_cache import purge_cache, purge_cache_impl
7
+ from .workers_kv_get import workers_kv_get, workers_kv_get_impl
8
+ from .workers_kv_put import workers_kv_put, workers_kv_put_impl
9
+
10
+ __all__ = [
11
+ "app",
12
+ "delete_dns_record",
13
+ "delete_dns_record_impl",
14
+ "deploy_worker",
15
+ "deploy_worker_impl",
16
+ "dns_upsert",
17
+ "dns_upsert_impl",
18
+ "list_dns_records",
19
+ "list_dns_records_impl",
20
+ "purge_cache",
21
+ "purge_cache_impl",
22
+ "workers_kv_get",
23
+ "workers_kv_get_impl",
24
+ "workers_kv_put",
25
+ "workers_kv_put_impl",
26
+ ]
@@ -0,0 +1,88 @@
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 PurgeCacheInput(TypedDict):
9
+ zoneId: NotRequired[str]
10
+ everything: NotRequired[bool]
11
+ files: NotRequired[list[str]]
12
+
13
+
14
+ class PurgeCacheResult(TypedDict):
15
+ zoneId: str
16
+ purged: bool
17
+
18
+
19
+ class DnsUpsertInput(TypedDict):
20
+ zoneId: NotRequired[str]
21
+ type: Literal["A"] | Literal["AAAA"] | Literal["CNAME"] | Literal["TXT"] | Literal["MX"]
22
+ name: str
23
+ content: str
24
+ ttl: NotRequired[float]
25
+ proxied: NotRequired[bool]
26
+
27
+
28
+ class DnsUpsertResult(TypedDict):
29
+ recordId: str
30
+ name: str
31
+
32
+
33
+ class DeployWorkerInput(TypedDict):
34
+ accountId: NotRequired[str]
35
+ scriptName: str
36
+ script: str
37
+
38
+
39
+ class DeployWorkerResult(TypedDict):
40
+ scriptName: str
41
+ deployed: bool
42
+
43
+
44
+ class ListDnsRecordsInput(TypedDict):
45
+ zoneId: NotRequired[str]
46
+ type: NotRequired[str]
47
+ name: NotRequired[str]
48
+
49
+
50
+ class DnsRecord(TypedDict):
51
+ id: str
52
+ type: str
53
+ name: str
54
+ content: str
55
+ proxied: bool
56
+
57
+
58
+ class DeleteDnsRecordInput(TypedDict):
59
+ zoneId: NotRequired[str]
60
+ recordId: str
61
+
62
+
63
+ class DeleteDnsRecordResult(TypedDict):
64
+ id: str
65
+ deleted: bool
66
+
67
+
68
+ class WorkersKvPutInput(TypedDict):
69
+ accountId: NotRequired[str]
70
+ namespaceId: NotRequired[str]
71
+ key: str
72
+ value: str
73
+
74
+
75
+ class WorkersKvPutResult(TypedDict):
76
+ key: str
77
+ stored: bool
78
+
79
+
80
+ class WorkersKvGetInput(TypedDict):
81
+ accountId: NotRequired[str]
82
+ namespaceId: NotRequired[str]
83
+ key: str
84
+
85
+
86
+ class WorkersKvGetResult(TypedDict):
87
+ key: str
88
+ value: str | None
@@ -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 CLOUDFLARE_RETRY
6
+ from .types import WorkersKvGetInput, WorkersKvGetResult
7
+
8
+
9
+ async def workers_kv_get_impl(
10
+ ctx: TaskContext, input: WorkersKvGetInput, *, deps: Deps | None = None
11
+ ) -> WorkersKvGetResult:
12
+ async with dependencies(deps) as resolved:
13
+ return await resolved.cloudflare.workers_kv_get(input)
14
+
15
+
16
+ @app.task(name="cloudflare.workersKvGet", retry=CLOUDFLARE_RETRY)
17
+ async def workers_kv_get(ctx: TaskContext, input: WorkersKvGetInput) -> WorkersKvGetResult:
18
+ return await workers_kv_get_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 CLOUDFLARE_RETRY
6
+ from .types import WorkersKvPutInput, WorkersKvPutResult
7
+
8
+
9
+ async def workers_kv_put_impl(
10
+ ctx: TaskContext, input: WorkersKvPutInput, *, deps: Deps | None = None
11
+ ) -> WorkersKvPutResult:
12
+ async with dependencies(deps) as resolved:
13
+ return await resolved.cloudflare.workers_kv_put(input)
14
+
15
+
16
+ @app.task(name="cloudflare.workersKvPut", retry=CLOUDFLARE_RETRY)
17
+ async def workers_kv_put(ctx: TaskContext, input: WorkersKvPutInput) -> WorkersKvPutResult:
18
+ return await workers_kv_put_impl(ctx, input)
@@ -0,0 +1,8 @@
1
+ import pytest
2
+
3
+ from tests.batch_contracts import check_fixture, fixtures
4
+
5
+
6
+ @pytest.mark.parametrize("case", fixtures("cloudflare"))
7
+ async def test_ts_contract(case, ctx, monkeypatch):
8
+ await check_fixture(case, ctx, monkeypatch)
@@ -0,0 +1,54 @@
1
+ import json
2
+ from email import policy
3
+ from email.parser import BytesParser
4
+
5
+ import httpx
6
+ import pytest
7
+ from render_lab_tasks_cloudflare.client import Client
8
+ from render_lab_tasks_core.http import ApiError
9
+
10
+ ENV = {"CLOUDFLARE_API_TOKEN": "test-token", "CLOUDFLARE_ACCOUNT_ID": "test-account"}
11
+
12
+
13
+ async def test_upload_preserves_unicode_script_as_module_and_metadata():
14
+ calls = []
15
+ script = 'export default {fetch() {return new Response("héllo 🌍");}};\r\n'
16
+
17
+ async def handle(request):
18
+ calls.append(request)
19
+ return httpx.Response(200, json={"success": True, "result": {"id": "test-worker"}})
20
+
21
+ async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as http:
22
+ assert await Client(http, ENV).deploy_worker(
23
+ {"scriptName": "test-worker", "script": script}
24
+ ) == {"scriptName": "test-worker", "deployed": True}
25
+ assert len(calls) == 1
26
+ request = calls[0]
27
+ assert request.method == "PUT"
28
+ assert str(request.url).endswith("/accounts/test-account/workers/scripts/test-worker")
29
+ assert request.headers["authorization"] == "Bearer test-token"
30
+ message = BytesParser(policy=policy.default).parsebytes(
31
+ f"Content-Type: {request.headers['content-type']}\r\n\r\n".encode() + request.content
32
+ )
33
+ parts = list(message.iter_parts())
34
+ assert len(parts) == 2
35
+ assert parts[0].get_param("name", header="content-disposition") == "metadata"
36
+ assert json.loads(parts[0].get_payload(decode=True)) == {"main_module": "worker.js"}
37
+ assert parts[1].get_param("name", header="content-disposition") == "worker.js"
38
+ assert parts[1].get_content_type() == "application/javascript+module"
39
+ assert parts[1].get_payload(decode=True).decode() == script
40
+
41
+
42
+ @pytest.mark.parametrize("status", [400, 429, 503])
43
+ async def test_upload_failure_is_one_attempt(status):
44
+ calls = []
45
+
46
+ async def handle(request):
47
+ calls.append(request)
48
+ return httpx.Response(status, json={"success": False, "errors": []})
49
+
50
+ async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as http:
51
+ with pytest.raises(ApiError) as error:
52
+ await Client(http, ENV).deploy_worker({"scriptName": "test-worker", "script": "bad"})
53
+ assert error.value.status == status
54
+ assert len(calls) == 1