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.
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: featrix-shell
3
+ Version: 0.1.0
4
+ Summary: The Featrix Foundation Shell (ffs)
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: click>=8.0
7
+ Requires-Dist: featrixsphere
8
+ Requires-Dist: rich
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ffs = ffs.cli:main
@@ -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")