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/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """The tasklease client package."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("tasklease")
6
+
7
+ __all__ = ["__version__"]
tasklease/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Run the tasklease command line interface."""
2
+
3
+ from tasklease.cli import main
4
+
5
+ raise SystemExit(main())
@@ -0,0 +1,53 @@
1
+ """The tasklease command line entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from collections.abc import Callable, Sequence
7
+ from pathlib import Path
8
+ from typing import cast
9
+
10
+ from tasklease import __version__
11
+
12
+
13
+ def main(argv: Sequence[str] | None = None) -> int:
14
+ """Parse a command without side effects until its handler runs."""
15
+ from tasklease.cli import (
16
+ identity,
17
+ inbox,
18
+ monitor,
19
+ requester,
20
+ service,
21
+ tasks,
22
+ topics,
23
+ worker,
24
+ )
25
+ from tasklease.cli.common import message
26
+ from tasklease.errors import TaskleaseError
27
+
28
+ parser = argparse.ArgumentParser(
29
+ prog="tl", description="A leased task board for agents and workers."
30
+ )
31
+ parser.add_argument(
32
+ "--version", action="version", version=f"%(prog)s {__version__}"
33
+ )
34
+ parser.add_argument("--config", type=Path)
35
+ parser.add_argument("--url")
36
+ parser.add_argument("--worker")
37
+ commands = parser.add_subparsers(dest="command")
38
+ for module in (identity, requester, inbox, tasks, topics, service, worker, monitor):
39
+ module.configure_parser(commands)
40
+ args = parser.parse_args(argv)
41
+ selected: object = getattr(args, "handler", None)
42
+ if selected is None:
43
+ parser.print_help()
44
+ return 0
45
+ handler = cast(Callable[[argparse.Namespace], int], selected)
46
+ try:
47
+ return handler(args)
48
+ except (TaskleaseError, OSError, ValueError) as error:
49
+ message(str(error))
50
+ return 1
51
+ except KeyboardInterrupt:
52
+ message("Interrupted")
53
+ return 130
@@ -0,0 +1,87 @@
1
+ """Shared command configuration and output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import re
8
+ import sys
9
+ from collections.abc import Iterator
10
+ from contextlib import contextmanager
11
+ from pathlib import Path
12
+
13
+ from tasklease.client import Client
14
+ from tasklease.config import Config, load_config
15
+ from tasklease.errors import ConfigError
16
+
17
+
18
+ def connection_options(parser: argparse.ArgumentParser) -> None:
19
+ parser.add_argument("--config", type=Path, default=argparse.SUPPRESS)
20
+ parser.add_argument("--url", default=argparse.SUPPRESS)
21
+ parser.add_argument("--worker", default=argparse.SUPPRESS)
22
+
23
+
24
+ def optional_text(args: argparse.Namespace, name: str) -> str | None:
25
+ value: object = getattr(args, name, None)
26
+ if value is None:
27
+ return None
28
+ if not isinstance(value, str):
29
+ raise ConfigError(f"{name} must be text")
30
+ return value
31
+
32
+
33
+ def text(args: argparse.Namespace, name: str) -> str:
34
+ value = optional_text(args, name)
35
+ if value is None:
36
+ raise ConfigError(f"{name} is required")
37
+ return value
38
+
39
+
40
+ def flag(args: argparse.Namespace, name: str) -> bool:
41
+ value: object = getattr(args, name, False)
42
+ return value is True
43
+
44
+
45
+ def duration_seconds(value: str, *, name: str = "Duration") -> int:
46
+ """Parse a positive duration expressed in seconds, minutes, hours or days."""
47
+ match = re.fullmatch(r"([0-9]+)(s|m|h|d)", value)
48
+ if not match or int(match[1]) <= 0:
49
+ raise ConfigError(
50
+ f"{name} must be positive seconds, minutes, hours or days, e.g. 3d"
51
+ )
52
+ return int(match[1]) * {"s": 1, "m": 60, "h": 3600, "d": 86400}[match[2]]
53
+
54
+
55
+ def configuration(args: argparse.Namespace) -> Config:
56
+ path: object = getattr(args, "config", None)
57
+ if path is not None and not isinstance(path, (str, Path)):
58
+ raise ConfigError("config must be a path")
59
+ return load_config(path)
60
+
61
+
62
+ def domain(args: argparse.Namespace, config: Config) -> str:
63
+ value = optional_text(args, "domain") or config.domain
64
+ if not value:
65
+ raise ConfigError("Give a domain or set domain in configuration")
66
+ return value
67
+
68
+
69
+ @contextmanager
70
+ def client(args: argparse.Namespace, *, admin: bool = False) -> Iterator[Client]:
71
+ config = configuration(args)
72
+ url = optional_text(args, "url") or config.url
73
+ if not url:
74
+ raise ConfigError("Set the service URL in config.toml or use --url")
75
+ worker = optional_text(args, "worker") or config.worker_label
76
+ with Client(url, config.token(admin=admin), worker) as api:
77
+ yield api
78
+
79
+
80
+ def output(value: object) -> None:
81
+ # Escaping also preserves unusual wire JSON strings, including isolated
82
+ # surrogate code units, without failing at the terminal's UTF-8 boundary.
83
+ print(json.dumps(value, ensure_ascii=True, allow_nan=False, separators=(",", ":")))
84
+
85
+
86
+ def message(value: str) -> None:
87
+ print(value, file=sys.stderr)
@@ -0,0 +1,106 @@
1
+ """Identity administration and inspection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from tasklease.cli.common import (
8
+ client,
9
+ connection_options,
10
+ flag,
11
+ message,
12
+ optional_text,
13
+ output,
14
+ text,
15
+ )
16
+ from tasklease.errors import ConfigError
17
+
18
+
19
+ def _globs(args: argparse.Namespace, name: str) -> list[str]:
20
+ value = optional_text(args, name)
21
+ if value is None:
22
+ return []
23
+ result = value.split(",")
24
+ if any(not item for item in result):
25
+ raise ConfigError(f"--{name} contains an empty glob")
26
+ return result
27
+
28
+
29
+ def _add(args: argparse.Namespace) -> int:
30
+ domains: object = getattr(args, "domain", None)
31
+ if not isinstance(domains, list):
32
+ raise ConfigError("Give at least one --domain")
33
+ checked_domains: list[str] = []
34
+ for item in domains:
35
+ if not isinstance(item, str):
36
+ raise ConfigError("Domain names must be text")
37
+ checked_domains.append(item)
38
+ own, create = _globs(args, "own"), _globs(args, "create")
39
+ with client(args, admin=True) as api:
40
+ result = api.identity_add(
41
+ {
42
+ "name": text(args, "name"),
43
+ "domains": checked_domains,
44
+ "scopes": {"own": own, "create": create, "read": _globs(args, "read")},
45
+ "operator": flag(args, "operator"),
46
+ }
47
+ )
48
+ output(result)
49
+ added = list(
50
+ dict.fromkeys(f"{glob}/error" for glob in own if f"{glob}/error" not in create)
51
+ )
52
+ if added:
53
+ message(f"Added create grants: {', '.join(added)}")
54
+ message("Store the identity token securely; it is shown only once.")
55
+ return 0
56
+
57
+
58
+ def _revoke(args: argparse.Namespace) -> int:
59
+ with client(args, admin=True) as api:
60
+ output(api.identity_revoke({"name": text(args, "name")}))
61
+ return 0
62
+
63
+
64
+ def _rotate(args: argparse.Namespace) -> int:
65
+ with client(args, admin=True) as api:
66
+ output(api.identity_rotate({"name": text(args, "name")}))
67
+ message(
68
+ "Store the replacement token securely; the old token expires within 60 seconds."
69
+ )
70
+ return 0
71
+
72
+
73
+ def _list(args: argparse.Namespace) -> int:
74
+ with client(args, admin=True) as api:
75
+ output(api.identity_list({}))
76
+ return 0
77
+
78
+
79
+ def _whoami(args: argparse.Namespace) -> int:
80
+ with client(args) as api:
81
+ output(api.identity_whoami({}))
82
+ return 0
83
+
84
+
85
+ def configure_parser(
86
+ subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
87
+ ) -> None:
88
+ parser = subparsers.add_parser("identity", help="Manage machine identities")
89
+ commands = parser.add_subparsers(dest="identity_command", required=True)
90
+ add = commands.add_parser("add", help="Issue an identity token")
91
+ connection_options(add)
92
+ add.add_argument("name")
93
+ add.add_argument("--domain", action="append", required=True)
94
+ for scope in ("own", "create", "read"):
95
+ add.add_argument(f"--{scope}", help="Comma-separated topic globs")
96
+ add.add_argument("--operator", action="store_true")
97
+ add.set_defaults(handler=_add)
98
+ for name, handler in (("revoke", _revoke), ("rotate", _rotate), ("list", _list)):
99
+ command = commands.add_parser(name)
100
+ connection_options(command)
101
+ if name != "list":
102
+ command.add_argument("name")
103
+ command.set_defaults(handler=handler)
104
+ whoami = subparsers.add_parser("whoami", help="Show the current identity")
105
+ connection_options(whoami)
106
+ whoami.set_defaults(handler=_whoami)
tasklease/cli/inbox.py ADDED
@@ -0,0 +1,37 @@
1
+ """Inspect and prune the machine's durable local reply stores."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from tasklease.cli.common import connection_options, flag, output
8
+ from tasklease.cli.requester import agent_store
9
+ from tasklease.store import Store
10
+
11
+
12
+ def _inbox(args: argparse.Namespace) -> int:
13
+ stores = (
14
+ [Store(agent_id) for agent_id in Store.agents()]
15
+ if flag(args, "all")
16
+ else [agent_store(args)]
17
+ )
18
+ pruned = sum(store.prune() for store in stores) if flag(args, "prune") else None
19
+ result: dict[str, object] = {
20
+ "replies": [reply.to_dict() for store in stores for reply in store.inbox()]
21
+ }
22
+ if pruned is not None:
23
+ result["pruned"] = pruned
24
+ output(result)
25
+ return 0
26
+
27
+
28
+ def configure_parser(
29
+ subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
30
+ ) -> None:
31
+ parser = subparsers.add_parser("inbox", help="List replies saved on this machine")
32
+ connection_options(parser)
33
+ parser.add_argument("--all", action="store_true", help="Include every local agent")
34
+ parser.add_argument(
35
+ "--prune", action="store_true", help="Remove records over 7 days"
36
+ )
37
+ parser.set_defaults(handler=_inbox)
@@ -0,0 +1,57 @@
1
+ """Open a read-only dashboard with credentials kept in this Python process."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ import webbrowser
8
+ from pathlib import Path
9
+
10
+ from tasklease.cli.common import (
11
+ client,
12
+ connection_options,
13
+ flag,
14
+ message,
15
+ optional_text,
16
+ output,
17
+ )
18
+ from tasklease.errors import ConfigError
19
+ from tasklease.monitor.server import MonitorServer, validate_domain_selection
20
+
21
+
22
+ def _run(args: argparse.Namespace) -> int:
23
+ selected_domain = validate_domain_selection(optional_text(args, "domain"))
24
+ port: object = getattr(args, "port", 0)
25
+ state_root: object = getattr(args, "state_dir", None)
26
+ if type(port) is not int or not 0 <= port <= 65535:
27
+ raise ConfigError("port must be an integer from 0 to 65535")
28
+ if state_root is not None and not isinstance(state_root, Path):
29
+ raise ConfigError("state-dir must be a path")
30
+ with (
31
+ client(args) as api,
32
+ MonitorServer(
33
+ api, port=port, state_root=state_root, domain=selected_domain
34
+ ) as server,
35
+ ):
36
+ output({"url": server.url})
37
+ message(f"Monitor at {server.url}; press Ctrl-C to stop")
38
+ sys.stdout.flush()
39
+ sys.stderr.flush()
40
+ if not flag(args, "no_browser"):
41
+ webbrowser.open(server.url)
42
+ server.serve_forever(poll_interval=0.2)
43
+ return 0
44
+
45
+
46
+ def configure_parser(
47
+ subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
48
+ ) -> None:
49
+ parser = subparsers.add_parser("monitor", help="Open the read-only local dashboard")
50
+ connection_options(parser)
51
+ parser.add_argument("--domain", help="Limit service views to an authorized domain")
52
+ parser.add_argument(
53
+ "--port", type=int, default=0, help="Local port, default free port"
54
+ )
55
+ parser.add_argument("--no-browser", action="store_true")
56
+ parser.add_argument("--state-dir", type=Path)
57
+ parser.set_defaults(handler=_run)
@@ -0,0 +1,197 @@
1
+ """Durable requester commands and recovery after uncertain submissions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from pathlib import Path
8
+ from typing import cast
9
+ from uuid import uuid4
10
+
11
+ from tasklease.cli.common import (
12
+ client,
13
+ configuration,
14
+ connection_options,
15
+ domain,
16
+ duration_seconds,
17
+ flag,
18
+ message,
19
+ optional_text,
20
+ output,
21
+ text,
22
+ )
23
+ from tasklease.client import Client, reject_nonfinite
24
+ from tasklease.errors import ConfigError, TaskleaseError
25
+ from tasklease.store import RequestRecord, Store
26
+
27
+
28
+ def agent_store(args: argparse.Namespace) -> Store:
29
+ """Select a single agent without requiring any network configuration."""
30
+ agent_id = configuration(args).agent_id
31
+ if agent_id is None:
32
+ raise ConfigError("Set agent_id in config.toml or TL_AGENT_ID")
33
+ return Store(agent_id)
34
+
35
+
36
+ def _data(value: str) -> dict[str, object]:
37
+ if value.startswith("@"):
38
+ if len(value) == 1:
39
+ raise ConfigError("--data @file requires a file path")
40
+ try:
41
+ value = Path(value[1:]).read_text(encoding="utf-8")
42
+ except (OSError, UnicodeError):
43
+ raise ConfigError("Cannot read --data file") from None
44
+ try:
45
+ decoded: object = json.loads(value, parse_constant=reject_nonfinite)
46
+ except (ValueError, UnicodeError, RecursionError):
47
+ raise ConfigError("--data must contain a valid JSON object") from None
48
+ if not isinstance(decoded, dict):
49
+ raise ConfigError("--data must contain a JSON object")
50
+ return cast(dict[str, object], decoded)
51
+
52
+
53
+ def _status(record: RequestRecord) -> None:
54
+ output(
55
+ {
56
+ "request_id": record.request_id,
57
+ "task_id": record.task_id,
58
+ "state": record.state,
59
+ }
60
+ )
61
+
62
+
63
+ def _report_recovery(store: Store, request_id: str) -> None:
64
+ try:
65
+ record = store.request(request_id)
66
+ except (TaskleaseError, OSError, ValueError):
67
+ return
68
+ if record is None:
69
+ return
70
+ _status(record)
71
+ if record.state == "submission_unknown":
72
+ message(
73
+ "Submission outcome is unknown. Resume with tl wait --request-id "
74
+ f"{request_id}, or resend with tl request --resend {request_id}. "
75
+ "Cancellation is unavailable until a task ID is saved."
76
+ )
77
+ else:
78
+ message(
79
+ f"Request {request_id} remains saved. "
80
+ f"Resume with tl wait --request-id {request_id}."
81
+ )
82
+
83
+
84
+ def _wait_for(store: Store, api: Client, request_id: str, timeout_seconds: int) -> int:
85
+ reply = store.wait(api, request_id, timeout_seconds=timeout_seconds)
86
+ if reply is None:
87
+ output({"request_id": request_id, "timed_out": True})
88
+ message(f"Timed out; request {request_id} remains saved for tl wait")
89
+ return 3
90
+ output({"request_id": request_id, "data": reply.data, "creator": reply.creator})
91
+ return 0
92
+
93
+
94
+ def _request(args: argparse.Namespace) -> int:
95
+ timeout = duration_seconds(text(args, "timeout"), name="Timeout")
96
+ resend = optional_text(args, "resend")
97
+ if resend is not None:
98
+ if any(
99
+ optional_text(args, name) is not None
100
+ for name in ("domain", "topic", "reply_topic", "data", "request_id")
101
+ ):
102
+ raise ConfigError(
103
+ "--resend uses the saved request; omit --domain, --topic, "
104
+ "--reply-topic, --data and --request-id"
105
+ )
106
+ request_id = resend
107
+ payload = None
108
+ selected = topic = reply_topic = None
109
+ else:
110
+ selected = domain(args, configuration(args))
111
+ topic = text(args, "topic")
112
+ reply_topic = text(args, "reply_topic")
113
+ payload = _data(text(args, "data"))
114
+ supplied_id = optional_text(args, "request_id")
115
+ request_id = uuid4().hex if supplied_id is None else supplied_id
116
+ store = agent_store(args)
117
+ with client(args) as api:
118
+ try:
119
+ if resend is not None:
120
+ record = store.resend(api, request_id)
121
+ else:
122
+ assert selected is not None and topic is not None
123
+ assert reply_topic is not None and payload is not None
124
+ record = store.submit(
125
+ api,
126
+ selected,
127
+ topic,
128
+ reply_topic,
129
+ payload,
130
+ request_id=request_id,
131
+ )
132
+ if flag(args, "wait"):
133
+ return _wait_for(store, api, request_id, timeout)
134
+ except (TaskleaseError, OSError, ValueError, KeyboardInterrupt):
135
+ _report_recovery(store, request_id)
136
+ raise
137
+ _status(record)
138
+ return 0
139
+
140
+
141
+ def _wait(args: argparse.Namespace) -> int:
142
+ timeout = duration_seconds(text(args, "timeout"), name="Timeout")
143
+ store = agent_store(args)
144
+ with client(args) as api:
145
+ return _wait_for(store, api, text(args, "request_id"), timeout)
146
+
147
+
148
+ def _cancel(args: argparse.Namespace) -> int:
149
+ store = agent_store(args)
150
+ request_id = text(args, "request_id")
151
+ with client(args) as api:
152
+ result = store.cancel(api, request_id)
153
+ output(
154
+ {
155
+ "request_id": request_id,
156
+ "cancelled": result == "cancelled",
157
+ "state": result,
158
+ }
159
+ )
160
+ if result == "submission_unknown":
161
+ message("Cannot cancel: the submission has no saved task ID; use tl wait")
162
+ return 1
163
+ if result == "not_found":
164
+ message(
165
+ "Cannot confirm cancellation: the task was already taken or removed. "
166
+ "The request remains saved for tl wait."
167
+ )
168
+ return 1
169
+ return 0
170
+
171
+
172
+ def configure_parser(
173
+ subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
174
+ ) -> None:
175
+ request = subparsers.add_parser("request", help="Submit a durable agent request")
176
+ connection_options(request)
177
+ request.add_argument("--domain")
178
+ request.add_argument("--topic")
179
+ request.add_argument("--reply-topic")
180
+ request.add_argument("--data", help="JSON object or @file")
181
+ identifiers = request.add_mutually_exclusive_group()
182
+ identifiers.add_argument("--request-id")
183
+ identifiers.add_argument("--resend", metavar="REQUEST_ID")
184
+ request.add_argument("--wait", action="store_true")
185
+ request.add_argument("--timeout", default="6h", help="Wait duration, e.g. 6h")
186
+ request.set_defaults(handler=_request)
187
+
188
+ wait = subparsers.add_parser("wait", help="Wait for a saved request's reply")
189
+ connection_options(wait)
190
+ wait.add_argument("--request-id", required=True)
191
+ wait.add_argument("--timeout", default="6h", help="Wait duration, e.g. 6h")
192
+ wait.set_defaults(handler=_wait)
193
+
194
+ cancel = subparsers.add_parser("cancel", help="Cancel a saved unowned request")
195
+ connection_options(cancel)
196
+ cancel.add_argument("--request-id", required=True)
197
+ cancel.set_defaults(handler=_cancel)