tasklease 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tasklease/__init__.py +7 -0
- tasklease/__main__.py +5 -0
- tasklease/cli/__init__.py +53 -0
- tasklease/cli/common.py +87 -0
- tasklease/cli/identity.py +106 -0
- tasklease/cli/inbox.py +37 -0
- tasklease/cli/monitor.py +57 -0
- tasklease/cli/requester.py +197 -0
- tasklease/cli/service.py +169 -0
- tasklease/cli/tasks.py +175 -0
- tasklease/cli/topics.py +65 -0
- tasklease/cli/worker.py +186 -0
- tasklease/client.py +283 -0
- tasklease/cloudflare/__init__.py +6 -0
- tasklease/cloudflare/bundle.py +78 -0
- tasklease/cloudflare/deployer.py +714 -0
- tasklease/cloudflare/wrangler.py +300 -0
- tasklease/config.py +206 -0
- tasklease/errors.py +89 -0
- tasklease/fsio.py +275 -0
- tasklease/generated/__init__.py +1 -0
- tasklease/generated/protocol.py +478 -0
- tasklease/monitor/__init__.py +1 -0
- tasklease/monitor/server.py +325 -0
- tasklease/monitor/static/THIRD_PARTY_NOTICES.md +25 -0
- tasklease/monitor/static/assets/index-ClHk7fvq.css +1 -0
- tasklease/monitor/static/assets/index-CmFr3mRG.js +6 -0
- tasklease/monitor/static/index.html +13 -0
- tasklease/names.py +60 -0
- tasklease/py.typed +0 -0
- tasklease/service_bundle/README.md +1 -0
- tasklease/service_bundle/THIRD_PARTY_NOTICES.md +2163 -0
- tasklease/service_bundle/index.js +18533 -0
- tasklease/service_bundle/index.js.map +8 -0
- tasklease/service_bundle/manifest.json +4 -0
- tasklease/service_bundle/wrangler.template.jsonc +53 -0
- tasklease/store.py +669 -0
- tasklease/worker.py +444 -0
- tasklease/worker_control.py +114 -0
- tasklease/worker_templates/launchd.plist +22 -0
- tasklease/worker_templates/systemd.service +15 -0
- tasklease/worker_templates/windows-stop.ps1 +4 -0
- tasklease/worker_templates/windows.xml +27 -0
- tasklease-0.1.0.dist-info/METADATA +495 -0
- tasklease-0.1.0.dist-info/RECORD +48 -0
- tasklease-0.1.0.dist-info/WHEEL +4 -0
- tasklease-0.1.0.dist-info/entry_points.txt +2 -0
- tasklease-0.1.0.dist-info/licenses/LICENSE +202 -0
tasklease/client.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Typed synchronous access to the tasklease wire protocol.
|
|
2
|
+
|
|
3
|
+
No operation is retried: a lost response can mean that a mutation succeeded.
|
|
4
|
+
Callers decide how to recover using the operation's fencing semantics.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import math
|
|
11
|
+
from types import TracebackType
|
|
12
|
+
from typing import NoReturn, Self, cast
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from tasklease.config import validate_url
|
|
17
|
+
from tasklease.errors import API_ERRORS, Invalid, ProtocolError, TransportError
|
|
18
|
+
from tasklease.generated import protocol as p
|
|
19
|
+
from tasklease.names import is_bearer_token, is_worker_label
|
|
20
|
+
|
|
21
|
+
# Check the transport envelope and its required top-level members before the
|
|
22
|
+
# generated DTO cast. Full schemas remain a contract-test dependency, rather
|
|
23
|
+
# than adding a runtime JSON Schema dependency to each client installation.
|
|
24
|
+
_SUCCESS_FIELDS: dict[str, dict[str, type[object]]] = {
|
|
25
|
+
"create": {"id": str},
|
|
26
|
+
"own": {"task": dict},
|
|
27
|
+
"renew": {"task": dict},
|
|
28
|
+
"read": {"task": dict},
|
|
29
|
+
"delete": {},
|
|
30
|
+
"consume": {"ids": list},
|
|
31
|
+
"topics.list": {"topics": list},
|
|
32
|
+
"tasks.list": {"tasks": list},
|
|
33
|
+
"topic.pollers": {"pollers": list},
|
|
34
|
+
"topic.declare": {},
|
|
35
|
+
"blob.put_url": {"key": str, "url": str, "expires_at": int},
|
|
36
|
+
"blob.get_url": {"url": str, "expires_at": int},
|
|
37
|
+
"identity.add": {"name": str, "token": str},
|
|
38
|
+
"identity.revoke": {},
|
|
39
|
+
"identity.rotate": {"token": str},
|
|
40
|
+
"identity.list": {"identities": list},
|
|
41
|
+
"identity.whoami": {
|
|
42
|
+
"name": str,
|
|
43
|
+
"domains": list,
|
|
44
|
+
"scopes": dict,
|
|
45
|
+
"operator": bool,
|
|
46
|
+
},
|
|
47
|
+
"monitor.snapshot": {
|
|
48
|
+
"domain": str,
|
|
49
|
+
"generated_at": int,
|
|
50
|
+
"topics": list,
|
|
51
|
+
"stats": list,
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def reject_nonfinite(value: str) -> NoReturn:
|
|
57
|
+
"""json parse_constant hook: the protocol has no NaN or Infinity."""
|
|
58
|
+
raise ValueError("Nonfinite JSON number")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Client:
|
|
62
|
+
"""One HTTP connection pool, identity credential and worker label.
|
|
63
|
+
|
|
64
|
+
Own, Renew, Consume and Delete accept a per-call timeout cap in seconds.
|
|
65
|
+
It limits each configured HTTPX phase timeout, including unlimited phases,
|
|
66
|
+
and never lengthens a configured limit. Omitting the cap preserves settings.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
base_url: str,
|
|
72
|
+
token: str,
|
|
73
|
+
worker_label: str = "cli",
|
|
74
|
+
*,
|
|
75
|
+
transport: httpx.BaseTransport | None = None,
|
|
76
|
+
timeout: float | httpx.Timeout | None = 30.0,
|
|
77
|
+
) -> None:
|
|
78
|
+
self._base_url = validate_url(base_url)
|
|
79
|
+
if not is_bearer_token(token):
|
|
80
|
+
raise Invalid("token must be a nonempty bearer token")
|
|
81
|
+
if not is_worker_label(worker_label):
|
|
82
|
+
raise Invalid(
|
|
83
|
+
"worker_label must contain 1 to 256 letters, digits, ., _ or -"
|
|
84
|
+
)
|
|
85
|
+
self._token = token
|
|
86
|
+
self._worker_label = worker_label
|
|
87
|
+
self._http = httpx.Client(
|
|
88
|
+
transport=transport, timeout=timeout, follow_redirects=False
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def __enter__(self) -> Self:
|
|
92
|
+
self._http.__enter__()
|
|
93
|
+
return self
|
|
94
|
+
|
|
95
|
+
def __exit__(
|
|
96
|
+
self,
|
|
97
|
+
exc_type: type[BaseException] | None,
|
|
98
|
+
exc_value: BaseException | None,
|
|
99
|
+
traceback: TracebackType | None,
|
|
100
|
+
) -> None:
|
|
101
|
+
self._http.__exit__(exc_type, exc_value, traceback)
|
|
102
|
+
|
|
103
|
+
def close(self) -> None:
|
|
104
|
+
self._http.close()
|
|
105
|
+
|
|
106
|
+
def _send(
|
|
107
|
+
self,
|
|
108
|
+
method: str,
|
|
109
|
+
operation: str,
|
|
110
|
+
content: bytes | None = None,
|
|
111
|
+
*,
|
|
112
|
+
timeout: float | None = None,
|
|
113
|
+
) -> httpx.Response:
|
|
114
|
+
if timeout is not None and (not math.isfinite(timeout) or timeout <= 0):
|
|
115
|
+
raise Invalid("timeout must be a positive finite number of seconds")
|
|
116
|
+
headers = (
|
|
117
|
+
{
|
|
118
|
+
"Authorization": f"Bearer {self._token}",
|
|
119
|
+
"X-Worker": self._worker_label,
|
|
120
|
+
"Content-Type": "application/json",
|
|
121
|
+
}
|
|
122
|
+
if method == "POST"
|
|
123
|
+
else {}
|
|
124
|
+
)
|
|
125
|
+
try:
|
|
126
|
+
url = f"{self._base_url}/v1/{operation}"
|
|
127
|
+
if timeout is None:
|
|
128
|
+
response = self._http.request(
|
|
129
|
+
method, url, headers=headers, content=content
|
|
130
|
+
)
|
|
131
|
+
else:
|
|
132
|
+
configured = self._http.timeout
|
|
133
|
+
|
|
134
|
+
def cap(value: float | None) -> float:
|
|
135
|
+
return timeout if value is None else min(value, timeout)
|
|
136
|
+
|
|
137
|
+
bounded = httpx.Timeout(
|
|
138
|
+
connect=cap(configured.connect),
|
|
139
|
+
read=cap(configured.read),
|
|
140
|
+
write=cap(configured.write),
|
|
141
|
+
pool=cap(configured.pool),
|
|
142
|
+
)
|
|
143
|
+
response = self._http.request(
|
|
144
|
+
method, url, headers=headers, content=content, timeout=bounded
|
|
145
|
+
)
|
|
146
|
+
except httpx.RequestError:
|
|
147
|
+
raise TransportError("The tasklease service could not be reached") from None
|
|
148
|
+
if response.status_code >= 500:
|
|
149
|
+
raise TransportError(
|
|
150
|
+
f"The tasklease service returned HTTP {response.status_code}",
|
|
151
|
+
status_code=response.status_code,
|
|
152
|
+
)
|
|
153
|
+
return response
|
|
154
|
+
|
|
155
|
+
def _decode(self, response: httpx.Response) -> dict[str, object]:
|
|
156
|
+
try:
|
|
157
|
+
value: object = json.loads(
|
|
158
|
+
response.content, parse_constant=reject_nonfinite
|
|
159
|
+
)
|
|
160
|
+
except (ValueError, UnicodeError, RecursionError):
|
|
161
|
+
raise ProtocolError("The service returned invalid JSON") from None
|
|
162
|
+
if not isinstance(value, dict):
|
|
163
|
+
raise ProtocolError("The service returned a non-object response")
|
|
164
|
+
return cast(dict[str, object], value)
|
|
165
|
+
|
|
166
|
+
def _post(
|
|
167
|
+
self, operation: str, request: object, *, timeout: float | None = None
|
|
168
|
+
) -> dict[str, object]:
|
|
169
|
+
try:
|
|
170
|
+
content = json.dumps(
|
|
171
|
+
request, ensure_ascii=False, allow_nan=False, separators=(",", ":")
|
|
172
|
+
).encode("utf-8")
|
|
173
|
+
except (TypeError, ValueError, UnicodeError, RecursionError):
|
|
174
|
+
raise Invalid("Request must contain valid JSON values") from None
|
|
175
|
+
response = self._send("POST", operation, content, timeout=timeout)
|
|
176
|
+
body = self._decode(response)
|
|
177
|
+
if body.get("ok") is False:
|
|
178
|
+
error = body.get("error")
|
|
179
|
+
if isinstance(error, dict):
|
|
180
|
+
details = cast(dict[str, object], error)
|
|
181
|
+
code, message = details.get("code"), details.get("message")
|
|
182
|
+
error_type = API_ERRORS.get(code) if isinstance(code, str) else None
|
|
183
|
+
if (
|
|
184
|
+
error_type is not None
|
|
185
|
+
and isinstance(message, str)
|
|
186
|
+
and response.status_code == error_type.status_code
|
|
187
|
+
):
|
|
188
|
+
raise error_type(message.replace(self._token, "[redacted]"))
|
|
189
|
+
raise ProtocolError("The service returned an invalid error envelope")
|
|
190
|
+
if response.status_code != 200 or body.get("ok") is not True:
|
|
191
|
+
raise ProtocolError("The service returned an invalid success envelope")
|
|
192
|
+
for name, value_type in _SUCCESS_FIELDS[operation].items():
|
|
193
|
+
if type(body.get(name)) is not value_type:
|
|
194
|
+
raise ProtocolError(
|
|
195
|
+
"The service returned an incomplete success envelope"
|
|
196
|
+
)
|
|
197
|
+
return body
|
|
198
|
+
|
|
199
|
+
def create(self, request: p.CreateRequest) -> p.CreateResponse:
|
|
200
|
+
return cast(p.CreateResponse, self._post("create", request))
|
|
201
|
+
|
|
202
|
+
def own(
|
|
203
|
+
self, request: p.OwnRequest, *, timeout: float | None = None
|
|
204
|
+
) -> p.OwnResponse:
|
|
205
|
+
"""Own a task, optionally capping each configured HTTP timeout in seconds."""
|
|
206
|
+
return cast(p.OwnResponse, self._post("own", request, timeout=timeout))
|
|
207
|
+
|
|
208
|
+
def renew(
|
|
209
|
+
self, request: p.RenewRequest, *, timeout: float | None = None
|
|
210
|
+
) -> p.RenewResponse:
|
|
211
|
+
"""Renew a task, optionally capping each configured HTTP timeout."""
|
|
212
|
+
return cast(p.RenewResponse, self._post("renew", request, timeout=timeout))
|
|
213
|
+
|
|
214
|
+
def read(self, request: p.ReadRequest) -> p.ReadResponse:
|
|
215
|
+
return cast(p.ReadResponse, self._post("read", request))
|
|
216
|
+
|
|
217
|
+
def delete(
|
|
218
|
+
self, request: p.DeleteRequest, *, timeout: float | None = None
|
|
219
|
+
) -> p.DeleteResponse:
|
|
220
|
+
"""Delete a task, optionally capping each configured HTTP timeout in seconds."""
|
|
221
|
+
return cast(p.DeleteResponse, self._post("delete", request, timeout=timeout))
|
|
222
|
+
|
|
223
|
+
def consume(
|
|
224
|
+
self, request: p.ConsumeRequest, *, timeout: float | None = None
|
|
225
|
+
) -> p.ConsumeResponse:
|
|
226
|
+
"""Consume a task, optionally capping each configured HTTP timeout."""
|
|
227
|
+
return cast(p.ConsumeResponse, self._post("consume", request, timeout=timeout))
|
|
228
|
+
|
|
229
|
+
def topics_list(self, request: p.TopicsListRequest) -> p.TopicsListResponse:
|
|
230
|
+
return cast(p.TopicsListResponse, self._post("topics.list", request))
|
|
231
|
+
|
|
232
|
+
def tasks_list(self, request: p.TasksListRequest) -> p.TasksListResponse:
|
|
233
|
+
return cast(p.TasksListResponse, self._post("tasks.list", request))
|
|
234
|
+
|
|
235
|
+
def topic_pollers(self, request: p.TopicPollersRequest) -> p.TopicPollersResponse:
|
|
236
|
+
return cast(p.TopicPollersResponse, self._post("topic.pollers", request))
|
|
237
|
+
|
|
238
|
+
def topic_declare(self, request: p.TopicDeclareRequest) -> p.TopicDeclareResponse:
|
|
239
|
+
return cast(p.TopicDeclareResponse, self._post("topic.declare", request))
|
|
240
|
+
|
|
241
|
+
def blob_put_url(self, request: p.BlobPutUrlRequest) -> p.BlobPutUrlResponse:
|
|
242
|
+
return cast(p.BlobPutUrlResponse, self._post("blob.put_url", request))
|
|
243
|
+
|
|
244
|
+
def blob_get_url(self, request: p.BlobGetUrlRequest) -> p.BlobGetUrlResponse:
|
|
245
|
+
return cast(p.BlobGetUrlResponse, self._post("blob.get_url", request))
|
|
246
|
+
|
|
247
|
+
def identity_add(self, request: p.IdentityAddRequest) -> p.IdentityAddResponse:
|
|
248
|
+
return cast(p.IdentityAddResponse, self._post("identity.add", request))
|
|
249
|
+
|
|
250
|
+
def identity_revoke(
|
|
251
|
+
self, request: p.IdentityRevokeRequest
|
|
252
|
+
) -> p.IdentityRevokeResponse:
|
|
253
|
+
return cast(p.IdentityRevokeResponse, self._post("identity.revoke", request))
|
|
254
|
+
|
|
255
|
+
def identity_rotate(
|
|
256
|
+
self, request: p.IdentityRotateRequest
|
|
257
|
+
) -> p.IdentityRotateResponse:
|
|
258
|
+
return cast(p.IdentityRotateResponse, self._post("identity.rotate", request))
|
|
259
|
+
|
|
260
|
+
def identity_list(self, request: p.IdentityListRequest) -> p.IdentityListResponse:
|
|
261
|
+
return cast(p.IdentityListResponse, self._post("identity.list", request))
|
|
262
|
+
|
|
263
|
+
def identity_whoami(
|
|
264
|
+
self, request: p.IdentityWhoamiRequest
|
|
265
|
+
) -> p.IdentityWhoamiResponse:
|
|
266
|
+
return cast(p.IdentityWhoamiResponse, self._post("identity.whoami", request))
|
|
267
|
+
|
|
268
|
+
def monitor_snapshot(
|
|
269
|
+
self, request: p.MonitorSnapshotRequest
|
|
270
|
+
) -> p.MonitorSnapshotResponse:
|
|
271
|
+
return cast(p.MonitorSnapshotResponse, self._post("monitor.snapshot", request))
|
|
272
|
+
|
|
273
|
+
def version(self) -> p.VersionResponse:
|
|
274
|
+
response = self._send("GET", "version")
|
|
275
|
+
body = self._decode(response)
|
|
276
|
+
if (
|
|
277
|
+
response.status_code != 200
|
|
278
|
+
or body.get("protocol") != "v1"
|
|
279
|
+
or not isinstance(body.get("service_version"), str)
|
|
280
|
+
or not body["service_version"]
|
|
281
|
+
):
|
|
282
|
+
raise ProtocolError("The service returned an invalid version envelope")
|
|
283
|
+
return cast(p.VersionResponse, body)
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Deployment of the packaged Worker through its pinned Wrangler release."""
|
|
2
|
+
|
|
3
|
+
from tasklease.cloudflare.deployer import DeployOptions, deploy, status
|
|
4
|
+
from tasklease.cloudflare.wrangler import DeployError
|
|
5
|
+
|
|
6
|
+
__all__ = ["DeployError", "DeployOptions", "deploy", "status"]
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Read and stage the installed, prebuilt Worker without a source checkout."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
from importlib.resources import as_file, files
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from tasklease import __version__
|
|
12
|
+
from tasklease.cloudflare.wrangler import DeployError, json_value, object_value
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def stage_bundle(destination: Path) -> tuple[str, dict[str, object]]:
|
|
16
|
+
resource = files("tasklease").joinpath("service_bundle")
|
|
17
|
+
if not resource.is_dir():
|
|
18
|
+
raise DeployError(
|
|
19
|
+
"This installation has no bundled Worker; reinstall tasklease."
|
|
20
|
+
)
|
|
21
|
+
with as_file(resource) as source:
|
|
22
|
+
shutil.copytree(source, destination, dirs_exist_ok=True)
|
|
23
|
+
try:
|
|
24
|
+
manifest = object_value(
|
|
25
|
+
json_value((destination / "manifest.json").read_text(), "bundle manifest"),
|
|
26
|
+
"bundle manifest",
|
|
27
|
+
)
|
|
28
|
+
template = object_value(
|
|
29
|
+
json_value(
|
|
30
|
+
(destination / "wrangler.template.jsonc").read_text(), "Worker template"
|
|
31
|
+
),
|
|
32
|
+
"Worker template",
|
|
33
|
+
)
|
|
34
|
+
except OSError as exc:
|
|
35
|
+
raise DeployError("The installed Worker bundle is incomplete.") from exc
|
|
36
|
+
version = manifest.get("wrangler_version")
|
|
37
|
+
main = template.get("main")
|
|
38
|
+
if (
|
|
39
|
+
manifest.get("service_version") != __version__
|
|
40
|
+
or not isinstance(version, str)
|
|
41
|
+
or re.fullmatch(r"\d+\.\d+\.\d+", version) is None
|
|
42
|
+
or not isinstance(main, str)
|
|
43
|
+
or not (destination / main).is_file()
|
|
44
|
+
or not (destination / main).resolve().is_relative_to(destination.resolve())
|
|
45
|
+
):
|
|
46
|
+
raise DeployError("The installed Worker manifest or entrypoint is invalid.")
|
|
47
|
+
return version, template
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def configure_bundle(
|
|
51
|
+
destination: Path,
|
|
52
|
+
template: dict[str, object],
|
|
53
|
+
*,
|
|
54
|
+
account_id: str,
|
|
55
|
+
name: str,
|
|
56
|
+
bucket: str,
|
|
57
|
+
) -> dict[str, object]:
|
|
58
|
+
config = dict(template)
|
|
59
|
+
config.update(account_id=account_id, name=name, no_bundle=True, workers_dev=True)
|
|
60
|
+
variables = object_value(config.get("vars", {}), "Worker variables").copy()
|
|
61
|
+
variables.update(R2_ACCOUNT_ID=account_id, R2_BUCKET_NAME=bucket)
|
|
62
|
+
config["vars"] = variables
|
|
63
|
+
bindings = config.get("r2_buckets")
|
|
64
|
+
if not isinstance(bindings, list):
|
|
65
|
+
raise DeployError("Worker template is missing R2 bindings.")
|
|
66
|
+
found = False
|
|
67
|
+
updated: list[dict[str, object]] = []
|
|
68
|
+
for binding in bindings:
|
|
69
|
+
record = object_value(binding, "R2 binding").copy()
|
|
70
|
+
if record.get("binding") == "BLOBS":
|
|
71
|
+
record["bucket_name"] = bucket
|
|
72
|
+
found = True
|
|
73
|
+
updated.append(record)
|
|
74
|
+
if not found:
|
|
75
|
+
raise DeployError("Worker template is missing the BLOBS binding.")
|
|
76
|
+
config["r2_buckets"] = updated
|
|
77
|
+
(destination / "wrangler.jsonc").write_text(json.dumps(config, indent=2) + "\n")
|
|
78
|
+
return config
|