runtime-sdk 0.4.24__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.24
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.24"
3
+ version = "0.4.25"
4
4
  description = "Runtime Python SDK and CLI"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -889,22 +889,6 @@ def _prompt_typed_confirm(message: str, expected: str) -> bool:
889
889
  return typed.strip().lower() == expected.strip().lower()
890
890
 
891
891
 
892
- def _display_width(value: Any, minimum: int, maximum: int) -> int:
893
- text = str(value or "")
894
- return max(minimum, min(len(text), maximum))
895
-
896
-
897
- def _fit_cell(value: Any, width: int) -> str:
898
- text = str(value or "")
899
- if width <= 0:
900
- return ""
901
- if len(text) > width:
902
- if width == 1:
903
- return "…"
904
- text = text[: width - 1] + "…"
905
- return text.ljust(width)
906
-
907
-
908
892
  def _computer_power_state(c: dict[str, Any]) -> str:
909
893
  power = c.get("power_state")
910
894
  if power is not None:
@@ -1123,157 +1107,97 @@ def _computer_sort_key(c: dict[str, Any]) -> tuple[int, str, str]:
1123
1107
 
1124
1108
 
1125
1109
 
1126
- def _computer_table_layout(computers: list[dict[str, Any]]) -> dict[str, int]:
1127
- term_width = shutil.get_terminal_size((100, 20)).columns
1128
- slug_width = max(_display_width(_computer_name(c), len("name"), 24) for c in computers)
1129
- status_width = max(
1130
- _display_width(_computer_status_label(c), len("state"), 20)
1131
- for c in computers
1132
- )
1133
- created_width = max(
1134
- _display_width(_computer_created_label(c), len("age"), 8)
1135
- for c in computers
1136
- )
1137
- reserved = slug_width + status_width + created_width + len(" ") * 3
1138
- url_budget = max(16, term_width - reserved)
1139
- url_width = max(
1140
- len("url"),
1141
- min(max(len(_computer_url_label(c)) for c in computers), url_budget),
1142
- )
1143
- return {
1144
- "slug": slug_width,
1145
- "status": status_width,
1146
- "public_url": url_width,
1147
- "created_at": created_width,
1148
- }
1149
-
1150
-
1151
- def _format_computer_row(c: dict[str, Any], widths: dict[str, int]) -> str:
1152
- return " │ ".join(
1153
- [
1154
- _fit_cell(_computer_name(c), widths["slug"]),
1155
- _fit_cell(_computer_status_label(c), widths["status"]),
1156
- _fit_cell(_computer_url_label(c), widths["public_url"]),
1157
- _fit_cell(_computer_created_label(c), widths["created_at"]),
1158
- ]
1159
- )
1160
-
1161
-
1162
- def _computer_table_prompt(prompt: str, computers: list[dict[str, Any]]) -> tuple[str, dict[str, int]]:
1163
- widths = _computer_table_layout(computers)
1164
- header = " │ ".join(
1165
- [
1166
- _fit_cell("name", widths["slug"]),
1167
- _fit_cell("state", widths["status"]),
1168
- _fit_cell("url", widths["public_url"]),
1169
- _fit_cell("age", widths["created_at"]),
1170
- ]
1171
- )
1172
- divider = "─" * len(header)
1173
- 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"
1174
1122
 
1175
1123
 
1124
+ def _picker_widths(computers: list[dict[str, Any]]) -> dict[str, int]:
1125
+ """Compute aligned column widths for the interactive picker rows.
1176
1126
 
1177
- def _computer_choice_title(c: dict[str, Any], widths: dict[str, int]) -> str:
1178
- 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)
1179
1149
  status = _computer_status_label(c)
1180
- url = _computer_url_label(c)
1181
- created = _computer_created_label(c)
1182
- parts = [f"{_computer_name(c)}", status]
1183
- if url:
1184
- parts.append(url)
1185
- if created:
1186
- parts.append(created)
1187
- return " · ".join(parts)
1188
-
1189
-
1190
-
1191
- def _render_computer_summary(computers: list[dict[str, Any]]) -> None:
1192
- mods = _UI.rich()
1193
- Panel, Text = mods["Panel"], mods["Text"]
1194
- counts: dict[str, int] = {}
1195
- for computer in computers:
1196
- status = _computer_status_label(computer)
1197
- counts[status] = counts.get(status, 0) + 1
1198
-
1199
- body = Text()
1200
- body.append(f"total {len(computers)}", style="bold white")
1201
- if counts:
1202
- body.append(" ")
1203
- first = True
1204
- status_styles = {
1205
- "warm": "bold green",
1206
- "cold": "bold cyan",
1207
- "running": "bold green",
1208
- "archiving": "bold yellow",
1209
- "restoring": "bold yellow",
1210
- "creating": "bold magenta",
1211
- "orphaned": "bold red",
1212
- }
1213
- for status in sorted(counts):
1214
- if not first:
1215
- body.append(" • ", style="dim")
1216
- first = False
1217
- status_style = "bold white"
1218
- for token, style in status_styles.items():
1219
- if token in status:
1220
- status_style = style
1221
- break
1222
- body.append(status, style=status_style)
1223
- body.append(f" {counts[status]}", style="white")
1224
-
1225
- _UI.console().print(
1226
- Panel(body, title="runtime", border_style="blue", expand=False, padding=(0, 1))
1227
- )
1228
-
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
+ ]
1229
1169
 
1230
1170
 
1231
- def _render_computer_table(computers: list[dict[str, Any]], *, title: str | None = None) -> None:
1232
- mods = _UI.rich()
1233
- Table, box = mods["Table"], mods["box"]
1234
- table = Table(
1235
- title=title,
1236
- box=box.MINIMAL_DOUBLE_HEAD,
1237
- expand=True,
1238
- show_lines=False,
1239
- header_style="bold #7dd3fc",
1240
- title_style="bold white",
1241
- pad_edge=False,
1242
- collapse_padding=True,
1243
- row_styles=["", "dim"],
1244
- )
1245
- table.add_column("Name", style="bold white", no_wrap=True, ratio=2)
1246
- table.add_column("State", no_wrap=True, ratio=2)
1247
- table.add_column("URL", style="white", overflow="fold", ratio=4)
1248
- table.add_column("Age", style="dim", no_wrap=True, justify="right", ratio=1)
1249
-
1250
- for computer in sorted(computers, key=_computer_sort_key):
1251
- status = _computer_status_label(computer)
1252
- status_style = _computer_status_style(status)
1253
- table.add_row(
1254
- _computer_name(computer),
1255
- f"[{status_style}]{_computer_status_dot(status)} {status}[/{status_style}]",
1256
- _computer_url_label(computer),
1257
- _computer_created_label(computer),
1258
- )
1259
1171
 
1260
- _UI.console().print(table)
1261
1172
 
1262
1173
 
1263
1174
  def _select_prompt(message: str, choices: list[Any], *, instruction: str | None = None) -> Any:
1264
1175
  # questionary rejects use_jk_keys together with use_search_filter because
1265
1176
  # j/k would be swallowed by the search prefix. We pick search over vim keys.
1266
- return _UI.q().select(
1177
+ question = _UI.q().select(
1267
1178
  message,
1268
1179
  choices=choices,
1269
1180
  qmark="●",
1270
1181
  pointer="❯",
1271
- instruction=instruction or "↑↓ navigate · type to search · enter · esc cancel",
1182
+ instruction=instruction or "↑↓ · type to search · enter select · esc back",
1272
1183
  use_indicator=True,
1273
1184
  use_jk_keys=False,
1274
1185
  use_search_filter=True,
1275
1186
  style=_UI.style(),
1276
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
1277
1201
 
1278
1202
 
1279
1203
  def _pick_computer(
@@ -1283,8 +1207,8 @@ def _pick_computer(
1283
1207
  ) -> dict[str, Any] | None:
1284
1208
  """Fetch computers and let the user arrow-key through them.
1285
1209
 
1286
- Returns the picked computer dict, or None if the list is empty or the user
1287
- 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.
1288
1212
  """
1289
1213
  client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
1290
1214
  payload = client.list_computers()
@@ -1299,8 +1223,7 @@ def _pick_computer(
1299
1223
  return None
1300
1224
 
1301
1225
  questionary = _UI.q()
1302
- widths = _computer_table_layout(computers)
1303
- _render_computer_table(computers)
1226
+ widths = _picker_widths(computers)
1304
1227
  choices = [
1305
1228
  questionary.Choice(title=_computer_choice_title(c, widths), value=c) for c in computers
1306
1229
  ]
@@ -2977,7 +2900,11 @@ def _send_terminal_resize(ws: Any) -> None:
2977
2900
 
2978
2901
 
2979
2902
  def _interactive_list(config: RuntimeConfig) -> int:
2980
- """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
+ """
2981
2908
  client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
2982
2909
  questionary = _UI.q()
2983
2910
 
@@ -2992,9 +2919,19 @@ def _interactive_list(config: RuntimeConfig) -> int:
2992
2919
  return handle_create(config, None, None, None, None)
2993
2920
 
2994
2921
  computers = sorted(computers, key=_computer_sort_key)
2995
- _render_computer_summary(computers)
2996
- widths = _computer_table_layout(computers)
2997
- _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
+
2998
2935
  choices: list[Any] = [
2999
2936
  questionary.Choice(title=_computer_choice_title(c, widths), value=c)
3000
2937
  for c in computers
@@ -3004,9 +2941,9 @@ def _interactive_list(config: RuntimeConfig) -> int:
3004
2941
  choices.append(questionary.Choice(title="↻ refresh", value="__refresh__"))
3005
2942
  choices.append(questionary.Choice(title="✕ quit", value="__quit__"))
3006
2943
 
3007
- picked = _select_prompt("Pick a computer", choices).unsafe_ask()
2944
+ picked = _select_prompt(f"Pick a computer ({summary})", choices).unsafe_ask()
3008
2945
 
3009
- if picked == "__quit__":
2946
+ if picked is None or picked == "__quit__":
3010
2947
  return 0
3011
2948
  if picked == "__refresh__":
3012
2949
  continue
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runtime-sdk
3
- Version: 0.4.24
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