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.
Files changed (48) hide show
  1. tasklease/__init__.py +7 -0
  2. tasklease/__main__.py +5 -0
  3. tasklease/cli/__init__.py +53 -0
  4. tasklease/cli/common.py +87 -0
  5. tasklease/cli/identity.py +106 -0
  6. tasklease/cli/inbox.py +37 -0
  7. tasklease/cli/monitor.py +57 -0
  8. tasklease/cli/requester.py +197 -0
  9. tasklease/cli/service.py +169 -0
  10. tasklease/cli/tasks.py +175 -0
  11. tasklease/cli/topics.py +65 -0
  12. tasklease/cli/worker.py +186 -0
  13. tasklease/client.py +283 -0
  14. tasklease/cloudflare/__init__.py +6 -0
  15. tasklease/cloudflare/bundle.py +78 -0
  16. tasklease/cloudflare/deployer.py +714 -0
  17. tasklease/cloudflare/wrangler.py +300 -0
  18. tasklease/config.py +206 -0
  19. tasklease/errors.py +89 -0
  20. tasklease/fsio.py +275 -0
  21. tasklease/generated/__init__.py +1 -0
  22. tasklease/generated/protocol.py +478 -0
  23. tasklease/monitor/__init__.py +1 -0
  24. tasklease/monitor/server.py +325 -0
  25. tasklease/monitor/static/THIRD_PARTY_NOTICES.md +25 -0
  26. tasklease/monitor/static/assets/index-ClHk7fvq.css +1 -0
  27. tasklease/monitor/static/assets/index-CmFr3mRG.js +6 -0
  28. tasklease/monitor/static/index.html +13 -0
  29. tasklease/names.py +60 -0
  30. tasklease/py.typed +0 -0
  31. tasklease/service_bundle/README.md +1 -0
  32. tasklease/service_bundle/THIRD_PARTY_NOTICES.md +2163 -0
  33. tasklease/service_bundle/index.js +18533 -0
  34. tasklease/service_bundle/index.js.map +8 -0
  35. tasklease/service_bundle/manifest.json +4 -0
  36. tasklease/service_bundle/wrangler.template.jsonc +53 -0
  37. tasklease/store.py +669 -0
  38. tasklease/worker.py +444 -0
  39. tasklease/worker_control.py +114 -0
  40. tasklease/worker_templates/launchd.plist +22 -0
  41. tasklease/worker_templates/systemd.service +15 -0
  42. tasklease/worker_templates/windows-stop.ps1 +4 -0
  43. tasklease/worker_templates/windows.xml +27 -0
  44. tasklease-0.1.0.dist-info/METADATA +495 -0
  45. tasklease-0.1.0.dist-info/RECORD +48 -0
  46. tasklease-0.1.0.dist-info/WHEEL +4 -0
  47. tasklease-0.1.0.dist-info/entry_points.txt +2 -0
  48. 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