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.
- fulfil_cli/__init__.py +3 -0
- fulfil_cli/__main__.py +5 -0
- fulfil_cli/auth/__init__.py +0 -0
- fulfil_cli/auth/api_key.py +76 -0
- fulfil_cli/auth/keyring_store.py +25 -0
- fulfil_cli/cli/__init__.py +7 -0
- fulfil_cli/cli/app.py +248 -0
- fulfil_cli/cli/commands/__init__.py +0 -0
- fulfil_cli/cli/commands/api.py +63 -0
- fulfil_cli/cli/commands/auth.py +167 -0
- fulfil_cli/cli/commands/completion.py +65 -0
- fulfil_cli/cli/commands/config.py +68 -0
- fulfil_cli/cli/commands/model.py +445 -0
- fulfil_cli/cli/commands/report.py +229 -0
- fulfil_cli/cli/state.py +61 -0
- fulfil_cli/client/__init__.py +0 -0
- fulfil_cli/client/errors.py +121 -0
- fulfil_cli/client/http.py +164 -0
- fulfil_cli/config/__init__.py +0 -0
- fulfil_cli/config/manager.py +114 -0
- fulfil_cli/config/paths.py +29 -0
- fulfil_cli/output/__init__.py +0 -0
- fulfil_cli/output/describe.py +125 -0
- fulfil_cli/output/formatter.py +113 -0
- fulfil_cli/output/json_output.py +29 -0
- fulfil_cli/output/report.py +189 -0
- fulfil_cli/output/table.py +42 -0
- fulfil_cli-0.1.0.dist-info/METADATA +291 -0
- fulfil_cli-0.1.0.dist-info/RECORD +31 -0
- fulfil_cli-0.1.0.dist-info/WHEEL +4 -0
- fulfil_cli-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -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)
|