featrix-shell 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.
- featrix_shell-0.1.0.dist-info/METADATA +8 -0
- featrix_shell-0.1.0.dist-info/RECORD +11 -0
- featrix_shell-0.1.0.dist-info/WHEEL +5 -0
- featrix_shell-0.1.0.dist-info/entry_points.txt +2 -0
- featrix_shell-0.1.0.dist-info/top_level.txt +1 -0
- ffs/__init__.py +1 -0
- ffs/cli.py +21 -0
- ffs/client.py +38 -0
- ffs/model_cmd.py +323 -0
- ffs/output.py +34 -0
- ffs/server_cmd.py +22 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
ffs/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
ffs/cli.py,sha256=jtCPxtrlttXT8-C-gw-lkTEjWJSyR6f5fRWOX4N6yDk,783
|
|
3
|
+
ffs/client.py,sha256=bLoww85QD83YMLMH0K8R-Omad1Md-5f_cHQG0i45-Oo,1020
|
|
4
|
+
ffs/model_cmd.py,sha256=uolOozHGmsJQMGmgEYAn_MKNoqnUUtK6WvO5S3IJjNw,11288
|
|
5
|
+
ffs/output.py,sha256=u6N3qiC0O2OkOwM0ABb3vGZaR91sa2ldisjvhpCLZkM,1043
|
|
6
|
+
ffs/server_cmd.py,sha256=89MiSKRp3qoE57cbilHo0Q4bOCiApWRQXLMYkI-k-dc,466
|
|
7
|
+
featrix_shell-0.1.0.dist-info/METADATA,sha256=hfKb2GdKjQhXurxNlubGj3PtSx4NZeqU1jK0pR8FbZI,200
|
|
8
|
+
featrix_shell-0.1.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
|
|
9
|
+
featrix_shell-0.1.0.dist-info/entry_points.txt,sha256=2-7o6y1592Jvh1UXlqh56h-3DZV_8ajPI7q-9XEFkxU,37
|
|
10
|
+
featrix_shell-0.1.0.dist-info/top_level.txt,sha256=Z0KinK23V0UJlyK31ynmAyNhmrmsA8F-mKH2GN-0KJ0,4
|
|
11
|
+
featrix_shell-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ffs
|
ffs/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
ffs/cli.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import click
|
|
2
|
+
|
|
3
|
+
from ffs.client import pass_client, ClientState
|
|
4
|
+
from ffs import model_cmd
|
|
5
|
+
from ffs import server_cmd
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@click.group()
|
|
9
|
+
@click.option("--server", envvar="FFS_SERVER", default="https://sphere-api.featrix.com", help="API server URL")
|
|
10
|
+
@click.option("--cluster", envvar="FFS_CLUSTER", default=None, help="Compute cluster name")
|
|
11
|
+
@click.option("--json", "output_json", is_flag=True, help="Output raw JSON")
|
|
12
|
+
@click.option("--quiet", is_flag=True, help="Minimal output")
|
|
13
|
+
@click.pass_context
|
|
14
|
+
def main(ctx, server, cluster, output_json, quiet):
|
|
15
|
+
"""The Featrix Foundation Shell."""
|
|
16
|
+
ctx.ensure_object(dict)
|
|
17
|
+
ctx.obj = ClientState(server=server, cluster=cluster, output_json=output_json, quiet=quiet)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
main.add_command(model_cmd.model)
|
|
21
|
+
main.add_command(server_cmd.server)
|
ffs/client.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import click
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
from featrixsphere.api import FeatrixSphere
|
|
5
|
+
from featrixsphere import FeatrixSphereClient
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class ClientState:
|
|
10
|
+
server: str
|
|
11
|
+
cluster: str | None
|
|
12
|
+
output_json: bool
|
|
13
|
+
quiet: bool
|
|
14
|
+
_client: FeatrixSphere | None = None
|
|
15
|
+
_low_client: FeatrixSphereClient | None = None
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def client(self) -> FeatrixSphere:
|
|
19
|
+
"""OO API client."""
|
|
20
|
+
if self._client is None:
|
|
21
|
+
self._client = FeatrixSphere(
|
|
22
|
+
base_url=self.server,
|
|
23
|
+
compute_cluster=self.cluster,
|
|
24
|
+
)
|
|
25
|
+
return self._client
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def low(self) -> FeatrixSphereClient:
|
|
29
|
+
"""Low-level client for operations not on the OO API."""
|
|
30
|
+
if self._low_client is None:
|
|
31
|
+
self._low_client = FeatrixSphereClient(
|
|
32
|
+
base_url=self.server,
|
|
33
|
+
compute_cluster=self.cluster,
|
|
34
|
+
)
|
|
35
|
+
return self._low_client
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
pass_client = click.make_pass_decorator(ClientState)
|
ffs/model_cmd.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""ffs model subcommands."""
|
|
2
|
+
import json
|
|
3
|
+
import time
|
|
4
|
+
import click
|
|
5
|
+
|
|
6
|
+
from ffs.client import pass_client, ClientState
|
|
7
|
+
from ffs.output import print_json, print_kv, console
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.group()
|
|
11
|
+
def model():
|
|
12
|
+
"""Manage foundational models."""
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@model.command()
|
|
17
|
+
@click.option("--name", required=True, help="Model name")
|
|
18
|
+
@click.option("--data", "data_file", required=True, type=click.Path(exists=True), help="CSV/Parquet/JSON file")
|
|
19
|
+
@click.option("--epochs", type=int, default=None, help="Training epochs (auto if omitted)")
|
|
20
|
+
@click.option("--ignore-columns", default=None, help="Comma-separated columns to ignore")
|
|
21
|
+
@pass_client
|
|
22
|
+
def create(state: ClientState, name, data_file, epochs, ignore_columns):
|
|
23
|
+
"""Create a new foundational model from data."""
|
|
24
|
+
ignore = [c.strip() for c in ignore_columns.split(",")] if ignore_columns else None
|
|
25
|
+
fm = state.client.create_foundational_model(
|
|
26
|
+
name=name,
|
|
27
|
+
csv_file=data_file,
|
|
28
|
+
ignore_columns=ignore,
|
|
29
|
+
epochs=epochs,
|
|
30
|
+
session_name_prefix=name,
|
|
31
|
+
)
|
|
32
|
+
if state.output_json:
|
|
33
|
+
print_json({"model_id": fm.id, "status": fm.status})
|
|
34
|
+
else:
|
|
35
|
+
console.print(f"[green]Model created:[/green] {fm.id}")
|
|
36
|
+
console.print(f"Status: {fm.status}")
|
|
37
|
+
console.print(f"\nRun [bold]ffs model wait {fm.id}[/bold] to monitor training.")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@model.command("list")
|
|
41
|
+
@click.option("--prefix", default="", help="Filter by name prefix")
|
|
42
|
+
@pass_client
|
|
43
|
+
def list_models(state: ClientState, prefix):
|
|
44
|
+
"""List models."""
|
|
45
|
+
sessions = state.client.list_sessions(name_prefix=prefix)
|
|
46
|
+
if state.output_json:
|
|
47
|
+
print_json(sessions)
|
|
48
|
+
elif not sessions:
|
|
49
|
+
console.print("No models found.")
|
|
50
|
+
else:
|
|
51
|
+
for sid in sessions:
|
|
52
|
+
console.print(sid)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@model.command()
|
|
56
|
+
@click.argument("model_id")
|
|
57
|
+
@pass_client
|
|
58
|
+
def show(state: ClientState, model_id):
|
|
59
|
+
"""Show model details."""
|
|
60
|
+
fm = state.client.foundational_model(model_id)
|
|
61
|
+
data = {
|
|
62
|
+
"model_id": fm.id,
|
|
63
|
+
"name": fm.name,
|
|
64
|
+
"status": fm.status,
|
|
65
|
+
"dimensions": fm.dimensions,
|
|
66
|
+
"epochs": fm.epochs,
|
|
67
|
+
"final_loss": fm.final_loss,
|
|
68
|
+
"compute_cluster": fm.compute_cluster,
|
|
69
|
+
}
|
|
70
|
+
if state.output_json:
|
|
71
|
+
print_json(data)
|
|
72
|
+
else:
|
|
73
|
+
print_kv({
|
|
74
|
+
"Model ID": fm.id,
|
|
75
|
+
"Name": fm.name or "(unnamed)",
|
|
76
|
+
"Status": fm.status,
|
|
77
|
+
"Dimensions": fm.dimensions or "—",
|
|
78
|
+
"Epochs": fm.epochs or "—",
|
|
79
|
+
"Final Loss": fm.final_loss or "—",
|
|
80
|
+
"Cluster": fm.compute_cluster or "—",
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@model.command()
|
|
85
|
+
@click.argument("model_id")
|
|
86
|
+
@pass_client
|
|
87
|
+
def columns(state: ClientState, model_id):
|
|
88
|
+
"""Show columns in the model's embedding space."""
|
|
89
|
+
fm = state.client.foundational_model(model_id)
|
|
90
|
+
cols = fm.get_columns()
|
|
91
|
+
if state.output_json:
|
|
92
|
+
print_json(cols)
|
|
93
|
+
else:
|
|
94
|
+
for col in cols:
|
|
95
|
+
console.print(col)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@model.command()
|
|
99
|
+
@click.argument("model_id")
|
|
100
|
+
@pass_client
|
|
101
|
+
def card(state: ClientState, model_id):
|
|
102
|
+
"""Show the model card."""
|
|
103
|
+
fm = state.client.foundational_model(model_id)
|
|
104
|
+
print_json(fm.get_model_card())
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _format_duration(seconds: int) -> str:
|
|
108
|
+
"""Format seconds into human-readable duration."""
|
|
109
|
+
if seconds < 60:
|
|
110
|
+
return f"{seconds}s"
|
|
111
|
+
minutes, secs = divmod(seconds, 60)
|
|
112
|
+
if minutes < 60:
|
|
113
|
+
return f"{minutes}m{secs:02d}s"
|
|
114
|
+
hours, minutes = divmod(minutes, 60)
|
|
115
|
+
return f"{hours}h{minutes:02d}m"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _job_status_lines(session_info) -> list[str]:
|
|
119
|
+
"""Build status lines from job plan."""
|
|
120
|
+
from datetime import datetime, timezone
|
|
121
|
+
now = datetime.now(timezone.utc)
|
|
122
|
+
lines = []
|
|
123
|
+
for job in session_info.job_plan:
|
|
124
|
+
jtype = job.get("job_type", "?")
|
|
125
|
+
jid = job.get("job_id")
|
|
126
|
+
if jid and jid in session_info.jobs:
|
|
127
|
+
j = session_info.jobs[jid]
|
|
128
|
+
status = j.get("status", "?")
|
|
129
|
+
progress = j.get("progress", 0)
|
|
130
|
+
created = j.get("created_at", "")
|
|
131
|
+
|
|
132
|
+
# Calculate how long this job has been in its current state
|
|
133
|
+
age = ""
|
|
134
|
+
if created:
|
|
135
|
+
try:
|
|
136
|
+
if isinstance(created, str):
|
|
137
|
+
created_dt = datetime.fromisoformat(created)
|
|
138
|
+
else:
|
|
139
|
+
created_dt = created
|
|
140
|
+
delta = int((now - created_dt).total_seconds())
|
|
141
|
+
age = f" ({_format_duration(delta)})"
|
|
142
|
+
except (ValueError, TypeError):
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
finished = j.get("finished_at", "")
|
|
146
|
+
duration = ""
|
|
147
|
+
if finished and created:
|
|
148
|
+
try:
|
|
149
|
+
if isinstance(finished, str):
|
|
150
|
+
finished_dt = datetime.fromisoformat(finished)
|
|
151
|
+
else:
|
|
152
|
+
finished_dt = finished
|
|
153
|
+
if isinstance(created, str):
|
|
154
|
+
created_dt = datetime.fromisoformat(created)
|
|
155
|
+
else:
|
|
156
|
+
created_dt = created
|
|
157
|
+
dur = int((finished_dt - created_dt).total_seconds())
|
|
158
|
+
duration = f" ({_format_duration(dur)})"
|
|
159
|
+
except (ValueError, TypeError):
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
queue = j.get("queue", "")
|
|
163
|
+
queue_str = f" [{queue}]" if queue and status != "done" else ""
|
|
164
|
+
|
|
165
|
+
if status == "done":
|
|
166
|
+
lines.append(f" [green]done[/green] {jtype}{duration}")
|
|
167
|
+
elif status == "running" and progress:
|
|
168
|
+
lines.append(f" [yellow]running {progress}%[/yellow] {jtype}{queue_str}{age}")
|
|
169
|
+
elif status == "running":
|
|
170
|
+
lines.append(f" [yellow]running[/yellow] {jtype}{queue_str}{age}")
|
|
171
|
+
else:
|
|
172
|
+
lines.append(f" [dim]{status}[/dim] {jtype}{queue_str}{age}")
|
|
173
|
+
else:
|
|
174
|
+
lines.append(f" [dim]pending[/dim] {jtype}")
|
|
175
|
+
return lines
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@model.command()
|
|
179
|
+
@click.argument("model_id")
|
|
180
|
+
@click.option("--poll-interval", type=int, default=10, help="Seconds between checks")
|
|
181
|
+
@click.option("--timeout", type=int, default=3600, help="Max wait time in seconds")
|
|
182
|
+
@pass_client
|
|
183
|
+
def wait(state: ClientState, model_id, poll_interval, timeout):
|
|
184
|
+
"""Wait for model training to complete."""
|
|
185
|
+
start = time.time()
|
|
186
|
+
first = True
|
|
187
|
+
while True:
|
|
188
|
+
session = state.low.get_session_status(model_id)
|
|
189
|
+
elapsed = int(time.time() - start)
|
|
190
|
+
|
|
191
|
+
if session.status == "done":
|
|
192
|
+
fm = state.client.foundational_model(model_id)
|
|
193
|
+
console.print(f"\n[green]Training complete.[/green]")
|
|
194
|
+
if not state.quiet:
|
|
195
|
+
print_kv({
|
|
196
|
+
"Model ID": fm.id,
|
|
197
|
+
"Status": fm.status,
|
|
198
|
+
"Dimensions": fm.dimensions or "—",
|
|
199
|
+
"Epochs": fm.epochs or "—",
|
|
200
|
+
"Final Loss": fm.final_loss or "—",
|
|
201
|
+
})
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
if session.status == "error":
|
|
205
|
+
console.print(f"\n[red]Training failed.[/red]")
|
|
206
|
+
for j in session.jobs.values():
|
|
207
|
+
if j.get("status") == "error":
|
|
208
|
+
console.print(f" {j['job_type']}: {j.get('error', 'unknown error')}")
|
|
209
|
+
raise SystemExit(1)
|
|
210
|
+
|
|
211
|
+
if elapsed > timeout:
|
|
212
|
+
console.print(f"\n[red]Timeout after {timeout}s. Status: {session.status}[/red]")
|
|
213
|
+
raise SystemExit(1)
|
|
214
|
+
|
|
215
|
+
# Clear screen and redraw
|
|
216
|
+
if not first:
|
|
217
|
+
# Move cursor up to overwrite previous output
|
|
218
|
+
n_lines = len(session.job_plan) + 2 # header + blank + jobs
|
|
219
|
+
click.echo(f"\033[{n_lines}A\033[J", nl=False)
|
|
220
|
+
first = False
|
|
221
|
+
|
|
222
|
+
console.print(f"[bold]Waiting for {model_id}[/bold] ({_format_duration(elapsed)})")
|
|
223
|
+
for line in _job_status_lines(session):
|
|
224
|
+
console.print(line)
|
|
225
|
+
|
|
226
|
+
time.sleep(poll_interval)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@model.command()
|
|
231
|
+
@click.argument("model_id")
|
|
232
|
+
@click.option("--data", "data_file", required=True, type=click.Path(exists=True), help="New data file")
|
|
233
|
+
@click.option("--epochs", type=int, default=None, help="Additional epochs")
|
|
234
|
+
@pass_client
|
|
235
|
+
def extend(state: ClientState, model_id, data_file, epochs):
|
|
236
|
+
"""Extend a model with new data."""
|
|
237
|
+
fm = state.client.foundational_model(model_id)
|
|
238
|
+
kwargs = {}
|
|
239
|
+
if epochs:
|
|
240
|
+
kwargs["epochs"] = epochs
|
|
241
|
+
new_fm = fm.extend(new_data_file=data_file, **kwargs)
|
|
242
|
+
if state.output_json:
|
|
243
|
+
print_json({"model_id": new_fm.id, "parent_model_id": model_id, "status": new_fm.status})
|
|
244
|
+
else:
|
|
245
|
+
console.print(f"[green]Extended model created:[/green] {new_fm.id}")
|
|
246
|
+
console.print(f"Run [bold]ffs model wait {new_fm.id}[/bold] to monitor.")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@model.command()
|
|
251
|
+
@click.argument("model_id")
|
|
252
|
+
@click.argument("record_json")
|
|
253
|
+
@click.option("--short", is_flag=True, help="Return 3D short embedding for visualization")
|
|
254
|
+
@pass_client
|
|
255
|
+
def encode(state: ClientState, model_id, record_json, short):
|
|
256
|
+
"""Encode a record into the embedding space."""
|
|
257
|
+
record = json.loads(record_json)
|
|
258
|
+
if short:
|
|
259
|
+
# OO API encode() doesn't support short — use low-level client
|
|
260
|
+
result = state.low.encode_records(model_id, record, short=True)
|
|
261
|
+
print_json(result)
|
|
262
|
+
else:
|
|
263
|
+
fm = state.client.foundational_model(model_id)
|
|
264
|
+
vectors = fm.encode(record)
|
|
265
|
+
print_json(vectors)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@model.command()
|
|
269
|
+
@click.argument("model_id")
|
|
270
|
+
@click.option("--org", required=True, help="Organization ID")
|
|
271
|
+
@click.option("--name", default=None, help="Published name")
|
|
272
|
+
@pass_client
|
|
273
|
+
def publish(state: ClientState, model_id, org, name):
|
|
274
|
+
"""Publish a model."""
|
|
275
|
+
fm = state.client.foundational_model(model_id)
|
|
276
|
+
result = fm.publish(org_id=org, name=name)
|
|
277
|
+
if state.output_json:
|
|
278
|
+
print_json(result)
|
|
279
|
+
else:
|
|
280
|
+
console.print(f"[green]Published:[/green] {result.get('published_path', model_id)}")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@model.command()
|
|
284
|
+
@click.argument("model_id")
|
|
285
|
+
@pass_client
|
|
286
|
+
def unpublish(state: ClientState, model_id):
|
|
287
|
+
"""Unpublish a model."""
|
|
288
|
+
fm = state.client.foundational_model(model_id)
|
|
289
|
+
result = fm.unpublish()
|
|
290
|
+
if state.output_json:
|
|
291
|
+
print_json(result)
|
|
292
|
+
else:
|
|
293
|
+
console.print(f"[green]Unpublished:[/green] {model_id}")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@model.command()
|
|
297
|
+
@click.argument("model_id")
|
|
298
|
+
@click.option("--message", required=True, help="Deprecation warning message")
|
|
299
|
+
@click.option("--expires", required=True, help="Expiration date (ISO format)")
|
|
300
|
+
@pass_client
|
|
301
|
+
def deprecate(state: ClientState, model_id, message, expires):
|
|
302
|
+
"""Deprecate a model with a warning and expiration date."""
|
|
303
|
+
fm = state.client.foundational_model(model_id)
|
|
304
|
+
result = fm.deprecate(warning_message=message, expiration_date=expires)
|
|
305
|
+
if state.output_json:
|
|
306
|
+
print_json(result)
|
|
307
|
+
else:
|
|
308
|
+
console.print(f"[yellow]Deprecated:[/yellow] {model_id}")
|
|
309
|
+
console.print(f"Expires: {expires}")
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@model.command()
|
|
313
|
+
@click.argument("model_id")
|
|
314
|
+
@click.confirmation_option(prompt="Are you sure you want to delete this model?")
|
|
315
|
+
@pass_client
|
|
316
|
+
def delete(state: ClientState, model_id):
|
|
317
|
+
"""Delete a model."""
|
|
318
|
+
# Not on OO API yet — use low-level client
|
|
319
|
+
result = state.low.mark_for_deletion(model_id)
|
|
320
|
+
if state.output_json:
|
|
321
|
+
print_json(result)
|
|
322
|
+
else:
|
|
323
|
+
console.print(f"[red]Marked for deletion:[/red] {model_id}")
|
ffs/output.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Output formatting helpers."""
|
|
2
|
+
import json
|
|
3
|
+
import click
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
|
|
7
|
+
console = Console()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def print_json(data):
|
|
11
|
+
"""Print raw JSON to stdout."""
|
|
12
|
+
click.echo(json.dumps(data, indent=2, default=str))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def print_kv(pairs: dict, title: str | None = None):
|
|
16
|
+
"""Print key-value pairs as a rich table."""
|
|
17
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
18
|
+
table.add_column(style="bold cyan")
|
|
19
|
+
table.add_column()
|
|
20
|
+
if title:
|
|
21
|
+
console.print(f"\n[bold]{title}[/bold]")
|
|
22
|
+
for k, v in pairs.items():
|
|
23
|
+
table.add_row(str(k), str(v))
|
|
24
|
+
console.print(table)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def print_list_table(rows: list[dict], columns: list[str], title: str | None = None):
|
|
28
|
+
"""Print a list of dicts as a rich table."""
|
|
29
|
+
table = Table(title=title)
|
|
30
|
+
for col in columns:
|
|
31
|
+
table.add_column(col, style="cyan" if col.endswith("id") or col.endswith("ID") else None)
|
|
32
|
+
for row in rows:
|
|
33
|
+
table.add_row(*[str(row.get(c, "")) for c in columns])
|
|
34
|
+
console.print(table)
|
ffs/server_cmd.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""ffs server subcommands."""
|
|
2
|
+
import click
|
|
3
|
+
|
|
4
|
+
from ffs.client import pass_client, ClientState
|
|
5
|
+
from ffs.output import print_json, print_kv, console
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@click.group()
|
|
9
|
+
def server():
|
|
10
|
+
"""Server operations."""
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@server.command()
|
|
15
|
+
@pass_client
|
|
16
|
+
def health(state: ClientState):
|
|
17
|
+
"""Check API server health."""
|
|
18
|
+
result = state.client.health_check()
|
|
19
|
+
if state.output_json:
|
|
20
|
+
print_json(result)
|
|
21
|
+
else:
|
|
22
|
+
print_kv(result, title="Server Health")
|