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
@@ -0,0 +1,169 @@
1
+ """Operator-facing deployment commands and complete first-use instructions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import getpass
7
+ import json
8
+ import os
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ from tasklease.cloudflare import DeployError, DeployOptions, deploy, status
13
+ from tasklease.config import load_config
14
+ from tasklease.errors import ConfigError
15
+
16
+ DEPLOY_HELP = """Deploy or upgrade tasklease in your own Cloudflare account.
17
+
18
+ You need Workers and R2 enabled, and Node.js current LTS (with npx).
19
+ tl uses the exact Wrangler version recorded in its bundled manifest.
20
+
21
+ 1. SIGN IN
22
+ Run npx --yes wrangler@4.156.0 login once and approve the browser flow.
23
+ For scripts, use the Cloudflare Edit Cloudflare Workers API token template,
24
+ export CLOUDFLARE_API_TOKEN, and pass --account-id. The token must permit
25
+ creating/updating Workers and managing R2 buckets and lifecycle rules.
26
+
27
+ 2. ACCOUNT ID
28
+ Pass --account-id, set CLOUDFLARE_ACCOUNT_ID, or reuse the saved account.
29
+ Without one, a single account in Wrangler's OAuth sign-in is selected.
30
+ Dashboard: Workers & Pages > Account Details > Account ID.
31
+
32
+ 3. R2 SIGNING KEYS
33
+ Give both TL_R2_ACCESS_KEY_ID and TL_R2_SECRET_ACCESS_KEY, or pass the ID
34
+ with --r2-access-key-id and enter the secret at the private prompt.
35
+ On first use, run without keys: tl creates the bucket and Worker, prints
36
+ and confirms an admin token, then stops with instructions for the keys.
37
+ In R2 Object Storage > Overview > Account Details > Manage API Tokens,
38
+ create an Account API token with Object Read & Write permission scoped
39
+ to the selected bucket. Record the Access Key ID and Secret Access Key.
40
+ OAuth login credentials cannot sign S3 URLs.
41
+
42
+ The service uses https://<name>.<subdomain>.workers.dev. Open Workers & Pages
43
+ once if the account has no workers.dev subdomain. --url supplies that address
44
+ when Wrangler cannot report it. No custom domain is needed.
45
+
46
+ New admin tokens are printed before submission and never saved. Store yours
47
+ in a password manager. If confirmation cannot finish, retain the candidate;
48
+ set TL_ADMIN_TOKEN to it and rerun, or use --rotate-admin-token. A timeout
49
+ does not prove the candidate was rejected or that the old token still works.
50
+
51
+ On upgrades, TL_ADMIN_TOKEN enables the task and 16-byte R2 upload/download
52
+ check. Without it, the deploy reports that verification was skipped. Probe
53
+ identities are revoked; probe blobs expire under the seven-day lifecycle rule.
54
+ Changing the account or bucket requires a new pair of signing keys.
55
+
56
+ --config (or TL_CONFIG) selects every config read and write. Unrelated config
57
+ settings are preserved. Nonsecret coordinates are saved after upload so an
58
+ interrupted bootstrap resumes against the same target. --dry-run performs
59
+ read-only inspection and prints the generated Wrangler config and action plan.
60
+
61
+ Separate test copy:
62
+ tl service deploy --config /tmp/tl-test.toml --name tasklease-test \\
63
+ --bucket tasklease-test-blobs
64
+
65
+ Exit codes: 0 completed; 4 more input needed; 1 failed.
66
+ """
67
+
68
+
69
+ def _text(args: argparse.Namespace, name: str) -> str | None:
70
+ value: object = getattr(args, name, None)
71
+ if value is None:
72
+ return None
73
+ if isinstance(value, Path):
74
+ return str(value)
75
+ if not isinstance(value, str):
76
+ raise DeployError(f"Invalid {name} argument.", 4)
77
+ return value
78
+
79
+
80
+ def _run_deploy(args: argparse.Namespace) -> int:
81
+ try:
82
+ environment = dict(os.environ)
83
+ key_id = _text(args, "r2_access_key_id") or environment.get(
84
+ "TL_R2_ACCESS_KEY_ID"
85
+ )
86
+ if (
87
+ key_id
88
+ and not environment.get("TL_R2_SECRET_ACCESS_KEY")
89
+ and sys.stdin.isatty()
90
+ ):
91
+ environment["TL_R2_SECRET_ACCESS_KEY"] = getpass.getpass(
92
+ "R2 Secret Access Key: "
93
+ )
94
+ result = deploy(
95
+ DeployOptions(
96
+ account_id=_text(args, "account_id"),
97
+ name=_text(args, "name"),
98
+ bucket=_text(args, "bucket"),
99
+ r2_access_key_id=key_id,
100
+ url=_text(args, "url"),
101
+ rotate_admin_token=bool(getattr(args, "rotate_admin_token", False)),
102
+ dry_run=bool(getattr(args, "dry_run", False)),
103
+ ),
104
+ load_config(_text(args, "config"), environ=environment),
105
+ environ=environment,
106
+ )
107
+ except DeployError as exc:
108
+ print(str(exc), file=sys.stderr)
109
+ return exc.exit_code
110
+ except (ConfigError, OSError) as exc:
111
+ print(str(exc), file=sys.stderr)
112
+ return 4 if isinstance(exc, ConfigError) else 1
113
+ print(json.dumps(result))
114
+ return 0
115
+
116
+
117
+ def _run_status(args: argparse.Namespace) -> int:
118
+ try:
119
+ environment = dict(os.environ)
120
+ override = _text(args, "url")
121
+ if override is not None:
122
+ environment["TL_URL"] = override
123
+ result = status(
124
+ load_config(_text(args, "config"), environ=environment),
125
+ environ=environment,
126
+ )
127
+ except DeployError as exc:
128
+ print(str(exc), file=sys.stderr)
129
+ return exc.exit_code
130
+ except (ConfigError, OSError) as exc:
131
+ print(str(exc), file=sys.stderr)
132
+ return 4 if isinstance(exc, ConfigError) else 1
133
+ print(json.dumps(result))
134
+ return 0
135
+
136
+
137
+ def configure_parser(
138
+ subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
139
+ ) -> None:
140
+ service = subparsers.add_parser("service", help="deploy or inspect the service")
141
+ commands = service.add_subparsers(dest="service_command", required=True)
142
+ deployment = commands.add_parser(
143
+ "deploy",
144
+ help="deploy or upgrade the bundled Worker",
145
+ description=DEPLOY_HELP,
146
+ formatter_class=argparse.RawDescriptionHelpFormatter,
147
+ )
148
+ for name, help_text in (
149
+ ("account-id", "Cloudflare account ID"),
150
+ ("name", "Worker name"),
151
+ ("bucket", "R2 bucket name"),
152
+ ("r2-access-key-id", "R2 signing access key ID"),
153
+ ("url", "HTTPS workers.dev address when Wrangler cannot report it"),
154
+ ("config", "tasklease config file (or TL_CONFIG)"),
155
+ ):
156
+ deployment.add_argument(f"--{name}", help=help_text, default=argparse.SUPPRESS)
157
+ deployment.add_argument("--rotate-admin-token", action="store_true")
158
+ deployment.add_argument("--dry-run", action="store_true")
159
+ deployment.set_defaults(handler=_run_deploy)
160
+ inspection = commands.add_parser(
161
+ "status", help="compare service and installed versions"
162
+ )
163
+ inspection.add_argument(
164
+ "--config", default=argparse.SUPPRESS, help="tasklease config file"
165
+ )
166
+ inspection.add_argument(
167
+ "--url", default=argparse.SUPPRESS, help="service origin to inspect"
168
+ )
169
+ inspection.set_defaults(handler=_run_status)
tasklease/cli/tasks.py ADDED
@@ -0,0 +1,175 @@
1
+ """Read, move and prune tasks using their current fenced handles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import time
7
+ from collections.abc import Iterator
8
+
9
+ from tasklease.cli.common import (
10
+ client,
11
+ configuration,
12
+ connection_options,
13
+ domain,
14
+ duration_seconds,
15
+ flag,
16
+ optional_text,
17
+ output,
18
+ text,
19
+ )
20
+ from tasklease.client import Client
21
+ from tasklease.errors import Forbidden, NotFound
22
+ from tasklease.generated.protocol import TasksListRequest, TaskSummary
23
+
24
+
25
+ def _tasks(args: argparse.Namespace) -> int:
26
+ request: TasksListRequest = {
27
+ "domain": domain(args, configuration(args)),
28
+ "topic": text(args, "topic"),
29
+ }
30
+ cursor = optional_text(args, "cursor")
31
+ if cursor is not None:
32
+ request["cursor"] = cursor
33
+ with client(args) as api:
34
+ output(api.tasks_list(request))
35
+ return 0
36
+
37
+
38
+ def _read(args: argparse.Namespace) -> int:
39
+ with client(args) as api:
40
+ output(
41
+ api.read(
42
+ {
43
+ "domain": domain(args, configuration(args)),
44
+ "topic": text(args, "topic"),
45
+ "id": text(args, "id"),
46
+ }
47
+ )
48
+ )
49
+ return 0
50
+
51
+
52
+ def _delete(args: argparse.Namespace) -> int:
53
+ with client(args) as api:
54
+ output(
55
+ api.delete(
56
+ {
57
+ "domain": domain(args, configuration(args)),
58
+ "topic": text(args, "topic"),
59
+ "id": text(args, "id"),
60
+ }
61
+ )
62
+ )
63
+ return 0
64
+
65
+
66
+ def _pollers(args: argparse.Namespace) -> int:
67
+ with client(args) as api:
68
+ output(
69
+ api.topic_pollers(
70
+ {
71
+ "domain": domain(args, configuration(args)),
72
+ "topic": text(args, "topic"),
73
+ }
74
+ )
75
+ )
76
+ return 0
77
+
78
+
79
+ def _move(args: argparse.Namespace) -> int:
80
+ selected, topic, task_id = (
81
+ domain(args, configuration(args)),
82
+ text(args, "topic"),
83
+ text(args, "id"),
84
+ )
85
+ with client(args) as api:
86
+ task = api.read({"domain": selected, "topic": topic, "id": task_id})["task"]
87
+ output(
88
+ api.consume(
89
+ {
90
+ "domain": selected,
91
+ "topic": topic,
92
+ "id": task["id"],
93
+ "tasks": [{"topic": text(args, "to"), "data": task["data"]}],
94
+ }
95
+ )
96
+ )
97
+ return 0
98
+
99
+
100
+ def _age(value: str) -> int:
101
+ return duration_seconds(value, name="Age") * 1000
102
+
103
+
104
+ def _pages(api: Client, selected: str, topic: str) -> Iterator[TaskSummary]:
105
+ request: TasksListRequest = {"domain": selected, "topic": topic}
106
+ while True:
107
+ page = api.tasks_list(request)
108
+ yield from page["tasks"]
109
+ cursor = page.get("cursor")
110
+ if cursor is None:
111
+ return
112
+ request["cursor"] = cursor
113
+
114
+
115
+ def _prune(args: argparse.Namespace) -> int:
116
+ selected = domain(args, configuration(args))
117
+ cutoff = time.time_ns() // 1_000_000 - _age(text(args, "lease_older_than"))
118
+ deleted = skipped = 0
119
+ with client(args) as api:
120
+ topics = api.topics_list({"domain": selected, "glob": text(args, "topic")})[
121
+ "topics"
122
+ ]
123
+ for topic in topics:
124
+ for task in _pages(api, selected, topic["topic"]):
125
+ if task["lease_until"] >= cutoff or (
126
+ task["owned_count"] == 0 and not flag(args, "all")
127
+ ):
128
+ continue
129
+ try:
130
+ api.delete(
131
+ {
132
+ "domain": selected,
133
+ "topic": topic["topic"],
134
+ "id": task["id"],
135
+ "lease_before": cutoff,
136
+ }
137
+ )
138
+ except (NotFound, Forbidden):
139
+ skipped += 1
140
+ else:
141
+ deleted += 1
142
+ output({"ok": True, "deleted": deleted, "skipped": skipped})
143
+ return 0
144
+
145
+
146
+ def configure_parser(
147
+ subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
148
+ ) -> None:
149
+ for name, handler in (
150
+ ("tasks", _tasks),
151
+ ("read", _read),
152
+ ("delete", _delete),
153
+ ("pollers", _pollers),
154
+ ("move", _move),
155
+ ):
156
+ parser = subparsers.add_parser(name)
157
+ connection_options(parser)
158
+ parser.add_argument("domain")
159
+ parser.add_argument("topic")
160
+ if name in {"read", "delete", "move"}:
161
+ parser.add_argument("id")
162
+ if name == "tasks":
163
+ parser.add_argument("--cursor")
164
+ if name == "move":
165
+ parser.add_argument("--to", required=True)
166
+ parser.set_defaults(handler=handler)
167
+ prune = subparsers.add_parser("prune", help="Remove abandoned tasks by age")
168
+ connection_options(prune)
169
+ prune.add_argument("domain")
170
+ prune.add_argument("--topic", required=True, help="Topic glob")
171
+ prune.add_argument("--lease-older-than", required=True)
172
+ prune.add_argument(
173
+ "--all", action="store_true", help="Also include never-owned tasks"
174
+ )
175
+ prune.set_defaults(handler=_prune)
@@ -0,0 +1,65 @@
1
+ """Topic discovery and declarations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from tasklease.cli.common import (
8
+ client,
9
+ configuration,
10
+ connection_options,
11
+ domain,
12
+ flag,
13
+ optional_text,
14
+ output,
15
+ text,
16
+ )
17
+ from tasklease.generated.protocol import TopicsListRequest
18
+
19
+
20
+ def _topics(args: argparse.Namespace) -> int:
21
+ selected = optional_text(args, "domain")
22
+ with client(args) as api:
23
+ domains = [selected] if selected else api.identity_whoami({})["domains"]
24
+ results: list[object] = []
25
+ for name in domains:
26
+ request: TopicsListRequest = {"domain": name}
27
+ glob = optional_text(args, "glob")
28
+ if glob is not None:
29
+ request["glob"] = glob
30
+ results.append({"domain": name, **api.topics_list(request)})
31
+ output(results)
32
+ return 0
33
+
34
+
35
+ def _declare(args: argparse.Namespace) -> int:
36
+ config = configuration(args)
37
+ with client(args) as api:
38
+ output(
39
+ api.topic_declare(
40
+ {
41
+ "domain": domain(args, config),
42
+ "topic": text(args, "topic"),
43
+ "declared": not flag(args, "remove"),
44
+ }
45
+ )
46
+ )
47
+ return 0
48
+
49
+
50
+ def configure_parser(
51
+ subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
52
+ ) -> None:
53
+ topics = subparsers.add_parser("topics", help="List topics across domains")
54
+ connection_options(topics)
55
+ topics.add_argument("--domain")
56
+ topics.add_argument("--glob")
57
+ topics.set_defaults(handler=_topics)
58
+ topic = subparsers.add_parser("topic", help="Declare well-known topics")
59
+ commands = topic.add_subparsers(dest="topic_command", required=True)
60
+ declare = commands.add_parser("declare")
61
+ connection_options(declare)
62
+ declare.add_argument("domain")
63
+ declare.add_argument("topic")
64
+ declare.add_argument("--remove", action="store_true", help="Remove the declaration")
65
+ declare.set_defaults(handler=_declare)
@@ -0,0 +1,186 @@
1
+ """Run a generic worker or request a graceful local stop."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import importlib
7
+ import sys
8
+ from collections.abc import Callable
9
+ from importlib.resources import files
10
+ from pathlib import Path
11
+ from threading import TIMEOUT_MAX
12
+ from typing import cast
13
+
14
+ from tasklease.cli.common import (
15
+ client,
16
+ configuration,
17
+ connection_options,
18
+ duration_seconds,
19
+ flag,
20
+ optional_text,
21
+ output,
22
+ text,
23
+ )
24
+ from tasklease.errors import ConfigError
25
+ from tasklease.worker import (
26
+ EchoHandler,
27
+ Handler,
28
+ UnlimitedCapacity,
29
+ WorkerLoop,
30
+ validate_work_topic,
31
+ )
32
+ from tasklease.worker_control import WorkerControl, request_stop
33
+
34
+ _TEMPLATES = {
35
+ "systemd": "systemd.service",
36
+ "launchd": "launchd.plist",
37
+ "windows": "windows.xml",
38
+ "windows-stop": "windows-stop.ps1",
39
+ }
40
+
41
+
42
+ def _template(args: argparse.Namespace) -> int:
43
+ resource = files("tasklease").joinpath(
44
+ "worker_templates", _TEMPLATES[text(args, "platform")]
45
+ )
46
+ sys.stdout.write(resource.read_text(encoding="utf-8"))
47
+ return 0
48
+
49
+
50
+ def _table(values: dict[str, object]) -> dict[str, object]:
51
+ value = values.get("worker", {})
52
+ if not isinstance(value, dict):
53
+ raise ConfigError("worker must be a TOML table")
54
+ return cast(dict[str, object], value)
55
+
56
+
57
+ def _setting(
58
+ args: argparse.Namespace, settings: dict[str, object], name: str, default: object
59
+ ) -> object:
60
+ selected: object = getattr(
61
+ args, "worker_handler" if name == "handler" else name, None
62
+ )
63
+ return settings.get(name, default) if selected is None else selected
64
+
65
+
66
+ def _text(value: object, name: str) -> str:
67
+ if not isinstance(value, str) or not value:
68
+ raise ConfigError(f"worker {name} must be a nonempty string")
69
+ return value
70
+
71
+
72
+ def _seconds(value: object, name: str) -> int:
73
+ if type(value) is int and value > 0:
74
+ return value
75
+ if isinstance(value, str):
76
+ return duration_seconds(value, name=name)
77
+ raise ConfigError(f"worker {name} must be positive seconds or a duration")
78
+
79
+
80
+ def _handler(value: str) -> Handler[object, object]:
81
+ if value == "echo":
82
+ return cast(Handler[object, object], EchoHandler())
83
+ module_name, separator, factory_name = value.partition(":")
84
+ if not separator or not module_name or not factory_name.isidentifier():
85
+ raise ConfigError("worker handler must be echo or module:factory")
86
+ try:
87
+ module = importlib.import_module(module_name)
88
+ factory: object = getattr(module, factory_name)
89
+ if not callable(factory):
90
+ raise TypeError("Factory is not callable")
91
+ instance = cast(Callable[[], object], factory)()
92
+ except Exception:
93
+ raise ConfigError(f"Cannot load worker handler {value}") from None
94
+ if not all(
95
+ callable(getattr(instance, name, None)) for name in ("validate", "run", "reply")
96
+ ):
97
+ raise ConfigError("worker handler must provide validate, run and reply")
98
+ return cast(Handler[object, object], instance)
99
+
100
+
101
+ def _root(args: argparse.Namespace) -> Path | None:
102
+ value: object = getattr(args, "state_dir", None)
103
+ if value is not None and not isinstance(value, Path):
104
+ raise ConfigError("state-dir must be a path")
105
+ return value
106
+
107
+
108
+ def _stop(args: argparse.Namespace) -> int:
109
+ label = optional_text(args, "worker") or configuration(args).worker_label
110
+ path = request_stop(label, _root(args))
111
+ output({"ok": True, "worker": label, "stop_file": str(path)})
112
+ return 0
113
+
114
+
115
+ def _run(args: argparse.Namespace) -> int:
116
+ config = configuration(args)
117
+ settings = _table(config.values)
118
+ selected = _text(optional_text(args, "domain") or config.domain, "domain")
119
+ topic = validate_work_topic(_text(_setting(args, settings, "topic", None), "topic"))
120
+ lease = _seconds(_setting(args, settings, "lease", 600), "lease")
121
+ renew = _seconds(_setting(args, settings, "renew_every", 180), "renew_every")
122
+ poll = _seconds(_setting(args, settings, "poll_interval", 30), "poll_interval")
123
+ if poll > TIMEOUT_MAX / 1.2:
124
+ raise ConfigError("worker poll_interval exceeds this platform's timer limit")
125
+ maximum = _setting(args, settings, "max_owned", 3)
126
+ if type(maximum) is not int or maximum <= 0:
127
+ raise ConfigError("worker max_owned must be a positive integer")
128
+ if lease > 86400 or renew >= lease:
129
+ raise ConfigError(
130
+ "worker lease must be at most 86400s and renew_every below lease"
131
+ )
132
+ handler = _handler(_text(_setting(args, settings, "handler", "echo"), "handler"))
133
+ label = optional_text(args, "worker") or config.worker_label
134
+ with WorkerControl(label, _root(args)) as control:
135
+ if control.stop.is_set():
136
+ return 0
137
+ with client(args) as api:
138
+ capacity = UnlimitedCapacity()
139
+ loop = WorkerLoop(
140
+ api,
141
+ selected,
142
+ topic,
143
+ capacity,
144
+ handler,
145
+ lease=lease,
146
+ renew_every=renew,
147
+ max_owned=maximum,
148
+ poll_interval=poll,
149
+ stop=control.stop,
150
+ )
151
+ if flag(args, "once"):
152
+ with capacity.acquire():
153
+ result = loop.step()
154
+ output({"status": result})
155
+ else:
156
+ loop.run()
157
+ return 0
158
+
159
+
160
+ def configure_parser(
161
+ subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
162
+ ) -> None:
163
+ parser = subparsers.add_parser("worker", help="Run an echo or plugin worker")
164
+ connection_options(parser)
165
+ parser.add_argument("--domain")
166
+ parser.add_argument("--topic")
167
+ parser.add_argument(
168
+ "--handler", dest="worker_handler", help="echo or module:factory"
169
+ )
170
+ parser.add_argument("--lease", help="Lease duration, default 600s")
171
+ parser.add_argument("--renew-every", help="Renew interval, default 180s")
172
+ parser.add_argument("--max-owned", type=int)
173
+ parser.add_argument("--poll-interval", help="Idle poll duration, default 30s")
174
+ parser.add_argument("--once", action="store_true", help="Own at most one task")
175
+ parser.add_argument("--state-dir", type=Path)
176
+ parser.set_defaults(handler=_run)
177
+ commands = parser.add_subparsers(dest="worker_command")
178
+ stop = commands.add_parser("stop", help="Request a graceful stop without a token")
179
+ connection_options(stop)
180
+ stop.add_argument("--state-dir", type=Path, default=argparse.SUPPRESS)
181
+ stop.set_defaults(handler=_stop)
182
+ template = commands.add_parser(
183
+ "template", help="Print a user-worker startup template for editing"
184
+ )
185
+ template.add_argument("--platform", choices=tuple(_TEMPLATES), required=True)
186
+ template.set_defaults(handler=_template)