fulfil-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,68 @@
1
+ """Config commands: get, set, list."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from rich.console import Console
7
+
8
+ from fulfil_cli.config.manager import ConfigManager
9
+ from fulfil_cli.output.formatter import output
10
+
11
+ app = typer.Typer(help="Manage CLI configuration.")
12
+ console = Console()
13
+
14
+
15
+ @app.command("set")
16
+ def config_set(
17
+ key: str = typer.Argument(help="Config key (e.g. 'merchant')"),
18
+ value: str = typer.Argument(help="Value to set"),
19
+ ) -> None:
20
+ """Set a configuration value."""
21
+ config = ConfigManager()
22
+ config.set(key, value)
23
+ console.print(f"[green]Set {key} = {value}[/green]")
24
+
25
+
26
+ @app.command("get")
27
+ def config_get(
28
+ key: str = typer.Argument(help="Config key"),
29
+ json: bool = typer.Option(False, "--json", help="Output as JSON"),
30
+ ) -> None:
31
+ """Get a configuration value."""
32
+ config = ConfigManager()
33
+ value = config.get(key)
34
+ if value is None:
35
+ console.print(f"[yellow]Key '{key}' is not set.[/yellow]")
36
+ raise typer.Exit(code=1)
37
+ if json:
38
+ output({key: value}, json_flag=True)
39
+ else:
40
+ print(value)
41
+
42
+
43
+ @app.command("list")
44
+ def config_list(
45
+ json: bool = typer.Option(False, "--json", help="Output as JSON"),
46
+ ) -> None:
47
+ """List all configuration values."""
48
+ config = ConfigManager()
49
+ data = config.all()
50
+ if json:
51
+ output(data, json_flag=True)
52
+ elif not data:
53
+ console.print("[dim]No configuration set.[/dim]")
54
+ else:
55
+ for key, value in _flatten(data):
56
+ console.print(f"{key} = {value}")
57
+
58
+
59
+ def _flatten(data: dict, prefix: str = "") -> list[tuple[str, str]]:
60
+ """Flatten nested dict to dotted key-value pairs."""
61
+ items: list[tuple[str, str]] = []
62
+ for key, value in data.items():
63
+ full_key = f"{prefix}{key}" if not prefix else f"{prefix}.{key}"
64
+ if isinstance(value, dict):
65
+ items.extend(_flatten(value, f"{full_key}."))
66
+ else:
67
+ items.append((full_key, str(value)))
68
+ return items
@@ -0,0 +1,445 @@
1
+ """Dynamic model subcommand actions: list/get/create/update/delete/count/call/fields."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ import json
7
+ import sys
8
+ from typing import Any
9
+
10
+ import click
11
+ import typer
12
+ from rich.console import Console
13
+
14
+ from fulfil_cli.cli.state import get_client, is_quiet
15
+ from fulfil_cli.client.errors import FulfilError, ValidationError
16
+ from fulfil_cli.output.formatter import output, output_model_describe, should_use_json
17
+
18
+ console = Console(stderr=True)
19
+
20
+
21
+ def _parse_json_arg(value: str, arg_name: str) -> Any:
22
+ """Parse a JSON string argument."""
23
+ try:
24
+ return json.loads(value)
25
+ except json.JSONDecodeError as exc:
26
+ console.print(f"[red]Invalid JSON for {arg_name}: {exc}[/red]")
27
+ raise typer.Exit(code=7) from None
28
+
29
+
30
+ def _parse_fields(value: str | None) -> list[str] | None:
31
+ """Parse comma-separated field names."""
32
+ if not value:
33
+ return None
34
+ return [f.strip() for f in value.split(",") if f.strip()]
35
+
36
+
37
+ def _parse_ids(value: str) -> list[int]:
38
+ """Parse comma-separated IDs."""
39
+ try:
40
+ return [int(x.strip()) for x in value.split(",")]
41
+ except ValueError:
42
+ console.print("[red]IDs must be comma-separated integers.[/red]")
43
+ raise typer.Exit(code=2) from None
44
+
45
+
46
+ def _parse_order(value: str) -> dict[str, str]:
47
+ """Parse order string like 'sale_date:desc,name:asc' into {"sale_date": "DESC", "name": "ASC"}.
48
+
49
+ Each pair is field:direction where direction defaults to ASC if omitted.
50
+ """
51
+ result: dict[str, str] = {}
52
+ for part in value.split(","):
53
+ part = part.strip()
54
+ if not part:
55
+ continue
56
+ if ":" in part:
57
+ field, direction = part.split(":", 1)
58
+ result[field.strip()] = direction.strip().upper()
59
+ else:
60
+ result[part] = "ASC"
61
+ return result
62
+
63
+
64
+ def _handle_error(exc: FulfilError, *, model: str | None = None) -> None:
65
+ """Print error with contextual hints and exit."""
66
+ console.print(f"[red]Error ({model or 'fulfil'}): {exc}[/red]")
67
+ if exc.hint:
68
+ console.print(f"[dim]Hint: {exc.hint}[/dim]")
69
+ elif not is_quiet() and model and isinstance(exc, ValidationError):
70
+ console.print(f"[dim]Hint: Run 'fulfil {model} describe' to see valid field names.[/dim]")
71
+ raise typer.Exit(code=exc.exit_code)
72
+
73
+
74
+ def create_model_group(model_name: str) -> click.Group:
75
+ """Create a Click group for a model with all standard actions."""
76
+
77
+ @click.group(name=model_name, help=f"Interact with {model_name} records.")
78
+ @click.pass_context
79
+ def model_group(ctx: click.Context) -> None:
80
+ ctx.ensure_object(dict)
81
+ ctx.obj["model"] = model_name
82
+
83
+ @model_group.command("list")
84
+ @click.option(
85
+ "--where",
86
+ default=None,
87
+ help=(
88
+ "MongoDB-style JSON filter. "
89
+ 'Equality: \'{"state": "confirmed"}\'. '
90
+ "Operators (gt, gte, lt, lte, ne, in, not_in, contains, startswith, endswith): "
91
+ '\'{"total_amount": {"gte": 100}}\'. '
92
+ 'OR logic: \'{"or": [{"state": "draft"}, {"state": "confirmed"}]}\''
93
+ ),
94
+ )
95
+ @click.option(
96
+ "--fields",
97
+ "fields_str",
98
+ default=None,
99
+ help="Comma-separated field names, e.g. name,state,sale_date",
100
+ )
101
+ @click.option(
102
+ "--order",
103
+ default=None,
104
+ help=(
105
+ "Sort order as field:direction pairs, comma-separated. "
106
+ "Direction is ASC or DESC (default: ASC). "
107
+ "Examples: sale_date:desc or sale_date:desc,name:asc or name"
108
+ ),
109
+ )
110
+ @click.option(
111
+ "--cursor",
112
+ default=None,
113
+ help="Opaque cursor for fetching the next page (from previous response).",
114
+ )
115
+ @click.option(
116
+ "--page-size", "--limit", default=20, type=int, help="Records per page (default: 20)"
117
+ )
118
+ @click.option("--json", "json_flag", is_flag=True, help="Output as JSON")
119
+ @click.pass_context
120
+ def list_cmd(
121
+ ctx: click.Context,
122
+ where: str | None,
123
+ fields_str: str | None,
124
+ order: str | None,
125
+ cursor: str | None,
126
+ page_size: int,
127
+ json_flag: bool,
128
+ ) -> None:
129
+ """List records matching filters.
130
+
131
+ Examples:
132
+
133
+ \b
134
+ fulfil sales_order list
135
+ fulfil sales_order list --where '{"state": "confirmed"}' --fields name,state
136
+ fulfil sales_order list --order sale_date:desc --limit 50
137
+ fulfil sales_order list --cursor <token>
138
+ """
139
+ model = ctx.obj["model"]
140
+ params: dict[str, Any] = {"page_size": page_size}
141
+
142
+ if where:
143
+ params["where"] = _parse_json_arg(where, "--where")
144
+ if order:
145
+ params["ordering"] = _parse_order(order)
146
+ if fields_str:
147
+ params["fields"] = _parse_fields(fields_str)
148
+ if cursor:
149
+ params["cursor"] = cursor
150
+
151
+ client = get_client()
152
+
153
+ try:
154
+ result = client.call(f"model.{model}.find", **params)
155
+ except FulfilError as exc:
156
+ _handle_error(exc, model=model)
157
+
158
+ # Determine if output will be JSON (explicit flag or auto-detected)
159
+ use_json = json_flag or should_use_json()
160
+
161
+ # Handle envelope response: {"data": [...], "pagination": {...}}
162
+ if isinstance(result, dict) and "data" in result and "pagination" in result:
163
+ records = result["data"]
164
+ pagination = result["pagination"]
165
+
166
+ if use_json:
167
+ output(result, json_flag=True)
168
+ else:
169
+ output(records, json_flag=False, title=model)
170
+ if not is_quiet() and pagination:
171
+ count = len(records)
172
+ next_cursor = pagination.get("next_cursor")
173
+ has_more = pagination.get("has_more", next_cursor is not None)
174
+ if has_more:
175
+ console.print(f"[dim]{count} records (more available)[/dim]")
176
+ if next_cursor:
177
+ console.print(
178
+ f"[dim]Next page: fulfil {model} list"
179
+ f" --cursor {next_cursor}[/dim]"
180
+ )
181
+ else:
182
+ console.print(f"[dim]{count} records[/dim]")
183
+ else:
184
+ # Fallback for servers that still return bare arrays
185
+ output(result, json_flag=json_flag, title=model)
186
+
187
+ @model_group.command("get")
188
+ @click.argument("ids", type=str)
189
+ @click.option("--json", "json_flag", is_flag=True, help="Output as JSON")
190
+ @click.pass_context
191
+ def get_cmd(
192
+ ctx: click.Context,
193
+ ids: str,
194
+ json_flag: bool,
195
+ ) -> None:
196
+ """Get records by ID(s). IDS is one or more comma-separated integers (e.g. 123 or 1,2,3)."""
197
+ model = ctx.obj["model"]
198
+ parsed_ids = _parse_ids(ids)
199
+
200
+ try:
201
+ client = get_client()
202
+ result = client.call(f"model.{model}.serialize", parsed_ids)
203
+ except FulfilError as exc:
204
+ _handle_error(exc, model=model)
205
+
206
+ # Single ID → single record output
207
+ if len(parsed_ids) == 1 and isinstance(result, list) and len(result) == 1:
208
+ result = result[0]
209
+
210
+ output(result, json_flag=json_flag, title=model)
211
+
212
+ @model_group.command("create")
213
+ @click.option(
214
+ "--data",
215
+ required=True,
216
+ help=(
217
+ "Record data as a JSON object or array of objects. "
218
+ 'Example: \'{"name": "Test", "code": "T001"}\' or '
219
+ '\'[{"name": "A"}, {"name": "B"}]\''
220
+ ),
221
+ )
222
+ @click.option("--json", "json_flag", is_flag=True, help="Output as JSON")
223
+ @click.pass_context
224
+ def create_cmd(
225
+ ctx: click.Context,
226
+ data: str,
227
+ json_flag: bool,
228
+ ) -> None:
229
+ """Create new record(s). Returns the created record ID(s)."""
230
+ model = ctx.obj["model"]
231
+ parsed = _parse_json_arg(data, "--data")
232
+ vlist = parsed if isinstance(parsed, list) else [parsed]
233
+
234
+ try:
235
+ client = get_client()
236
+ result = client.call(f"model.{model}.create", vlist=vlist)
237
+ except FulfilError as exc:
238
+ _handle_error(exc, model=model)
239
+
240
+ output(result, json_flag=json_flag)
241
+
242
+ @model_group.command("update")
243
+ @click.argument("ids", type=str)
244
+ @click.option(
245
+ "--data",
246
+ required=True,
247
+ help=(
248
+ "JSON object with fields to update. "
249
+ 'Example: \'{"state": "confirmed", "comment": "Approved"}\''
250
+ ),
251
+ )
252
+ @click.option("--json", "json_flag", is_flag=True, help="Output as JSON")
253
+ @click.pass_context
254
+ def update_cmd(
255
+ ctx: click.Context,
256
+ ids: str,
257
+ data: str,
258
+ json_flag: bool,
259
+ ) -> None:
260
+ """Update record(s) by ID.
261
+
262
+ IDS is one or more comma-separated integers (e.g. 42 or 1,2,3).
263
+ """
264
+ model = ctx.obj["model"]
265
+ parsed_ids = _parse_ids(ids)
266
+ values = _parse_json_arg(data, "--data")
267
+
268
+ try:
269
+ client = get_client()
270
+ result = client.call(f"model.{model}.update", ids=parsed_ids, values=values)
271
+ except FulfilError as exc:
272
+ _handle_error(exc, model=model)
273
+
274
+ output(result, json_flag=json_flag)
275
+
276
+ @model_group.command("delete")
277
+ @click.argument("ids", type=str)
278
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
279
+ @click.pass_context
280
+ def delete_cmd(ctx: click.Context, ids: str, yes: bool) -> None:
281
+ """Permanently delete record(s) by ID. This cannot be undone.
282
+
283
+ IDS is one or more comma-separated integers.
284
+ """
285
+ model = ctx.obj["model"]
286
+ parsed_ids = _parse_ids(ids)
287
+
288
+ if not yes:
289
+ if not sys.stdin.isatty():
290
+ console.print(
291
+ "[red]Error: Delete requires confirmation. "
292
+ "Use --yes/-y to skip in non-interactive mode.[/red]"
293
+ )
294
+ raise typer.Exit(code=2)
295
+ id_list = ", ".join(str(i) for i in parsed_ids)
296
+ if not click.confirm(f"Delete {len(parsed_ids)} record(s) from {model} ({id_list})?"):
297
+ console.print("[dim]Aborted.[/dim]")
298
+ raise typer.Exit(code=0)
299
+
300
+ try:
301
+ client = get_client()
302
+ client.call(f"model.{model}.delete", ids=parsed_ids)
303
+ except FulfilError as exc:
304
+ _handle_error(exc, model=model)
305
+
306
+ console.print(f"[green]Deleted {len(parsed_ids)} record(s).[/green]")
307
+
308
+ @model_group.command("count")
309
+ @click.option(
310
+ "--where",
311
+ default=None,
312
+ help=(
313
+ "MongoDB-style JSON filter (same syntax as list --where). "
314
+ 'Example: \'{"state": "confirmed"}\''
315
+ ),
316
+ )
317
+ @click.option("--json", "json_flag", is_flag=True, help="Output as JSON")
318
+ @click.pass_context
319
+ def count_cmd(
320
+ ctx: click.Context,
321
+ where: str | None,
322
+ json_flag: bool,
323
+ ) -> None:
324
+ """Count records matching filters. Returns a single integer."""
325
+ model = ctx.obj["model"]
326
+ params: dict[str, Any] = {}
327
+ if where:
328
+ params["where"] = _parse_json_arg(where, "--where")
329
+
330
+ try:
331
+ client = get_client()
332
+ result = client.call(f"model.{model}.count", **params)
333
+ except FulfilError as exc:
334
+ _handle_error(exc, model=model)
335
+
336
+ if json_flag:
337
+ output({"count": result}, json_flag=True)
338
+ else:
339
+ console.print(str(result))
340
+
341
+ @model_group.command("call")
342
+ @click.argument("method_name", type=str)
343
+ @click.option(
344
+ "--ids",
345
+ default=None,
346
+ help="Comma-separated record IDs to pass to the method, e.g. 1,2,3",
347
+ )
348
+ @click.option(
349
+ "--data",
350
+ default=None,
351
+ help=("Extra method arguments as a JSON object. Example: '{\"warehouse\": 1}'"),
352
+ )
353
+ @click.option("--json", "json_flag", is_flag=True, help="Output as JSON")
354
+ @click.pass_context
355
+ def call_cmd(
356
+ ctx: click.Context,
357
+ method_name: str,
358
+ ids: str | None,
359
+ data: str | None,
360
+ json_flag: bool,
361
+ ) -> None:
362
+ """Call a custom method on the model.
363
+
364
+ METHOD_NAME is the method suffix (e.g. 'confirm', 'process', 'cancel').
365
+ The full RPC method will be model.<model_name>.<METHOD_NAME>.
366
+
367
+ \b
368
+ Examples:
369
+ fulfil sales_order call confirm --ids 1,2,3
370
+ fulfil sales_order call process --ids 42
371
+ """
372
+ model = ctx.obj["model"]
373
+ params: dict[str, Any] = {}
374
+ if ids:
375
+ params["ids"] = _parse_ids(ids)
376
+ if data:
377
+ extra = _parse_json_arg(data, "--data")
378
+ if isinstance(extra, dict):
379
+ params.update(extra)
380
+
381
+ try:
382
+ client = get_client()
383
+ result = client.call(f"model.{model}.{method_name}", **params)
384
+ except FulfilError as exc:
385
+ _handle_error(exc, model=model)
386
+
387
+ output(result, json_flag=json_flag)
388
+
389
+ @model_group.command("describe")
390
+ @click.argument("endpoint_name", required=False, default=None)
391
+ @click.option("--json", "json_flag", is_flag=True, help="Output as JSON")
392
+ @click.pass_context
393
+ def describe_cmd(ctx: click.Context, endpoint_name: str | None, json_flag: bool) -> None:
394
+ """Describe the model, or a specific endpoint.
395
+
396
+ \b
397
+ fulfil sales_order describe # all fields and endpoints
398
+ fulfil sales_order describe find # details for the find endpoint
399
+ fulfil sales_order describe confirm # details for the confirm endpoint
400
+ """
401
+ model = ctx.obj["model"]
402
+ try:
403
+ client = get_client()
404
+ result = client.call("system.describe_model", model=model)
405
+ except FulfilError as exc:
406
+ _handle_error(exc, model=model)
407
+
408
+ if endpoint_name:
409
+ _describe_endpoint(result, model, endpoint_name, json_flag)
410
+ else:
411
+ output_model_describe(result, json_flag=json_flag)
412
+
413
+ @model_group.command("fields")
414
+ @click.option("--json", "json_flag", is_flag=True, help="Output as JSON")
415
+ @click.pass_context
416
+ def fields_cmd(ctx: click.Context, json_flag: bool) -> None:
417
+ """Alias for 'describe'."""
418
+ ctx.invoke(describe_cmd, endpoint_name=None, json_flag=json_flag)
419
+
420
+ return model_group
421
+
422
+
423
+ def _describe_endpoint(model_data: dict, model: str, endpoint_name: str, json_flag: bool) -> None:
424
+ """Show details for a specific endpoint, or error if not found."""
425
+ from fulfil_cli.output.describe import print_endpoint_detail
426
+
427
+ endpoints = model_data.get("endpoints", [])
428
+ for ep in endpoints:
429
+ if ep.get("rpc_name") == endpoint_name or ep.get("name") == endpoint_name:
430
+ if json_flag:
431
+ output(ep, json_flag=True)
432
+ else:
433
+ print_endpoint_detail(ep, model)
434
+ return
435
+
436
+ # Not found — show available endpoints
437
+ names = [ep.get("rpc_name", ep.get("name", "")) for ep in endpoints]
438
+ console.print(f"[red]Endpoint '{endpoint_name}' not found on {model}.[/red]")
439
+ if names:
440
+ matches = difflib.get_close_matches(endpoint_name, names, n=3, cutoff=0.4)
441
+ if matches:
442
+ console.print(f"[dim]Did you mean: {', '.join(matches)}?[/dim]")
443
+ else:
444
+ console.print(f"[dim]Available: {', '.join(sorted(names))}[/dim]")
445
+ raise typer.Exit(code=5)