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.
- render_lab_tasks_cloudflare-0.1.0/.gitignore +11 -0
- render_lab_tasks_cloudflare-0.1.0/LICENSE +21 -0
- render_lab_tasks_cloudflare-0.1.0/PKG-INFO +69 -0
- render_lab_tasks_cloudflare-0.1.0/README.md +56 -0
- render_lab_tasks_cloudflare-0.1.0/pyproject.toml +22 -0
- render_lab_tasks_cloudflare-0.1.0/src/render_lab_tasks_cloudflare/__init__.py +1 -0
- render_lab_tasks_cloudflare-0.1.0/src/render_lab_tasks_cloudflare/_app.py +3 -0
- render_lab_tasks_cloudflare-0.1.0/src/render_lab_tasks_cloudflare/client.py +180 -0
- render_lab_tasks_cloudflare-0.1.0/src/render_lab_tasks_cloudflare/delete_dns_record.py +18 -0
- render_lab_tasks_cloudflare-0.1.0/src/render_lab_tasks_cloudflare/deploy_worker.py +18 -0
- render_lab_tasks_cloudflare-0.1.0/src/render_lab_tasks_cloudflare/dns_upsert.py +18 -0
- render_lab_tasks_cloudflare-0.1.0/src/render_lab_tasks_cloudflare/list_dns_records.py +18 -0
- render_lab_tasks_cloudflare-0.1.0/src/render_lab_tasks_cloudflare/purge_cache.py +18 -0
- render_lab_tasks_cloudflare-0.1.0/src/render_lab_tasks_cloudflare/py.typed +0 -0
- render_lab_tasks_cloudflare-0.1.0/src/render_lab_tasks_cloudflare/retry.py +3 -0
- render_lab_tasks_cloudflare-0.1.0/src/render_lab_tasks_cloudflare/tasks.py +26 -0
- render_lab_tasks_cloudflare-0.1.0/src/render_lab_tasks_cloudflare/types.py +88 -0
- render_lab_tasks_cloudflare-0.1.0/src/render_lab_tasks_cloudflare/workers_kv_get.py +18 -0
- render_lab_tasks_cloudflare-0.1.0/src/render_lab_tasks_cloudflare/workers_kv_put.py +18 -0
- render_lab_tasks_cloudflare-0.1.0/tests/test_contracts.py +8 -0
- render_lab_tasks_cloudflare-0.1.0/tests/test_upload.py +54 -0
|
@@ -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,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)
|
|
File without changes
|
|
@@ -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,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
|