featrix-shell 0.1.0__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.
@@ -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,90 @@
1
+ # ffs-ai-shell
2
+ The Featrix Foundation Shell (ffs)
3
+
4
+ Transform any CSV into a production-ready ML model from the command line.
5
+
6
+ ## CLI Grammar
7
+
8
+ ```
9
+ ffs [global-options] <command> <subcommand> [options] [args]
10
+ ```
11
+
12
+ ### Global Options
13
+ ```
14
+ ffs --server URL # API server (default: https://sphere-api.featrix.com)
15
+ ffs --cluster NAME # Compute cluster (burrito, churro, etc.)
16
+ ffs --json # Output raw JSON instead of formatted tables
17
+ ffs --quiet # Minimal output
18
+ ```
19
+
20
+ ### Models (Foundational Models / Embedding Spaces)
21
+ ```
22
+ ffs model create --name NAME --data FILE [--epochs N] [--ignore-columns COL,COL]
23
+ ffs model list [--prefix PREFIX]
24
+ ffs model show MODEL_ID
25
+ ffs model columns MODEL_ID
26
+ ffs model card MODEL_ID
27
+ ffs model wait MODEL_ID
28
+ ffs model extend MODEL_ID --data FILE [--epochs N]
29
+ ffs model encode MODEL_ID RECORD_JSON [--short]
30
+ ffs model publish MODEL_ID --org ORG --name NAME
31
+ ffs model unpublish MODEL_ID
32
+ ffs model deprecate MODEL_ID --message MSG --expires DATE
33
+ ffs model delete MODEL_ID
34
+ ```
35
+
36
+ ### Predictors
37
+ ```
38
+ ffs predictor create MODEL_ID --target COLUMN --type {set,scalar} [--name NAME] [--data FILE]
39
+ ffs predictor list MODEL_ID
40
+ ffs predictor show MODEL_ID [--predictor-id ID]
41
+ ffs predictor metrics MODEL_ID [--predictor-id ID]
42
+ ffs predictor train-more MODEL_ID --epochs N [--predictor-id ID | --target COLUMN]
43
+ ffs predictor remove MODEL_ID {--predictor-id ID | --target COLUMN}
44
+ ```
45
+
46
+ ### Predict
47
+ ```
48
+ ffs predict MODEL_ID RECORD_JSON [--target COLUMN] [--predictor-id ID]
49
+ ffs predict MODEL_ID --file FILE [--target COLUMN] [--sample N]
50
+ ffs predict explain MODEL_ID RECORD_JSON [--target COLUMN]
51
+ ```
52
+
53
+ ### Vector Database
54
+ ```
55
+ ffs vectordb create MODEL_ID [--name NAME] [--records FILE]
56
+ ffs vectordb search MODEL_ID RECORD_JSON [-k N]
57
+ ffs vectordb add MODEL_ID --records FILE
58
+ ffs vectordb size MODEL_ID
59
+ ```
60
+
61
+ ### Server
62
+ ```
63
+ ffs server health
64
+ ```
65
+
66
+ ### Usage Examples
67
+ ```bash
68
+ # End-to-end: create model, train predictor, make prediction
69
+ ffs model create --name "customers" --data customers.csv
70
+ ffs model wait abc123
71
+ ffs predictor create abc123 --target churn --type set --rare-label "yes"
72
+ ffs model wait abc123
73
+ ffs predict abc123 '{"age": 35, "income": 50000}'
74
+
75
+ # Batch predict from CSV
76
+ ffs predict abc123 --file test_data.csv --target churn
77
+
78
+ # Similarity search
79
+ ffs vectordb create abc123 --name "customer_search"
80
+ ffs vectordb search abc123 '{"age": 35}' -k 10
81
+
82
+ # Pipe-friendly
83
+ ffs predict abc123 --file input.csv --json | jq '.predictions[].predicted_class'
84
+ ```
85
+
86
+ ## Architecture
87
+
88
+ - `MODEL_ID` = `session_id` in the underlying Featrix Sphere API
89
+ - CLI wraps the `featrixsphere` Python package
90
+ - Built with Click
@@ -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,14 @@
1
+ README.md
2
+ pyproject.toml
3
+ featrix_shell.egg-info/PKG-INFO
4
+ featrix_shell.egg-info/SOURCES.txt
5
+ featrix_shell.egg-info/dependency_links.txt
6
+ featrix_shell.egg-info/entry_points.txt
7
+ featrix_shell.egg-info/requires.txt
8
+ featrix_shell.egg-info/top_level.txt
9
+ ffs/__init__.py
10
+ ffs/cli.py
11
+ ffs/client.py
12
+ ffs/model_cmd.py
13
+ ffs/output.py
14
+ ffs/server_cmd.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ffs = ffs.cli:main
@@ -0,0 +1,3 @@
1
+ click>=8.0
2
+ featrixsphere
3
+ rich
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -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)
@@ -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)
@@ -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}")
@@ -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)
@@ -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")
@@ -0,0 +1,20 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "featrix-shell"
7
+ version = "0.1.0"
8
+ description = "The Featrix Foundation Shell (ffs)"
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "click>=8.0",
12
+ "featrixsphere",
13
+ "rich",
14
+ ]
15
+
16
+ [project.scripts]
17
+ ffs = "ffs.cli:main"
18
+
19
+ [tool.setuptools.packages.find]
20
+ include = ["ffs*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+