runtime-sdk 0.4.23__tar.gz → 0.4.25__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runtime-sdk
3
- Version: 0.4.23
3
+ Version: 0.4.25
4
4
  Summary: Runtime Python SDK and CLI
5
5
  Project-URL: Repository, https://github.com/The-Money-Company-Limited/runtimevm
6
6
  Project-URL: Issues, https://github.com/The-Money-Company-Limited/runtimevm/issues
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "runtime-sdk"
3
- version = "0.4.23"
3
+ version = "0.4.25"
4
4
  description = "Runtime Python SDK and CLI"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -325,6 +325,9 @@ def build_parser() -> argparse.ArgumentParser:
325
325
  )
326
326
  completion_cmd.add_argument("shell", choices=["bash", "zsh", "fish"], help="Target shell")
327
327
 
328
+ # Hidden helper used by shell completion to list slugs, one per line.
329
+ subparsers.add_parser("_slugs", help=argparse.SUPPRESS)
330
+
328
331
  parser.format_help = lambda: _format_top_help() # type: ignore[assignment]
329
332
  return parser
330
333
 
@@ -571,6 +574,8 @@ def main(argv: list[str] | None = None) -> int:
571
574
  return handle_help(args.topic)
572
575
  if args.command == "completion":
573
576
  return handle_completion(args.shell)
577
+ if args.command == "_slugs":
578
+ return handle_slugs(config)
574
579
  if args.command == "_proxy_agent":
575
580
  return run_proxy_agent(
576
581
  args.base_url,
@@ -884,22 +889,6 @@ def _prompt_typed_confirm(message: str, expected: str) -> bool:
884
889
  return typed.strip().lower() == expected.strip().lower()
885
890
 
886
891
 
887
- def _display_width(value: Any, minimum: int, maximum: int) -> int:
888
- text = str(value or "")
889
- return max(minimum, min(len(text), maximum))
890
-
891
-
892
- def _fit_cell(value: Any, width: int) -> str:
893
- text = str(value or "")
894
- if width <= 0:
895
- return ""
896
- if len(text) > width:
897
- if width == 1:
898
- return "…"
899
- text = text[: width - 1] + "…"
900
- return text.ljust(width)
901
-
902
-
903
892
  def _computer_power_state(c: dict[str, Any]) -> str:
904
893
  power = c.get("power_state")
905
894
  if power is not None:
@@ -1118,157 +1107,97 @@ def _computer_sort_key(c: dict[str, Any]) -> tuple[int, str, str]:
1118
1107
 
1119
1108
 
1120
1109
 
1121
- def _computer_table_layout(computers: list[dict[str, Any]]) -> dict[str, int]:
1122
- term_width = shutil.get_terminal_size((100, 20)).columns
1123
- slug_width = max(_display_width(_computer_name(c), len("name"), 24) for c in computers)
1124
- status_width = max(
1125
- _display_width(_computer_status_label(c), len("state"), 20)
1126
- for c in computers
1127
- )
1128
- created_width = max(
1129
- _display_width(_computer_created_label(c), len("age"), 8)
1130
- for c in computers
1131
- )
1132
- reserved = slug_width + status_width + created_width + len(" ") * 3
1133
- url_budget = max(16, term_width - reserved)
1134
- url_width = max(
1135
- len("url"),
1136
- min(max(len(_computer_url_label(c)) for c in computers), url_budget),
1137
- )
1138
- return {
1139
- "slug": slug_width,
1140
- "status": status_width,
1141
- "public_url": url_width,
1142
- "created_at": created_width,
1143
- }
1144
-
1145
-
1146
- def _format_computer_row(c: dict[str, Any], widths: dict[str, int]) -> str:
1147
- return " │ ".join(
1148
- [
1149
- _fit_cell(_computer_name(c), widths["slug"]),
1150
- _fit_cell(_computer_status_label(c), widths["status"]),
1151
- _fit_cell(_computer_url_label(c), widths["public_url"]),
1152
- _fit_cell(_computer_created_label(c), widths["created_at"]),
1153
- ]
1154
- )
1155
-
1156
-
1157
- def _computer_table_prompt(prompt: str, computers: list[dict[str, Any]]) -> tuple[str, dict[str, int]]:
1158
- widths = _computer_table_layout(computers)
1159
- header = " │ ".join(
1160
- [
1161
- _fit_cell("name", widths["slug"]),
1162
- _fit_cell("state", widths["status"]),
1163
- _fit_cell("url", widths["public_url"]),
1164
- _fit_cell("age", widths["created_at"]),
1165
- ]
1166
- )
1167
- divider = "─" * len(header)
1168
- return f"{prompt}\n{header}\n{divider}", widths
1110
+ def _picker_status_color(status: str) -> str:
1111
+ if "cold" in status:
1112
+ return "fg:#60a5fa" # cyan-blue
1113
+ if "warm" in status or "running" in status:
1114
+ return "fg:#22c55e" # green
1115
+ if "archiving" in status or "restoring" in status:
1116
+ return "fg:#eab308" # yellow
1117
+ if "creating" in status:
1118
+ return "fg:#a855f7" # magenta
1119
+ if "orphaned" in status:
1120
+ return "fg:#ef4444" # red
1121
+ return "fg:#e5e7eb"
1169
1122
 
1170
1123
 
1124
+ def _picker_widths(computers: list[dict[str, Any]]) -> dict[str, int]:
1125
+ """Compute aligned column widths for the interactive picker rows.
1171
1126
 
1172
- def _computer_choice_title(c: dict[str, Any], widths: dict[str, int]) -> str:
1173
- del widths
1127
+ We budget total width to leave room for questionary's pointer + indicator.
1128
+ """
1129
+ term_width = shutil.get_terminal_size((100, 20)).columns
1130
+ name_w = max(len("name"), max((len(_computer_name(c)) for c in computers), default=0))
1131
+ name_w = min(name_w, 24)
1132
+ status_w = max(len("state"), max((len(_computer_status_label(c)) for c in computers), default=0))
1133
+ status_w = min(status_w, 14)
1134
+ age_w = max(len("age"), max((len(_computer_created_label(c)) for c in computers), default=0))
1135
+ age_w = min(age_w, 6)
1136
+ # pointer + indicator + 2 gaps * 3 + status dot (2 chars) + padding
1137
+ reserved = 6 + name_w + status_w + age_w + 2 * 3 + 2
1138
+ url_w = max(len("url"), min(max((len(_computer_url_label(c)) for c in computers), default=16), term_width - reserved))
1139
+ url_w = max(10, url_w)
1140
+ return {"name": name_w, "status": status_w, "url": url_w, "age": age_w}
1141
+
1142
+
1143
+ def _computer_choice_title(c: dict[str, Any], widths: dict[str, int]) -> list[tuple[str, str]]:
1144
+ """Return a styled list-of-tuples title so the picker rows look tabular.
1145
+
1146
+ Columns: name · state (colored dot + label) · url · age.
1147
+ """
1148
+ name = _computer_name(c)
1174
1149
  status = _computer_status_label(c)
1175
- url = _computer_url_label(c)
1176
- created = _computer_created_label(c)
1177
- parts = [f"{_computer_name(c)}", status]
1178
- if url:
1179
- parts.append(url)
1180
- if created:
1181
- parts.append(created)
1182
- return " · ".join(parts)
1183
-
1184
-
1185
-
1186
- def _render_computer_summary(computers: list[dict[str, Any]]) -> None:
1187
- mods = _UI.rich()
1188
- Panel, Text = mods["Panel"], mods["Text"]
1189
- counts: dict[str, int] = {}
1190
- for computer in computers:
1191
- status = _computer_status_label(computer)
1192
- counts[status] = counts.get(status, 0) + 1
1193
-
1194
- body = Text()
1195
- body.append(f"total {len(computers)}", style="bold white")
1196
- if counts:
1197
- body.append(" ")
1198
- first = True
1199
- status_styles = {
1200
- "warm": "bold green",
1201
- "cold": "bold cyan",
1202
- "running": "bold green",
1203
- "archiving": "bold yellow",
1204
- "restoring": "bold yellow",
1205
- "creating": "bold magenta",
1206
- "orphaned": "bold red",
1207
- }
1208
- for status in sorted(counts):
1209
- if not first:
1210
- body.append(" • ", style="dim")
1211
- first = False
1212
- status_style = "bold white"
1213
- for token, style in status_styles.items():
1214
- if token in status:
1215
- status_style = style
1216
- break
1217
- body.append(status, style=status_style)
1218
- body.append(f" {counts[status]}", style="white")
1219
-
1220
- _UI.console().print(
1221
- Panel(body, title="runtime", border_style="blue", expand=False, padding=(0, 1))
1222
- )
1223
-
1150
+ url = _computer_url_label(c) or "—"
1151
+ age = _computer_created_label(c) or ""
1152
+ color = _picker_status_color(status)
1153
+ dot = _computer_status_dot(status)
1154
+ # Keep each column within its budget; truncate with ellipsis on overflow.
1155
+ def fit(text: str, width: int, align: str = "<") -> str:
1156
+ text = str(text or "")
1157
+ if len(text) > width:
1158
+ return text[: max(1, width - 1)] + "…"
1159
+ return format(text, f"{align}{width}")
1160
+ return [
1161
+ ("", fit(name, widths["name"]) + " "),
1162
+ (color, f"{dot} "),
1163
+ (color, fit(status, widths["status"])),
1164
+ ("", " "),
1165
+ ("fg:#cbd5e1", fit(url, widths["url"])),
1166
+ ("", " "),
1167
+ ("fg:#6b7280", fit(age, widths["age"], ">")),
1168
+ ]
1224
1169
 
1225
1170
 
1226
- def _render_computer_table(computers: list[dict[str, Any]], *, title: str | None = None) -> None:
1227
- mods = _UI.rich()
1228
- Table, box = mods["Table"], mods["box"]
1229
- table = Table(
1230
- title=title,
1231
- box=box.MINIMAL_DOUBLE_HEAD,
1232
- expand=True,
1233
- show_lines=False,
1234
- header_style="bold #7dd3fc",
1235
- title_style="bold white",
1236
- pad_edge=False,
1237
- collapse_padding=True,
1238
- row_styles=["", "dim"],
1239
- )
1240
- table.add_column("Name", style="bold white", no_wrap=True, ratio=2)
1241
- table.add_column("State", no_wrap=True, ratio=2)
1242
- table.add_column("URL", style="white", overflow="fold", ratio=4)
1243
- table.add_column("Age", style="dim", no_wrap=True, justify="right", ratio=1)
1244
-
1245
- for computer in sorted(computers, key=_computer_sort_key):
1246
- status = _computer_status_label(computer)
1247
- status_style = _computer_status_style(status)
1248
- table.add_row(
1249
- _computer_name(computer),
1250
- f"[{status_style}]{_computer_status_dot(status)} {status}[/{status_style}]",
1251
- _computer_url_label(computer),
1252
- _computer_created_label(computer),
1253
- )
1254
1171
 
1255
- _UI.console().print(table)
1256
1172
 
1257
1173
 
1258
1174
  def _select_prompt(message: str, choices: list[Any], *, instruction: str | None = None) -> Any:
1259
1175
  # questionary rejects use_jk_keys together with use_search_filter because
1260
1176
  # j/k would be swallowed by the search prefix. We pick search over vim keys.
1261
- return _UI.q().select(
1177
+ question = _UI.q().select(
1262
1178
  message,
1263
1179
  choices=choices,
1264
1180
  qmark="●",
1265
1181
  pointer="❯",
1266
- instruction=instruction or "↑↓ navigate · type to search · enter · esc cancel",
1182
+ instruction=instruction or "↑↓ · type to search · enter select · esc back",
1267
1183
  use_indicator=True,
1268
1184
  use_jk_keys=False,
1269
1185
  use_search_filter=True,
1270
1186
  style=_UI.style(),
1271
1187
  )
1188
+ # Bind ESC explicitly — questionary's select prompt does not bind it by
1189
+ # default. With eager=True the binding fires before prompt_toolkit's
1190
+ # meta-key timeout. .unsafe_ask() returns the `result` we pass to exit().
1191
+ with contextlib.suppress(Exception):
1192
+ from prompt_toolkit.keys import Keys
1193
+
1194
+ bindings = question.application.key_bindings
1195
+
1196
+ @bindings.add(Keys.Escape, eager=True)
1197
+ def _escape(event: Any) -> None:
1198
+ event.app.exit(result=None)
1199
+
1200
+ return question
1272
1201
 
1273
1202
 
1274
1203
  def _pick_computer(
@@ -1278,8 +1207,8 @@ def _pick_computer(
1278
1207
  ) -> dict[str, Any] | None:
1279
1208
  """Fetch computers and let the user arrow-key through them.
1280
1209
 
1281
- Returns the picked computer dict, or None if the list is empty or the user
1282
- explicitly chose "← cancel". Ctrl-C propagates as KeyboardInterrupt.
1210
+ Returns the picked computer dict, or None on ESC / "← cancel".
1211
+ Ctrl-C propagates as KeyboardInterrupt.
1283
1212
  """
1284
1213
  client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
1285
1214
  payload = client.list_computers()
@@ -1294,8 +1223,7 @@ def _pick_computer(
1294
1223
  return None
1295
1224
 
1296
1225
  questionary = _UI.q()
1297
- widths = _computer_table_layout(computers)
1298
- _render_computer_table(computers)
1226
+ widths = _picker_widths(computers)
1299
1227
  choices = [
1300
1228
  questionary.Choice(title=_computer_choice_title(c, widths), value=c) for c in computers
1301
1229
  ]
@@ -2972,7 +2900,11 @@ def _send_terminal_resize(ws: Any) -> None:
2972
2900
 
2973
2901
 
2974
2902
  def _interactive_list(config: RuntimeConfig) -> int:
2975
- """Nested menu: table pick VM action → back."""
2903
+ """Interactive top-level menu: picker is the display, no separate table.
2904
+
2905
+ ESC / Ctrl+C / "✕ quit" exit. "+ new computer" creates one. "↻ refresh"
2906
+ re-fetches. Picking a VM drops into its action submenu.
2907
+ """
2976
2908
  client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
2977
2909
  questionary = _UI.q()
2978
2910
 
@@ -2987,9 +2919,19 @@ def _interactive_list(config: RuntimeConfig) -> int:
2987
2919
  return handle_create(config, None, None, None, None)
2988
2920
 
2989
2921
  computers = sorted(computers, key=_computer_sort_key)
2990
- _render_computer_summary(computers)
2991
- widths = _computer_table_layout(computers)
2992
- _render_computer_table(computers, title="computers")
2922
+ widths = _picker_widths(computers)
2923
+
2924
+ # Title summarises counts so the picker alone carries enough context.
2925
+ total = len(computers)
2926
+ warm = sum(1 for c in computers if "warm" in _computer_status_label(c) or "running" in _computer_status_label(c))
2927
+ cold = sum(1 for c in computers if "cold" in _computer_status_label(c))
2928
+ bits = [f"{total} total"]
2929
+ if warm:
2930
+ bits.append(f"{warm} warm")
2931
+ if cold:
2932
+ bits.append(f"{cold} cold")
2933
+ summary = " · ".join(bits)
2934
+
2993
2935
  choices: list[Any] = [
2994
2936
  questionary.Choice(title=_computer_choice_title(c, widths), value=c)
2995
2937
  for c in computers
@@ -2999,9 +2941,9 @@ def _interactive_list(config: RuntimeConfig) -> int:
2999
2941
  choices.append(questionary.Choice(title="↻ refresh", value="__refresh__"))
3000
2942
  choices.append(questionary.Choice(title="✕ quit", value="__quit__"))
3001
2943
 
3002
- picked = _select_prompt("Pick a computer", choices).unsafe_ask()
2944
+ picked = _select_prompt(f"Pick a computer ({summary})", choices).unsafe_ask()
3003
2945
 
3004
- if picked == "__quit__":
2946
+ if picked is None or picked == "__quit__":
3005
2947
  return 0
3006
2948
  if picked == "__refresh__":
3007
2949
  continue
@@ -3058,7 +3000,7 @@ def _vm_detail_menu(client: RuntimeClient, vm: dict[str, Any]) -> str:
3058
3000
  choices,
3059
3001
  ).unsafe_ask()
3060
3002
 
3061
- if action == "__back__":
3003
+ if action is None or action == "__back__":
3062
3004
  return "__back__"
3063
3005
  if action == "__quit__":
3064
3006
  return "__quit__"
@@ -3270,9 +3212,9 @@ _runtime_complete() {
3270
3212
  fi
3271
3213
  sub="${COMP_WORDS[1]}"
3272
3214
  case "$sub" in
3273
- info|url|start|enter|run|publish|delete|rm|share|startup|service)
3215
+ info|url|start|enter|run|publish|delete|rm|startup|service)
3274
3216
  local ids
3275
- ids=$(runtime ls 2>/dev/null | python3 -c 'import sys,json;print("\n".join(c["slug"] for c in json.load(sys.stdin).get("computers",[]) if c.get("slug")))' 2>/dev/null)
3217
+ ids=$(runtime _slugs 2>/dev/null)
3276
3218
  COMPREPLY=( $(compgen -W "$ids" -- "$cur") )
3277
3219
  ;;
3278
3220
  completion)
@@ -3281,6 +3223,10 @@ _runtime_complete() {
3281
3223
  share)
3282
3224
  if [[ ${COMP_CWORD} -eq 2 ]]; then
3283
3225
  COMPREPLY=( $(compgen -W "public private" -- "$cur") )
3226
+ else
3227
+ local ids
3228
+ ids=$(runtime _slugs 2>/dev/null)
3229
+ COMPREPLY=( $(compgen -W "$ids" -- "$cur") )
3284
3230
  fi
3285
3231
  ;;
3286
3232
  esac
@@ -3331,11 +3277,17 @@ _runtime() {
3331
3277
  case ${words[1]} in
3332
3278
  info|url|start|enter|run|publish|delete|rm|startup|service)
3333
3279
  local -a ids
3334
- ids=(${(f)"$(runtime ls 2>/dev/null | python3 -c 'import sys,json;print(\"\\n\".join(c[\"slug\"] for c in json.load(sys.stdin).get(\"computers\",[]) if c.get(\"slug\")))' 2>/dev/null)"})
3280
+ ids=(${(f)"$(runtime _slugs 2>/dev/null)"})
3335
3281
  _describe -t slugs 'computer' ids
3336
3282
  ;;
3337
3283
  share)
3338
- _values 'visibility' public private
3284
+ if (( CURRENT == 2 )); then
3285
+ _values 'visibility' public private
3286
+ else
3287
+ local -a ids
3288
+ ids=(${(f)"$(runtime _slugs 2>/dev/null)"})
3289
+ _describe -t slugs 'computer' ids
3290
+ fi
3339
3291
  ;;
3340
3292
  completion)
3341
3293
  _values 'shell' bash zsh fish
@@ -3350,7 +3302,7 @@ _runtime "$@"
3350
3302
 
3351
3303
  _COMPLETION_FISH = r"""# fish completion for runtime
3352
3304
  function __runtime_slugs
3353
- runtime ls 2>/dev/null | python3 -c 'import sys,json;print("\n".join(c["slug"] for c in json.load(sys.stdin).get("computers",[]) if c.get("slug")))' 2>/dev/null
3305
+ runtime _slugs 2>/dev/null
3354
3306
  end
3355
3307
 
3356
3308
  complete -c runtime -f
@@ -3387,5 +3339,25 @@ def handle_completion(shell: str) -> int:
3387
3339
  return 0
3388
3340
 
3389
3341
 
3342
+ def handle_slugs(config: RuntimeConfig) -> int:
3343
+ """Print one slug per line. Used by shell completion scripts. Silent on errors."""
3344
+ if not config.api_key:
3345
+ return 0
3346
+ try:
3347
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
3348
+ payload = client.list_computers()
3349
+ except Exception:
3350
+ return 0
3351
+ computers = payload.get("computers") or []
3352
+ if not isinstance(computers, list):
3353
+ return 0
3354
+ for c in computers:
3355
+ if isinstance(c, dict):
3356
+ slug = str(c.get("slug") or "").strip()
3357
+ if slug:
3358
+ sys.stdout.write(slug + "\n")
3359
+ return 0
3360
+
3361
+
3390
3362
  if __name__ == "__main__":
3391
3363
  raise SystemExit(main())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runtime-sdk
3
- Version: 0.4.23
3
+ Version: 0.4.25
4
4
  Summary: Runtime Python SDK and CLI
5
5
  Project-URL: Repository, https://github.com/The-Money-Company-Limited/runtimevm
6
6
  Project-URL: Issues, https://github.com/The-Money-Company-Limited/runtimevm/issues
File without changes
File without changes