regents-cli 1.0.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 (200) hide show
  1. regents_cli/__init__.py +1 -0
  2. regents_cli/__main__.py +3 -0
  3. regents_cli/app.py +94 -0
  4. regents_cli/auth.py +161 -0
  5. regents_cli/check_commands.py +146 -0
  6. regents_cli/doctor.py +95 -0
  7. regents_cli/errors.py +47 -0
  8. regents_cli/http.py +148 -0
  9. regents_cli/output.py +253 -0
  10. regents_cli/platforms/__init__.py +146 -0
  11. regents_cli/platforms/patchbay/agent-payments.openapi.json +273 -0
  12. regents_cli/platforms/patchbay/commands.json +157 -0
  13. regents_cli/platforms/patchbay/openapi.json +5662 -0
  14. regents_cli/platforms/protocol/commands.json +57 -0
  15. regents_cli/platforms/protocol/openapi.json +1399 -0
  16. regents_cli/runner.py +286 -0
  17. regents_cli/schemas/__init__.py +0 -0
  18. regents_cli/schemas/commands.v1.json +272 -0
  19. regents_cli/siwa.py +342 -0
  20. regents_cli/techtree/__init__.py +1 -0
  21. regents_cli/techtree/approval.py +98 -0
  22. regents_cli/techtree/canonical.py +142 -0
  23. regents_cli/techtree/catalog/__init__.py +1 -0
  24. regents_cli/techtree/catalog/repository.py +270 -0
  25. regents_cli/techtree/catalog/service.py +285 -0
  26. regents_cli/techtree/cli.py +46 -0
  27. regents_cli/techtree/commands/__init__.py +1 -0
  28. regents_cli/techtree/commands/answers.py +30 -0
  29. regents_cli/techtree/commands/climb.py +412 -0
  30. regents_cli/techtree/commands/doctor.py +109 -0
  31. regents_cli/techtree/commands/engine.py +96 -0
  32. regents_cli/techtree/commands/forge.py +1477 -0
  33. regents_cli/techtree/commands/internal.py +58 -0
  34. regents_cli/techtree/commands/proof.py +259 -0
  35. regents_cli/techtree/commands/publish.py +192 -0
  36. regents_cli/techtree/commands/release.py +153 -0
  37. regents_cli/techtree/commands/run.py +375 -0
  38. regents_cli/techtree/commands/setup.py +71 -0
  39. regents_cli/techtree/commands/skill.py +87 -0
  40. regents_cli/techtree/commands/uplift.py +405 -0
  41. regents_cli/techtree/commands/withdraw.py +99 -0
  42. regents_cli/techtree/constants.py +32 -0
  43. regents_cli/techtree/crypto.py +84 -0
  44. regents_cli/techtree/doctor/__init__.py +1 -0
  45. regents_cli/techtree/doctor/checks.py +630 -0
  46. regents_cli/techtree/doctor/service.py +99 -0
  47. regents_cli/techtree/drafts/__init__.py +1 -0
  48. regents_cli/techtree/drafts/source.py +64 -0
  49. regents_cli/techtree/drafts/store.py +584 -0
  50. regents_cli/techtree/engines/__init__.py +1 -0
  51. regents_cli/techtree/engines/bundle.py +154 -0
  52. regents_cli/techtree/engines/installer.py +366 -0
  53. regents_cli/techtree/engines/registry.py +130 -0
  54. regents_cli/techtree/engines/runner.py +139 -0
  55. regents_cli/techtree/errors.py +123 -0
  56. regents_cli/techtree/execution_facts.py +151 -0
  57. regents_cli/techtree/forge/__init__.py +1 -0
  58. regents_cli/techtree/forge/builds.py +144 -0
  59. regents_cli/techtree/forge/calls.py +547 -0
  60. regents_cli/techtree/forge/capture.py +457 -0
  61. regents_cli/techtree/forge/collection.py +363 -0
  62. regents_cli/techtree/forge/comparability.py +78 -0
  63. regents_cli/techtree/forge/compare.py +460 -0
  64. regents_cli/techtree/forge/construction.py +818 -0
  65. regents_cli/techtree/forge/content.py +187 -0
  66. regents_cli/techtree/forge/docker.py +351 -0
  67. regents_cli/techtree/forge/experiment.py +137 -0
  68. regents_cli/techtree/forge/export.py +157 -0
  69. regents_cli/techtree/forge/improvement.py +434 -0
  70. regents_cli/techtree/forge/models.py +1713 -0
  71. regents_cli/techtree/forge/planning.py +444 -0
  72. regents_cli/techtree/forge/qualify.py +502 -0
  73. regents_cli/techtree/forge/records.py +64 -0
  74. regents_cli/techtree/forge/report.py +626 -0
  75. regents_cli/techtree/forge/revision.py +575 -0
  76. regents_cli/techtree/forge/run.py +501 -0
  77. regents_cli/techtree/forge/skill.py +154 -0
  78. regents_cli/techtree/forge/skill2env.py +512 -0
  79. regents_cli/techtree/forge/source.py +371 -0
  80. regents_cli/techtree/fs.py +149 -0
  81. regents_cli/techtree/identity/__init__.py +1 -0
  82. regents_cli/techtree/identity/models.py +68 -0
  83. regents_cli/techtree/identity/service.py +149 -0
  84. regents_cli/techtree/identity/store.py +181 -0
  85. regents_cli/techtree/ids.py +68 -0
  86. regents_cli/techtree/manifests/__init__.py +1 -0
  87. regents_cli/techtree/manifests/builder.py +311 -0
  88. regents_cli/techtree/manifests/compare.py +236 -0
  89. regents_cli/techtree/models/__init__.py +1 -0
  90. regents_cli/techtree/models/base.py +121 -0
  91. regents_cli/techtree/models/campaign.py +335 -0
  92. regents_cli/techtree/models/catalog.py +164 -0
  93. regents_cli/techtree/models/climb.py +195 -0
  94. regents_cli/techtree/models/data_policy.py +90 -0
  95. regents_cli/techtree/models/engine.py +110 -0
  96. regents_cli/techtree/models/episode_receipt.py +96 -0
  97. regents_cli/techtree/models/execution_plan.py +80 -0
  98. regents_cli/techtree/models/experiment.py +115 -0
  99. regents_cli/techtree/models/run.py +161 -0
  100. regents_cli/techtree/models/skill.py +112 -0
  101. regents_cli/techtree/models/uplift_report.py +132 -0
  102. regents_cli/techtree/models/validation.py +179 -0
  103. regents_cli/techtree/paths.py +121 -0
  104. regents_cli/techtree/pointers.py +23 -0
  105. regents_cli/techtree/presentation/__init__.py +1 -0
  106. regents_cli/techtree/presentation/build.py +661 -0
  107. regents_cli/techtree/presentation/compact.py +121 -0
  108. regents_cli/techtree/presentation/evidence.py +130 -0
  109. regents_cli/techtree/presentation/models.py +186 -0
  110. regents_cli/techtree/presentation/sanitize.py +82 -0
  111. regents_cli/techtree/publication/__init__.py +1 -0
  112. regents_cli/techtree/publication/address.py +98 -0
  113. regents_cli/techtree/publication/coordinates.py +11 -0
  114. regents_cli/techtree/publication/downloaded.py +113 -0
  115. regents_cli/techtree/publication/journal.py +136 -0
  116. regents_cli/techtree/publication/models.py +98 -0
  117. regents_cli/techtree/publication/offer.py +20 -0
  118. regents_cli/techtree/publication/service.py +418 -0
  119. regents_cli/techtree/publication/transport.py +168 -0
  120. regents_cli/techtree/publication/verify.py +146 -0
  121. regents_cli/techtree/publication/withdraw.py +100 -0
  122. regents_cli/techtree/receipts/__init__.py +1 -0
  123. regents_cli/techtree/receipts/bundle.py +378 -0
  124. regents_cli/techtree/receipts/compare.py +686 -0
  125. regents_cli/techtree/receipts/episode.py +404 -0
  126. regents_cli/techtree/receipts/execution.py +335 -0
  127. regents_cli/techtree/receipts/observed.py +323 -0
  128. regents_cli/techtree/receipts/set.py +238 -0
  129. regents_cli/techtree/receipts/uplift.py +449 -0
  130. regents_cli/techtree/receipts/verify.py +879 -0
  131. regents_cli/techtree/release/__init__.py +1 -0
  132. regents_cli/techtree/release/checks.py +299 -0
  133. regents_cli/techtree/release/document.py +80 -0
  134. regents_cli/techtree/release/models.py +133 -0
  135. regents_cli/techtree/release/provenance.py +49 -0
  136. regents_cli/techtree/resources/catalog/campaigns/hello-world-climb.json +1 -0
  137. regents_cli/techtree/resources/catalog/catalog.json +37 -0
  138. regents_cli/techtree/resources/catalog/climbs/hello-world-climb.json +1 -0
  139. regents_cli/techtree/resources/catalog/data-policies/hello-world-climb.json +1 -0
  140. regents_cli/techtree/resources/catalog/execution-plans/hello-world-climb.json +1 -0
  141. regents_cli/techtree/resources/catalog/taskset-validations/hello-world-climb.json +1 -0
  142. regents_cli/techtree/resources/catalog/validation-evidence/hello-world-climb.json +1 -0
  143. regents_cli/techtree/resources/engines/default/engine.json +20 -0
  144. regents_cli/techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/__init__.py +7 -0
  145. regents_cli/techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/algorithm.py +136 -0
  146. regents_cli/techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/dataset.py +156 -0
  147. regents_cli/techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/env.py +48 -0
  148. regents_cli/techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/taskset.py +162 -0
  149. regents_cli/techtree/resources/engines/default/packages/procedure-transfer-v1/pyproject.toml +13 -0
  150. regents_cli/techtree/resources/engines/default/pyproject.toml +22 -0
  151. regents_cli/techtree/resources/engines/default/tools/inspect_taskset.py +124 -0
  152. regents_cli/techtree/resources/engines/default/tools/normalize_eval_output.py +476 -0
  153. regents_cli/techtree/resources/engines/default/tools/normalize_validation.py +222 -0
  154. regents_cli/techtree/resources/engines/default/uv.lock +1583 -0
  155. regents_cli/techtree/resources/forge/skill2env/LICENSE +204 -0
  156. regents_cli/techtree/resources/forge/skill2env/README.md +124 -0
  157. regents_cli/techtree/resources/forge/skill2env/base-images.json +7 -0
  158. regents_cli/techtree/resources/forge/skill2env/contract.json +16 -0
  159. regents_cli/techtree/resources/forge/skill2env/creator-prompt.md +98 -0
  160. regents_cli/techtree/resources/forge/skill2env/planner-prompt.md +83 -0
  161. regents_cli/techtree/resources/release/build-provenance.json +4 -0
  162. regents_cli/techtree/resources/release/release-core.json +23 -0
  163. regents_cli/techtree/runs/__init__.py +1 -0
  164. regents_cli/techtree/runs/artifacts.py +424 -0
  165. regents_cli/techtree/runs/child_registry.py +121 -0
  166. regents_cli/techtree/runs/events.py +288 -0
  167. regents_cli/techtree/runs/executor.py +50 -0
  168. regents_cli/techtree/runs/launcher.py +102 -0
  169. regents_cli/techtree/runs/machine.py +353 -0
  170. regents_cli/techtree/runs/real.py +557 -0
  171. regents_cli/techtree/runs/report.py +412 -0
  172. regents_cli/techtree/runs/service.py +478 -0
  173. regents_cli/techtree/runs/store.py +430 -0
  174. regents_cli/techtree/runs/validation.py +117 -0
  175. regents_cli/techtree/runs/variants.py +378 -0
  176. regents_cli/techtree/runs/worker.py +200 -0
  177. regents_cli/techtree/skills/__init__.py +1 -0
  178. regents_cli/techtree/skills/scanner.py +277 -0
  179. regents_cli/techtree/skills/service.py +429 -0
  180. regents_cli/techtree/skills/starter.py +224 -0
  181. regents_cli/techtree/tasksets/__init__.py +1 -0
  182. regents_cli/techtree/tasksets/membership.py +28 -0
  183. regents_cli/techtree/verifiers/__init__.py +1 -0
  184. regents_cli/techtree/verifiers/budget.py +133 -0
  185. regents_cli/techtree/verifiers/child.py +425 -0
  186. regents_cli/techtree/verifiers/compiler.py +295 -0
  187. regents_cli/techtree/verifiers/config.py +224 -0
  188. regents_cli/techtree/verifiers/credentials.py +107 -0
  189. regents_cli/techtree/verifiers/image.py +90 -0
  190. regents_cli/techtree/verifiers/models.py +255 -0
  191. regents_cli/techtree/verifiers/outputs.py +259 -0
  192. regents_cli/techtree/verifiers/paths.py +98 -0
  193. regents_cli/techtree/verifiers/progress.py +97 -0
  194. regents_cli/techtree/verifiers/supervisor.py +242 -0
  195. regents_cli/techtree/verifiers/verify.py +573 -0
  196. regents_cli-1.0.0.dist-info/METADATA +131 -0
  197. regents_cli-1.0.0.dist-info/RECORD +200 -0
  198. regents_cli-1.0.0.dist-info/WHEEL +4 -0
  199. regents_cli-1.0.0.dist-info/entry_points.txt +2 -0
  200. regents_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1 @@
1
+ """The regents command line."""
@@ -0,0 +1,3 @@
1
+ from regents_cli.app import run
2
+
3
+ run()
regents_cli/app.py ADDED
@@ -0,0 +1,94 @@
1
+ """The `regents` executable: one namespace per site, one answer shape, one set of exit codes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from collections.abc import Sequence
7
+ from importlib.metadata import version
8
+
9
+ import click
10
+
11
+ from regents_cli import output
12
+ from regents_cli.auth import auth_group
13
+ from regents_cli.errors import (
14
+ EXIT_CANCELLED,
15
+ EXIT_CODES,
16
+ EXIT_FAILED,
17
+ EXIT_OK,
18
+ CommandError,
19
+ UsageError,
20
+ )
21
+ from regents_cli.platforms import pinned_platforms
22
+ from regents_cli.runner import platform_group
23
+ from regents_cli.techtree.cli import techtree_group
24
+
25
+ EPILOG = "Add --json to any command for machine output.\n\nExit codes:\n\n" + "\n\n".join(
26
+ f" {code} {meaning}" for code, meaning in EXIT_CODES.items()
27
+ )
28
+
29
+
30
+ def root() -> click.Group:
31
+ group = click.Group(
32
+ "regents",
33
+ help="Every Regents Labs site from one command line.",
34
+ epilog=EPILOG,
35
+ no_args_is_help=True,
36
+ params=[
37
+ click.Option(
38
+ ["--version"],
39
+ is_flag=True,
40
+ expose_value=False,
41
+ is_eager=True,
42
+ callback=_print_version,
43
+ help="Print the version.",
44
+ )
45
+ ],
46
+ )
47
+ platforms = pinned_platforms()
48
+ group.add_command(
49
+ auth_group(
50
+ [p.site for p in platforms if any(c.authority == "wallet-proof" for c in p.commands)]
51
+ )
52
+ )
53
+ for platform in platforms:
54
+ group.add_command(platform_group(platform))
55
+ group.add_command(techtree_group())
56
+ return group
57
+
58
+
59
+ def _print_version(ctx: click.Context, _param: click.Parameter, value: bool) -> None:
60
+ if value:
61
+ click.echo(f"regents {version('regents-cli')}")
62
+ ctx.exit()
63
+
64
+
65
+ def main(argv: Sequence[str] | None = None) -> int:
66
+ args = list(sys.argv[1:] if argv is None else argv)
67
+ as_json = "--json" in args[: args.index("--") if "--" in args else len(args)]
68
+ try:
69
+ root().main(args, prog_name="regents", standalone_mode=False)
70
+ except click.exceptions.NoArgsIsHelpError as shown:
71
+ click.echo(shown.ctx.get_help() if shown.ctx else "")
72
+ except click.UsageError as error:
73
+ problem = UsageError(error.format_message())
74
+ output.emit_error(problem, as_json=as_json)
75
+ return problem.exit_code
76
+ except CommandError as error:
77
+ output.emit_error(error, as_json=as_json)
78
+ return error.exit_code
79
+ except (click.Abort, KeyboardInterrupt):
80
+ output.emit_error(
81
+ CommandError("cancelled", "The command was interrupted.", exit_code=EXIT_CANCELLED),
82
+ as_json=as_json,
83
+ )
84
+ return EXIT_CANCELLED
85
+ except click.exceptions.Exit as done:
86
+ return done.exit_code
87
+ except click.ClickException as error:
88
+ output.emit_error(CommandError("command_error", error.format_message()), as_json=as_json)
89
+ return EXIT_FAILED
90
+ return EXIT_OK
91
+
92
+
93
+ def run() -> None:
94
+ sys.exit(main())
regents_cli/auth.py ADDED
@@ -0,0 +1,161 @@
1
+ """`regents auth`: sign in to a site with the agent key on this machine, or with your own."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import replace
6
+ from typing import Any
7
+
8
+ import click
9
+
10
+ from regents_cli import output, siwa
11
+ from regents_cli.errors import CommandError, UsageError
12
+ from regents_cli.http import Request, base_address, send
13
+ from regents_cli.platforms import pinned_platforms
14
+ from regents_cli.runner import read_stdin
15
+
16
+ TIMEOUT = click.Option(
17
+ ["--timeout-ms"],
18
+ type=click.IntRange(1, 300_000),
19
+ default=30_000,
20
+ show_default=True,
21
+ help="How long to wait for each answer.",
22
+ )
23
+ JSON = click.Option(["--json", "as_json"], is_flag=True, help="Print the answer as JSON.")
24
+
25
+
26
+ def auth_group(sites: list[str]) -> click.Group:
27
+ site = click.Option(
28
+ ["--site"], type=click.Choice(sites), required=True, help="The site to sign in to."
29
+ )
30
+ group = click.Group(
31
+ "auth",
32
+ help="Sign in to a site with a wallet. One agent key on this machine signs every "
33
+ f"request; it lives in {siwa.key_file()}.",
34
+ no_args_is_help=True,
35
+ )
36
+ group.add_command(
37
+ click.Command(
38
+ "login",
39
+ callback=login,
40
+ params=[
41
+ site,
42
+ click.Option(
43
+ ["--phase"],
44
+ type=click.Choice(["prepare", "send"]),
45
+ help="Sign in with your own key: prepare prints the message to sign; "
46
+ 'send reads {"wallet_address", "nonce", "message", "signature"} on stdin.',
47
+ ),
48
+ click.Option(
49
+ ["--wallet-address"], help="Your own wallet's address, with --phase prepare."
50
+ ),
51
+ click.Option(
52
+ ["--siwa-url"],
53
+ default=siwa.BROKER,
54
+ show_default=True,
55
+ help="The sign-in server.",
56
+ ),
57
+ JSON,
58
+ TIMEOUT,
59
+ ],
60
+ help="Sign in to a site. Creates the agent key on first use.",
61
+ )
62
+ )
63
+ group.add_command(
64
+ click.Command(
65
+ "status",
66
+ callback=status,
67
+ params=[JSON, TIMEOUT],
68
+ help="Show the agent key's address and each site's sign-in. Signed in to Regents, "
69
+ "it also shows the account the agent is paired with, and Regents counts that as "
70
+ "the agent checking in.",
71
+ )
72
+ )
73
+ group.add_command(
74
+ click.Command(
75
+ "logout",
76
+ callback=logout,
77
+ params=[site, JSON],
78
+ help="Forget a site's sign-in. The agent key stays.",
79
+ )
80
+ )
81
+ return group
82
+
83
+
84
+ def login(
85
+ site: str,
86
+ phase: str | None,
87
+ wallet_address: str | None,
88
+ siwa_url: str,
89
+ as_json: bool,
90
+ timeout_ms: int,
91
+ ) -> None:
92
+ if (phase == "prepare") != (wallet_address is not None):
93
+ raise UsageError("--wallet-address goes with --phase prepare, and only with it.")
94
+ if phase == "prepare" and wallet_address is not None:
95
+ output.emit(siwa.challenge(site, wallet_address, siwa_url, timeout_ms), as_json=as_json)
96
+ return
97
+ created = False
98
+ if phase == "send":
99
+ signed = read_stdin(timeout_ms)
100
+ fields = {"wallet_address", "nonce", "message", "signature"}
101
+ if not isinstance(signed, dict) or set(signed) != fields:
102
+ raise UsageError(
103
+ 'stdin must hold {"wallet_address", "nonce", "message", "signature"}: the '
104
+ "answer of --phase prepare with the message's signature added."
105
+ )
106
+ sign_in = siwa.verify(site, signed, siwa_url, timeout_ms, local=False)
107
+ else:
108
+ key = siwa.load_key()
109
+ if key is None:
110
+ key, created = siwa.create_key(), True
111
+ sign_in = siwa.sign_in_with_key(site, key, siwa_url, timeout_ms)
112
+ answer: dict[str, Any] = {
113
+ "site": site,
114
+ "wallet_address": sign_in.wallet_address,
115
+ "signed_in_until": sign_in.expires_at,
116
+ "signs": "this machine" if sign_in.local else "you",
117
+ }
118
+ if created:
119
+ answer["key_created"] = str(siwa.key_file())
120
+ output.emit(answer, as_json=as_json)
121
+
122
+
123
+ def status(as_json: bool, timeout_ms: int) -> None:
124
+ key = siwa.load_key()
125
+ sign_ins = siwa.sign_ins()
126
+ answer: dict[str, Any] = {
127
+ "agent_key": key.address if key is not None else None,
128
+ "sign_ins": [
129
+ {
130
+ "site": site,
131
+ "wallet_address": sign_in.wallet_address,
132
+ "signed_in_until": sign_in.expires_at,
133
+ "fresh": sign_in.fresh(),
134
+ "signs": "this machine" if sign_in.local else "you",
135
+ }
136
+ for site, sign_in in sign_ins.items()
137
+ ],
138
+ }
139
+ if "regents" in sign_ins:
140
+ answer["paired_with"] = paired_account(timeout_ms)
141
+ output.emit(answer, as_json=as_json)
142
+
143
+
144
+ def paired_account(timeout_ms: int) -> dict[str, Any] | None:
145
+ """The agent's pairing on Regents, as `regents protocol agents me` reads it; None unpaired."""
146
+ platform = next(p for p in pinned_platforms() if p.site == "regents")
147
+ me = next(c for c in platform.commands if c.words == ("agents", "me"))
148
+ request = Request(me.method, me.path, {}, None)
149
+ request = replace(request, headers=siwa.sign(request, siwa.current("regents", timeout_ms)))
150
+ try:
151
+ answer = send(base_address(None, platform.env_var, platform.base_url), request, timeout_ms)
152
+ except CommandError as error:
153
+ if error.code == "not_paired":
154
+ return None
155
+ raise
156
+ paired: dict[str, Any] = answer["data"]
157
+ return paired
158
+
159
+
160
+ def logout(site: str, as_json: bool) -> None:
161
+ output.emit({"site": site, "signed_out": siwa.remove_sign_in(site)}, as_json=as_json)
@@ -0,0 +1,146 @@
1
+ """Check site command descriptions against the format and the site's own OpenAPI documents.
2
+
3
+ python -m regents_cli.check_commands
4
+ every pinned platforms/<name>/commands.json, with the documents pinned beside it
5
+ python -m regents_cli.check_commands <commands.json> <openapi file>...
6
+ one site's description, for the site's own `make check-cli`
7
+
8
+ A site runs it at a pinned regents-cli commit, with nothing checked out:
9
+
10
+ uv run --no-project \\
11
+ --with "regents-cli[check] @ git+https://github.com/regents-ai/regents-cli@<commit>" \\
12
+ python -m regents_cli.check_commands cli/commands.json <openapi.json>
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import re
19
+ import sys
20
+ from collections import Counter
21
+ from importlib.resources import files
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ import yaml
26
+ from jsonschema import Draft202012Validator
27
+
28
+ SHARED_FLAGS = frozenset({"json", "help", "version", "base-url", "timeout-ms", "phase"})
29
+ METHODS = ("get", "put", "post", "patch", "delete")
30
+
31
+
32
+ def read_document(path: Path) -> Any:
33
+ text = path.read_text("utf-8")
34
+ return json.loads(text) if path.suffix == ".json" else yaml.safe_load(text)
35
+
36
+
37
+ def operations_in(documents: list[Any]) -> dict[str, tuple[str, str]]:
38
+ found = {}
39
+ for document in documents:
40
+ for route, item in (document.get("paths") or {}).items():
41
+ for method in METHODS:
42
+ operation = (item or {}).get(method) or {}
43
+ if "operationId" in operation:
44
+ found[operation["operationId"]] = (method.upper(), route)
45
+ return found
46
+
47
+
48
+ def problems_in(description: Any, operations: dict[str, tuple[str, str]]) -> list[str]:
49
+ schema = json.loads(
50
+ files("regents_cli.schemas").joinpath("commands.v1.json").read_text("utf-8")
51
+ )
52
+ errors = sorted(Draft202012Validator(schema).iter_errors(description), key=lambda e: e.path)
53
+ if errors:
54
+ return [f"/{'/'.join(map(str, e.absolute_path))} {e.message}" for e in errors]
55
+
56
+ problems = []
57
+ shapes = Counter(re.sub(r"<[^>]+>", "<>", c["command"]) for c in description["commands"])
58
+ problems += [f'two commands have the shape "{shape}"' for shape, n in shapes.items() if n > 1]
59
+
60
+ for entry in description["commands"]:
61
+
62
+ def say(message: str, entry: dict[str, Any] = entry) -> None:
63
+ problems.append(f"{entry['command']}: {message}")
64
+
65
+ args = entry.get("arguments", [])
66
+ flags = entry.get("flags", [])
67
+ flag_names = [f["name"] for f in flags]
68
+ inputs = args + flags
69
+
70
+ placeholders = [w[1:-1] for w in entry["command"].split(" ") if w.startswith("<")]
71
+ if placeholders != [a["name"] for a in args]:
72
+ say("arguments must match the <words> in the command, in order")
73
+ for name, n in Counter(i["name"] for i in inputs).items():
74
+ if n > 1:
75
+ say(f"input {name} is listed twice")
76
+ if entry["command"].split(" ")[0] == "doctor":
77
+ say("doctor is regents' own command for every site")
78
+ for name in flag_names:
79
+ if name in SHARED_FLAGS:
80
+ say(f"--{name} is a shared flag")
81
+
82
+ path_fields = sorted(i["field"] for i in inputs if i["in"] == "path")
83
+ if path_fields != sorted(re.findall(r"\{([^}]+)\}", entry["path"])):
84
+ say(
85
+ f"path inputs ({', '.join(path_fields) or 'none'}) "
86
+ f"must fill exactly {entry['path']}"
87
+ )
88
+ for i in inputs:
89
+ if "enum" in i and i["type"] != "string":
90
+ say(f"{i['name']}: only string inputs take enum")
91
+ if ("minimum" in i or "maximum" in i) and i["type"] != "integer":
92
+ say(f"{i['name']}: only integer inputs take minimum and maximum")
93
+
94
+ sends_body = (
95
+ any(i["in"] == "body" for i in inputs) or "body" in entry or "stdin_fields" in entry
96
+ )
97
+ if sends_body and entry["method"] in ("GET", "DELETE"):
98
+ say(f"{entry['method']} sends no body")
99
+ if "stdin_fields" in entry and entry["authority"] != "wallet-proof":
100
+ say("only wallet-proof commands read body fields from stdin")
101
+ for name in entry.get("required_one_of", []):
102
+ if name not in flag_names:
103
+ say(f"required_one_of names --{name}, which is not a flag")
104
+ pagination = entry.get("pagination")
105
+ if pagination and pagination["flag"] not in flag_names:
106
+ say(f"pagination passes the cursor as --{pagination['flag']}, which is not a flag")
107
+
108
+ operation = operations.get(entry["operation_id"])
109
+ if operation is None:
110
+ say(f"operation {entry['operation_id']} is not in the site's OpenAPI documents")
111
+ elif operation != (entry["method"], entry["path"]):
112
+ say(
113
+ f"operation {entry['operation_id']} is {' '.join(operation)}, "
114
+ f"not {entry['method']} {entry['path']}"
115
+ )
116
+ return problems
117
+
118
+
119
+ def pinned_sites() -> list[tuple[Path, list[Path]]]:
120
+ root = Path(str(files("regents_cli.platforms")))
121
+ return [
122
+ (folder / "commands.json", sorted(p for p in folder.iterdir() if p.name != "commands.json"))
123
+ for folder in sorted(root.iterdir())
124
+ if (folder / "commands.json").is_file()
125
+ ]
126
+
127
+
128
+ def main(args: list[str]) -> int:
129
+ sites = [(Path(args[0]), [Path(a) for a in args[1:]])] if args else pinned_sites()
130
+ failed = False
131
+ for commands, documents in sites:
132
+ problems = problems_in(
133
+ read_document(commands), operations_in(list(map(read_document, documents)))
134
+ )
135
+ if problems:
136
+ failed = True
137
+ print(f"{commands}:\n " + "\n ".join(problems), file=sys.stderr)
138
+ else:
139
+ print(f"{commands}: ok")
140
+ if not sites:
141
+ print("No pinned site describes its commands yet.")
142
+ return 1 if failed else 0
143
+
144
+
145
+ if __name__ == "__main__":
146
+ sys.exit(main(sys.argv[1:]))
regents_cli/doctor.py ADDED
@@ -0,0 +1,95 @@
1
+ """`regents <site> doctor`: the same checks for every site, read from its description.
2
+
3
+ The site answers at all; each public read that needs no input answers; and, for a site with
4
+ wallet-proof commands, the sign-in on this machine is one the sign-in server accepts.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Callable
10
+ from functools import partial
11
+ from typing import Any
12
+
13
+ import click
14
+
15
+ from regents_cli import output, siwa
16
+ from regents_cli.errors import CommandError
17
+ from regents_cli.http import Request, base_address, respond, send
18
+ from regents_cli.platforms import Command, Platform
19
+
20
+
21
+ def doctor_command(platform: Platform) -> click.Command:
22
+ def run(as_json: bool, base_url: str | None, timeout_ms: int) -> None:
23
+ base = base_address(base_url, platform.env_var, platform.base_url)
24
+ checks = [check("site answers", lambda: site_answers(base, timeout_ms))]
25
+ checks += [
26
+ check(" ".join(c.words), partial(read_answers, base, c, timeout_ms))
27
+ for c in platform.commands
28
+ if needs_nothing(c)
29
+ ]
30
+ if any(c.authority == "wallet-proof" for c in platform.commands):
31
+ checks.append(check("sign-in", lambda: sign_in_good(platform.site, timeout_ms)))
32
+ found: dict[str, Any] = {"site": platform.site, "base_url": base, "checks": checks}
33
+ failed = sum(not c["ok"] for c in checks)
34
+ if failed:
35
+ raise CommandError(
36
+ "doctor_found_problems", f"{failed} of {len(checks)} checks failed.", **found
37
+ )
38
+ output.emit(found, as_json=as_json)
39
+
40
+ return click.Command(
41
+ "doctor",
42
+ callback=run,
43
+ params=[
44
+ click.Option(["--json", "as_json"], is_flag=True, help="Print the answer as JSON."),
45
+ click.Option(["--base-url"], help=f"The site's address; also {platform.env_var}."),
46
+ click.Option(
47
+ ["--timeout-ms"],
48
+ type=click.IntRange(1, 300_000),
49
+ default=30_000,
50
+ show_default=True,
51
+ help="How long to wait for each answer.",
52
+ ),
53
+ ],
54
+ help="Check that the site answers, that its public reads answer, and that your "
55
+ "sign-in is good.",
56
+ )
57
+
58
+
59
+ def needs_nothing(command: Command) -> bool:
60
+ return (
61
+ command.authority == "public"
62
+ and command.effect == "read"
63
+ and command.method == "GET"
64
+ and not command.arguments
65
+ and not command.required_one_of
66
+ and not any(flag.required for flag in command.flags)
67
+ )
68
+
69
+
70
+ def check(name: str, run: Callable[[], str]) -> dict[str, Any]:
71
+ try:
72
+ return {"check": name, "ok": True, "detail": run()}
73
+ except CommandError as error:
74
+ return {"check": name, "ok": False, "detail": f"{error.code}: {error.message}"}
75
+
76
+
77
+ def site_answers(base: str, timeout_ms: int) -> str:
78
+ response = respond(base, Request("GET", "/", headers={"accept": "*/*"}), timeout_ms)
79
+ return f"{base} answered {response.status_code}."
80
+
81
+
82
+ def read_answers(base: str, command: Command, timeout_ms: int) -> str:
83
+ send(base, Request(command.method, command.path), timeout_ms)
84
+ return f"{command.method} {command.path} answered."
85
+
86
+
87
+ def sign_in_good(site: str, timeout_ms: int) -> str:
88
+ sign_in = siwa.current(site, timeout_ms)
89
+ if not sign_in.local:
90
+ return (
91
+ f"Signed in with your own key as {sign_in.wallet_address} until "
92
+ f"{sign_in.expires_at}; only that key can sign a request to check it further."
93
+ )
94
+ siwa.confirm(site, sign_in, timeout_ms)
95
+ return f"The sign-in server accepts {sign_in.wallet_address} until {sign_in.expires_at}."
regents_cli/errors.py ADDED
@@ -0,0 +1,47 @@
1
+ """Failures and the exit codes every `regents` command shares."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Final
6
+
7
+ EXIT_OK: Final = 0
8
+ EXIT_FAILED: Final = 1
9
+ EXIT_USAGE: Final = 2
10
+ EXIT_AUTH: Final = 3
11
+ EXIT_NOT_FOUND: Final = 4
12
+ EXIT_UNREACHABLE: Final = 5
13
+ EXIT_CANCELLED: Final = 130
14
+
15
+ EXIT_CODES: Final = {
16
+ EXIT_OK: "Success.",
17
+ EXIT_FAILED: "The command ran but the operation failed.",
18
+ EXIT_USAGE: "Usage error: unknown command, missing argument, or invalid flag value.",
19
+ EXIT_AUTH: "Sign-in or wallet proof is missing, expired, or rejected.",
20
+ EXIT_NOT_FOUND: "The requested record was not found.",
21
+ EXIT_UNREACHABLE: "The site could not be reached, or did not answer in time.",
22
+ EXIT_CANCELLED: "The command was interrupted.",
23
+ }
24
+
25
+
26
+ class CommandError(Exception):
27
+ """A failure reported as {"error": {"code", "message", ...}} with its exit code.
28
+
29
+ Extra fields travel into the error object; never put a secret in them.
30
+ """
31
+
32
+ def __init__(
33
+ self, code: str, message: str, *, exit_code: int = EXIT_FAILED, **fields: Any
34
+ ) -> None:
35
+ super().__init__(message)
36
+ self.code = code
37
+ self.message = message
38
+ self.exit_code = exit_code
39
+ self.fields = fields
40
+
41
+ def as_json(self) -> dict[str, Any]:
42
+ return {"error": {"code": self.code, "message": self.message, **self.fields}}
43
+
44
+
45
+ class UsageError(CommandError):
46
+ def __init__(self, message: str) -> None:
47
+ super().__init__("usage_error", message, exit_code=EXIT_USAGE)