opensandbox-cli 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.
@@ -0,0 +1,160 @@
1
+ # Copyright 2026 Alibaba Group Holding Ltd.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """CLI configuration loading and management.
16
+
17
+ Priority (highest to lowest):
18
+ 1. CLI flags
19
+ 2. Environment variables
20
+ 3. Config file (~/.opensandbox/config.toml)
21
+ 4. SDK defaults
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ import sys
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ if sys.version_info >= (3, 11):
32
+ import tomllib
33
+ else:
34
+ try:
35
+ import tomli as tomllib # type: ignore[no-redef]
36
+ except ModuleNotFoundError: # pragma: no cover
37
+ tomllib = None # type: ignore[assignment]
38
+
39
+
40
+ DEFAULT_CONFIG_DIR = Path.home() / ".opensandbox"
41
+ DEFAULT_CONFIG_PATH = DEFAULT_CONFIG_DIR / "config.toml"
42
+
43
+ DEFAULT_CONFIG_TEMPLATE = """\
44
+ # OpenSandbox CLI configuration
45
+ # Priority: CLI flags > environment variables > this file > SDK defaults
46
+
47
+ [connection]
48
+ # api_key = "your-api-key"
49
+ # domain = "localhost:8080"
50
+ # protocol = "http"
51
+ # request_timeout = 30
52
+ # use_server_proxy = false
53
+
54
+ [output]
55
+ # color = true
56
+
57
+ [defaults]
58
+ # image = "python:3.11"
59
+ # timeout = "10m" # or "none" for manual cleanup mode
60
+ """
61
+
62
+
63
+ def load_config_file(config_path: Path | None = None) -> dict[str, Any]:
64
+ """Load and parse the TOML config file.
65
+
66
+ Returns an empty dict if the file doesn't exist or tomllib is unavailable.
67
+ """
68
+ path = config_path or DEFAULT_CONFIG_PATH
69
+ if not path.exists():
70
+ return {}
71
+ if tomllib is None:
72
+ return {}
73
+ with open(path, "rb") as f:
74
+ return tomllib.load(f)
75
+
76
+
77
+ def resolve_config(
78
+ *,
79
+ cli_api_key: str | None = None,
80
+ cli_domain: str | None = None,
81
+ cli_protocol: str | None = None,
82
+ cli_timeout: int | None = None,
83
+ cli_use_server_proxy: bool | None = None,
84
+ config_path: Path | None = None,
85
+ ) -> dict[str, Any]:
86
+ """Merge config from all sources and return a flat dict.
87
+
88
+ Keys returned:
89
+ - api_key, domain, protocol, request_timeout (int seconds), use_server_proxy (bool)
90
+ - default_image, default_timeout (str like "10m")
91
+ """
92
+ file_cfg = load_config_file(config_path)
93
+ conn = file_cfg.get("connection", {})
94
+ output_cfg = file_cfg.get("output", {})
95
+ defaults = file_cfg.get("defaults", {})
96
+
97
+ return {
98
+ "api_key": cli_api_key
99
+ or os.getenv("OPEN_SANDBOX_API_KEY")
100
+ or conn.get("api_key"),
101
+ "domain": cli_domain
102
+ or os.getenv("OPEN_SANDBOX_DOMAIN")
103
+ or conn.get("domain"),
104
+ "protocol": cli_protocol
105
+ or os.getenv("OPEN_SANDBOX_PROTOCOL")
106
+ or conn.get("protocol")
107
+ or "http",
108
+ "request_timeout": cli_timeout
109
+ or _int_or_none(os.getenv("OPEN_SANDBOX_REQUEST_TIMEOUT"))
110
+ or conn.get("request_timeout")
111
+ or 30,
112
+ "use_server_proxy": _coalesce(
113
+ cli_use_server_proxy,
114
+ _bool_or_none(os.getenv("OPEN_SANDBOX_USE_SERVER_PROXY")),
115
+ conn.get("use_server_proxy"),
116
+ False,
117
+ ),
118
+ "color": output_cfg.get("color", True),
119
+ "default_image": defaults.get("image"),
120
+ "default_timeout": defaults.get("timeout"),
121
+ }
122
+
123
+
124
+ def init_config_file(config_path: Path | None = None, *, force: bool = False) -> Path:
125
+ """Create a default config file. Returns the path written."""
126
+ path = config_path or DEFAULT_CONFIG_PATH
127
+ if path.exists() and not force:
128
+ raise FileExistsError(
129
+ f"Config file already exists at {path}. Use --force to overwrite."
130
+ )
131
+ path.parent.mkdir(parents=True, exist_ok=True)
132
+ path.write_text(DEFAULT_CONFIG_TEMPLATE)
133
+ return path
134
+
135
+
136
+ def _int_or_none(value: str | None) -> int | None:
137
+ if value is None:
138
+ return None
139
+ try:
140
+ return int(value)
141
+ except ValueError:
142
+ return None
143
+
144
+
145
+ def _bool_or_none(value: str | None) -> bool | None:
146
+ if value is None:
147
+ return None
148
+ normalized = value.strip().lower()
149
+ if normalized in ("1", "true", "yes", "on"):
150
+ return True
151
+ if normalized in ("0", "false", "no", "off"):
152
+ return False
153
+ return None
154
+
155
+
156
+ def _coalesce(*values: Any) -> Any:
157
+ for value in values:
158
+ if value is not None:
159
+ return value
160
+ return None
@@ -0,0 +1,138 @@
1
+ # Copyright 2026 Alibaba Group Holding Ltd.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Root Click group with global options."""
16
+
17
+ from __future__ import annotations
18
+
19
+ from pathlib import Path
20
+
21
+ import click
22
+ from rich.console import Console
23
+
24
+ from opensandbox_cli import __version__
25
+ from opensandbox_cli.client import ClientContext
26
+ from opensandbox_cli.commands.command import command_group
27
+ from opensandbox_cli.commands.config_cmd import config_group
28
+ from opensandbox_cli.commands.devops import devops_group
29
+ from opensandbox_cli.commands.egress import egress_group
30
+ from opensandbox_cli.commands.file import file_group
31
+ from opensandbox_cli.commands.sandbox import sandbox_group
32
+ from opensandbox_cli.commands.skills import skills_group
33
+ from opensandbox_cli.config import resolve_config
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Banner
37
+ # ---------------------------------------------------------------------------
38
+
39
+ BANNER = r"""[bold cyan]
40
+ ____ _____ _ _
41
+ / __ \ / ____| | | |
42
+ | | | |_ __ ___ _ _| (___ __ _ _ __ __| | |__ _____ __
43
+ | | | | '_ \ / _ \ '_ \___ \ / _` | '_ \ / _` | '_ \ / _ \ \/ /
44
+ | |__| | |_) | __/ | | |___) | (_| | | | | (_| | |_) | (_) > <
45
+ \____/| .__/ \___|_| |_|____/ \__,_|_| |_|\__,_|_.__/ \___/_/\_\
46
+ | |
47
+ |_|[/] [dim]v{version}[/]
48
+ """
49
+
50
+
51
+ class BannerGroup(click.Group):
52
+ """Custom Click group that shows a banner before help text."""
53
+
54
+ def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
55
+ console = Console(stderr=False)
56
+ console.print(BANNER.format(version=__version__))
57
+ super().format_help(ctx, formatter)
58
+
59
+
60
+ @click.group(cls=BannerGroup, context_settings={"help_option_names": ["-h", "--help"]})
61
+ @click.option("--api-key", envvar="OPEN_SANDBOX_API_KEY", default=None, help="API key for authentication.")
62
+ @click.option("--domain", envvar="OPEN_SANDBOX_DOMAIN", default=None, help="API server domain (e.g. localhost:8080).")
63
+ @click.option("--protocol", type=click.Choice(["http", "https"]), default=None, help="Protocol (http/https).")
64
+ @click.option("--request-timeout", type=int, default=None, help="Request timeout in seconds.")
65
+ @click.option(
66
+ "--use-server-proxy/--no-use-server-proxy",
67
+ default=None,
68
+ help="Route execd and endpoint traffic through the sandbox server proxy.",
69
+ )
70
+ @click.option("--config", "config_path", type=click.Path(exists=False, path_type=Path), default=None, help="Config file path.")
71
+ @click.option("-v", "--verbose", is_flag=True, default=False, help="Enable verbose/debug output.")
72
+ @click.option("--no-color", is_flag=True, default=False, help="Disable colored output.")
73
+ @click.version_option(version=__version__, prog_name="opensandbox")
74
+ @click.pass_context
75
+ def cli(
76
+ ctx: click.Context,
77
+ api_key: str | None,
78
+ domain: str | None,
79
+ protocol: str | None,
80
+ request_timeout: int | None,
81
+ use_server_proxy: bool | None,
82
+ config_path: Path | None,
83
+ verbose: bool,
84
+ no_color: bool,
85
+ ) -> None:
86
+ """OpenSandbox CLI — manage sandboxes from your terminal."""
87
+ if verbose:
88
+ import logging
89
+
90
+ logging.basicConfig(level=logging.DEBUG)
91
+
92
+ cli_overrides = {
93
+ "api_key": api_key,
94
+ "domain": domain,
95
+ "protocol": protocol,
96
+ "request_timeout": request_timeout,
97
+ "use_server_proxy": use_server_proxy,
98
+ }
99
+
100
+ if ctx.invoked_subcommand == "config":
101
+ resolved = {
102
+ "api_key": api_key,
103
+ "domain": domain,
104
+ "protocol": protocol or "http",
105
+ "request_timeout": request_timeout or 30,
106
+ "use_server_proxy": use_server_proxy if use_server_proxy is not None else False,
107
+ "color": True,
108
+ "default_image": None,
109
+ "default_timeout": None,
110
+ }
111
+ else:
112
+ resolved = resolve_config(
113
+ cli_api_key=api_key,
114
+ cli_domain=domain,
115
+ cli_protocol=protocol,
116
+ cli_timeout=request_timeout,
117
+ cli_use_server_proxy=use_server_proxy,
118
+ config_path=config_path,
119
+ )
120
+ resolved["color"] = not no_color and resolved.get("color", True)
121
+
122
+ effective_config_path = config_path or Path.home() / ".opensandbox" / "config.toml"
123
+ ctx.obj = ClientContext(
124
+ resolved_config=resolved,
125
+ config_path=effective_config_path,
126
+ cli_overrides=cli_overrides,
127
+ )
128
+ ctx.call_on_close(lambda: ctx.obj.close())
129
+
130
+
131
+ # Register sub-command groups
132
+ cli.add_command(sandbox_group)
133
+ cli.add_command(command_group)
134
+ cli.add_command(file_group)
135
+ cli.add_command(egress_group)
136
+ cli.add_command(config_group)
137
+ cli.add_command(devops_group)
138
+ cli.add_command(skills_group)
@@ -0,0 +1,363 @@
1
+ # Copyright 2026 Alibaba Group Holding Ltd.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Output formatting: table (rich), JSON, YAML."""
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import sys
21
+ from collections.abc import Generator, Sequence
22
+ from contextlib import contextmanager
23
+ from typing import Any
24
+
25
+ import click
26
+
27
+ try:
28
+ import yaml
29
+ except ImportError: # pragma: no cover
30
+ yaml = None # type: ignore[assignment]
31
+
32
+ from pydantic import BaseModel
33
+ from rich import box
34
+ from rich.console import Console
35
+ from rich.panel import Panel
36
+ from rich.status import Status
37
+ from rich.table import Table
38
+ from rich.text import Text
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Status badge styling (sandbox state → color + icon)
42
+ # ---------------------------------------------------------------------------
43
+
44
+ _STATUS_STYLES: dict[str, tuple[str, str]] = {
45
+ # state → (rich style, icon)
46
+ "running": ("bold green", "●"),
47
+ "ready": ("bold green", "●"),
48
+ "healthy": ("bold green", "●"),
49
+ "pending": ("bold yellow", "◐"),
50
+ "creating": ("bold yellow", "◐"),
51
+ "starting": ("bold yellow", "◐"),
52
+ "paused": ("bold blue", "⏸"),
53
+ "stopped": ("dim", "○"),
54
+ "terminated": ("dim", "○"),
55
+ "killed": ("dim", "○"),
56
+ "error": ("bold red", "✗"),
57
+ "failed": ("bold red", "✗"),
58
+ "unhealthy": ("bold red", "✗"),
59
+ "created": ("bold cyan", "✦"),
60
+ }
61
+
62
+ # Columns that contain status-like values
63
+ _STATUS_COLUMNS = {"status", "state", "healthy"}
64
+
65
+ # Columns that should be rendered in a dimmer style (long IDs, timestamps)
66
+ _DIM_COLUMNS = {"created_at", "expires_at", "modified_at", "updated_at"}
67
+
68
+ # Columns that are primary identifiers
69
+ _ID_COLUMNS = {"id", "sandbox_id", "execution_id", "context_id"}
70
+
71
+
72
+ def _style_value(col: str, value: str) -> Text:
73
+ """Apply contextual styling to a cell value."""
74
+ lower = value.lower()
75
+
76
+ if col in _STATUS_COLUMNS:
77
+ style, icon = _STATUS_STYLES.get(lower, ("", ""))
78
+ if style:
79
+ return Text(f"{icon} {value}", style=style)
80
+
81
+ if col in _DIM_COLUMNS:
82
+ return Text(value, style="dim")
83
+
84
+ if col in _ID_COLUMNS:
85
+ return Text(value, style="bold cyan")
86
+
87
+ return Text(value)
88
+
89
+
90
+ class OutputFormatter:
91
+ """Renders data in table / json / yaml format."""
92
+
93
+ def __init__(self, fmt: str = "table", *, color: bool = True) -> None:
94
+ self.fmt = fmt
95
+ self.color = color
96
+ self.console = Console(
97
+ stderr=False, no_color=not color, force_terminal=None
98
+ )
99
+ self._err_console = Console(
100
+ stderr=True, no_color=not color, force_terminal=None
101
+ )
102
+
103
+ # ------------------------------------------------------------------
104
+ # Status messages with icons
105
+ # ------------------------------------------------------------------
106
+
107
+ def success(self, msg: str) -> None:
108
+ """Print a success message with ✅ icon."""
109
+ if self.fmt == "json":
110
+ self._print_json({"status": "ok", "message": msg})
111
+ return
112
+ if self.fmt == "yaml":
113
+ self._print_yaml({"status": "ok", "message": msg})
114
+ return
115
+ if self.color:
116
+ self.console.print(f" [bold green]✅ {msg}[/]")
117
+ else:
118
+ click.echo(f"OK: {msg}")
119
+
120
+ def info(self, msg: str) -> None:
121
+ """Print an info message with ℹ️ icon."""
122
+ if self.fmt == "json":
123
+ self._print_json({"status": "info", "message": msg})
124
+ return
125
+ if self.fmt == "yaml":
126
+ self._print_yaml({"status": "info", "message": msg})
127
+ return
128
+ if self.color:
129
+ self.console.print(f" [bold blue]ℹ️ {msg}[/]")
130
+ else:
131
+ click.echo(f"INFO: {msg}")
132
+
133
+ def warning(self, msg: str) -> None:
134
+ """Print a warning message with ⚠️ icon."""
135
+ if self.fmt == "json":
136
+ self._print_json({"status": "warning", "message": msg})
137
+ return
138
+ if self.fmt == "yaml":
139
+ self._print_yaml({"status": "warning", "message": msg})
140
+ return
141
+ if self.color:
142
+ self._err_console.print(f" [bold yellow]⚠️ {msg}[/]")
143
+ else:
144
+ click.echo(f"WARN: {msg}", err=True)
145
+
146
+ def error(self, msg: str) -> None:
147
+ """Print an error message with ❌ icon."""
148
+ if self.fmt == "json":
149
+ self._print_json({"status": "error", "message": msg})
150
+ return
151
+ if self.fmt == "yaml":
152
+ self._print_yaml({"status": "error", "message": msg})
153
+ return
154
+ if self.color:
155
+ self._err_console.print(f" [bold red]❌ {msg}[/]")
156
+ else:
157
+ click.echo(f"ERROR: {msg}", err=True)
158
+
159
+ def error_panel(self, msg: str, title: str = "Error") -> None:
160
+ """Print an error with a bold header and message."""
161
+ if self.color:
162
+ self._err_console.print()
163
+ self._err_console.print(f" [bold red]{title}[/]")
164
+ self._err_console.print(f" [dim]{'─' * (len(title) + 2)}[/]")
165
+ for line in msg.splitlines():
166
+ self._err_console.print(f" {line}")
167
+ self._err_console.print()
168
+ else:
169
+ click.echo(f"ERROR [{title}]: {msg}", err=True)
170
+
171
+ # ------------------------------------------------------------------
172
+ # Spinner for long-running operations
173
+ # ------------------------------------------------------------------
174
+
175
+ @contextmanager
176
+ def spinner(self, msg: str) -> Generator[Status, None, None]:
177
+ """Context manager that shows a spinner while work is in progress."""
178
+ if self.color and self.fmt == "table":
179
+ with self._err_console.status(f"[bold cyan]⏳ {msg}[/]", spinner="dots") as status:
180
+ yield status
181
+ else:
182
+ # No spinner in non-color or non-table mode
183
+ yield None # type: ignore[arg-type]
184
+
185
+ # ------------------------------------------------------------------
186
+ # Panel output
187
+ # ------------------------------------------------------------------
188
+
189
+ def panel(self, content: str, *, title: str | None = None, style: str = "cyan") -> None:
190
+ """Print content inside a styled panel."""
191
+ if self.color:
192
+ self.console.print(Panel(
193
+ content,
194
+ title=title,
195
+ title_align="left",
196
+ border_style=style,
197
+ box=box.ROUNDED,
198
+ padding=(0, 1),
199
+ ))
200
+ else:
201
+ if title:
202
+ click.echo(f"--- {title} ---")
203
+ click.echo(content)
204
+
205
+ def success_panel(self, data: dict[str, Any], *, title: str = "Success") -> None:
206
+ """Print a success result with a header and indented key-value pairs."""
207
+ if self.fmt != "table":
208
+ if self.fmt == "json":
209
+ self._print_json(data)
210
+ elif self.fmt == "yaml":
211
+ self._print_yaml(data)
212
+ return
213
+
214
+ if self.color:
215
+ self.console.print()
216
+ self.console.print(f" [bold green]✓ {title}[/]")
217
+ self.console.print(f" [dim]{'─' * (len(title) + 2)}[/]")
218
+ for k, v in data.items():
219
+ self.console.print(f" [bold]{k}:[/] [cyan]{v}[/]")
220
+ self.console.print()
221
+ else:
222
+ click.echo(f"--- {title} ---")
223
+ for k, v in data.items():
224
+ click.echo(f" {k}: {v}")
225
+
226
+ # ------------------------------------------------------------------
227
+ # Public helpers
228
+ # ------------------------------------------------------------------
229
+
230
+ def print_model(self, model: BaseModel, title: str | None = None) -> None:
231
+ """Print a single Pydantic model as key-value panel or JSON/YAML."""
232
+ data = _model_to_dict(model)
233
+ if self.fmt == "json":
234
+ self._print_json(data)
235
+ elif self.fmt == "yaml":
236
+ self._print_yaml(data)
237
+ else:
238
+ self._print_kv_table(data, title=title)
239
+
240
+ def print_models(
241
+ self,
242
+ models: Sequence[BaseModel],
243
+ columns: list[str],
244
+ *,
245
+ title: str | None = None,
246
+ ) -> None:
247
+ """Print a list of Pydantic models as a table or JSON/YAML."""
248
+ rows = [_model_to_dict(m) for m in models]
249
+ if self.fmt == "json":
250
+ self._print_json(rows)
251
+ elif self.fmt == "yaml":
252
+ self._print_yaml(rows)
253
+ else:
254
+ self._print_table(rows, columns, title=title)
255
+
256
+ def print_rows(
257
+ self,
258
+ rows: list[dict[str, Any]],
259
+ columns: list[str],
260
+ *,
261
+ title: str | None = None,
262
+ ) -> None:
263
+ """Print pre-processed rows (list of dicts) as a table or JSON/YAML."""
264
+ if self.fmt == "json":
265
+ self._print_json(rows)
266
+ elif self.fmt == "yaml":
267
+ self._print_yaml(rows)
268
+ else:
269
+ self._print_table(rows, columns, title=title)
270
+
271
+ def print_dict(self, data: dict[str, Any], title: str | None = None) -> None:
272
+ """Print a flat dict."""
273
+ if self.fmt == "json":
274
+ self._print_json(data)
275
+ elif self.fmt == "yaml":
276
+ self._print_yaml(data)
277
+ else:
278
+ self._print_kv_table(data, title=title)
279
+
280
+ def print_text(self, text: str) -> None:
281
+ """Print raw text (ignores format)."""
282
+ click.echo(text)
283
+
284
+ # ------------------------------------------------------------------
285
+ # Internal renderers
286
+ # ------------------------------------------------------------------
287
+
288
+ def _print_json(self, data: Any) -> None:
289
+ if self.color:
290
+ self.console.print_json(json.dumps(data, default=str))
291
+ else:
292
+ click.echo(json.dumps(data, indent=2, default=str))
293
+
294
+ def _print_yaml(self, data: Any) -> None:
295
+ if yaml is None:
296
+ click.secho(
297
+ "PyYAML is not installed. Use --output json instead.", fg="red", err=True
298
+ )
299
+ sys.exit(1)
300
+ click.echo(yaml.dump(data, default_flow_style=False, allow_unicode=True).rstrip())
301
+
302
+ def _print_kv_table(self, data: dict[str, Any], *, title: str | None = None) -> None:
303
+ table = Table(
304
+ title=title,
305
+ show_header=True,
306
+ header_style="bold magenta",
307
+ title_style="bold cyan",
308
+ box=box.ROUNDED,
309
+ border_style="bright_black",
310
+ padding=(0, 1),
311
+ show_lines=True,
312
+ )
313
+ table.add_column("Key", style="bold cyan", no_wrap=True)
314
+ table.add_column("Value")
315
+ for k, v in data.items():
316
+ val_text = _style_value(k, str(v)) if v is not None else Text("-", style="dim")
317
+ table.add_row(str(k), val_text)
318
+ self.console.print(table)
319
+
320
+ def _print_table(
321
+ self,
322
+ rows: list[dict[str, Any]],
323
+ columns: list[str],
324
+ *,
325
+ title: str | None = None,
326
+ ) -> None:
327
+ table = Table(
328
+ title=title,
329
+ show_header=True,
330
+ header_style="bold magenta",
331
+ title_style="bold cyan",
332
+ box=box.ROUNDED,
333
+ border_style="bright_black",
334
+ padding=(0, 1),
335
+ row_styles=["", "dim"],
336
+ )
337
+ for col in columns:
338
+ style = ""
339
+ if col in _ID_COLUMNS:
340
+ style = "bold cyan"
341
+ elif col in _DIM_COLUMNS:
342
+ style = "dim"
343
+ table.add_column(col.upper(), style=style, no_wrap=(col in _ID_COLUMNS))
344
+
345
+ for row in rows:
346
+ cells: list[Text | str] = []
347
+ for col in columns:
348
+ val = str(row.get(col, "-"))
349
+ if col in _STATUS_COLUMNS:
350
+ cells.append(_style_value(col, val))
351
+ else:
352
+ cells.append(val)
353
+ table.add_row(*cells)
354
+ self.console.print(table)
355
+
356
+
357
+ # ------------------------------------------------------------------
358
+ # Helpers
359
+ # ------------------------------------------------------------------
360
+
361
+
362
+ def _model_to_dict(model: BaseModel) -> dict[str, Any]:
363
+ return model.model_dump(mode="json")
@@ -0,0 +1 @@
1
+