dwf-platform-cli 0.2.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.
Files changed (47) hide show
  1. dwf_cli/__init__.py +3 -0
  2. dwf_cli/__main__.py +4 -0
  3. dwf_cli/api/__init__.py +3 -0
  4. dwf_cli/api/auth.py +46 -0
  5. dwf_cli/api/client.py +113 -0
  6. dwf_cli/api/datamodel.py +458 -0
  7. dwf_cli/api/formmodel.py +197 -0
  8. dwf_cli/api/funcmodel.py +436 -0
  9. dwf_cli/cli/__init__.py +69 -0
  10. dwf_cli/cli/_common.py +152 -0
  11. dwf_cli/cli/auth.py +197 -0
  12. dwf_cli/cli/config.py +200 -0
  13. dwf_cli/cli/datamodel.py +2270 -0
  14. dwf_cli/cli/formmodel.py +1007 -0
  15. dwf_cli/cli/funcmodel.py +2055 -0
  16. dwf_cli/cli/schema.py +210 -0
  17. dwf_cli/core/__init__.py +0 -0
  18. dwf_cli/core/config.py +177 -0
  19. dwf_cli/core/crypto.py +22 -0
  20. dwf_cli/core/errors.py +63 -0
  21. dwf_cli/core/output.py +6 -0
  22. dwf_cli/core/validator.py +129 -0
  23. dwf_cli/mcp/__init__.py +3 -0
  24. dwf_cli/mcp/server.py +411 -0
  25. dwf_cli/schemas/__init__.py +37 -0
  26. dwf_cli/schemas/datamodel/attribute_bind.schema.json +21 -0
  27. dwf_cli/schemas/datamodel/attribute_create.schema.json +24 -0
  28. dwf_cli/schemas/datamodel/attribute_update.schema.json +21 -0
  29. dwf_cli/schemas/datamodel/create.schema.json +27 -0
  30. dwf_cli/schemas/datamodel/excel_confirm.schema.json +65 -0
  31. dwf_cli/schemas/datamodel/external_create.schema.json +47 -0
  32. dwf_cli/schemas/datamodel/external_update.schema.json +47 -0
  33. dwf_cli/schemas/datamodel/object_create.schema.json +24 -0
  34. dwf_cli/schemas/datamodel/object_update.schema.json +17 -0
  35. dwf_cli/schemas/datamodel/relation_create.schema.json +34 -0
  36. dwf_cli/schemas/datamodel/relation_update.schema.json +34 -0
  37. dwf_cli/schemas/datamodel/update.schema.json +26 -0
  38. dwf_cli/schemas/funcmodel/app_create.schema.json +150 -0
  39. dwf_cli/schemas/funcmodel/app_update.schema.json +153 -0
  40. dwf_cli/schemas/funcmodel/language-package_create.schema.json +15 -0
  41. dwf_cli/schemas/funcmodel/operations_create.schema.json +77 -0
  42. dwf_cli/schemas/funcmodel/operations_update.schema.json +76 -0
  43. dwf_platform_cli-0.2.0.dist-info/METADATA +347 -0
  44. dwf_platform_cli-0.2.0.dist-info/RECORD +47 -0
  45. dwf_platform_cli-0.2.0.dist-info/WHEEL +4 -0
  46. dwf_platform_cli-0.2.0.dist-info/entry_points.txt +3 -0
  47. dwf_platform_cli-0.2.0.dist-info/licenses/LICENSE +190 -0
@@ -0,0 +1,2270 @@
1
+ from __future__ import annotations
2
+
3
+ import json as json_mod
4
+ from enum import Enum
5
+ from pathlib import Path
6
+ from typing import Annotated, Any
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ from dwf_cli.api import datamodel as datamodel_api
13
+ from dwf_cli.api.datamodel import ModelType
14
+ from dwf_cli.cli._common import (
15
+ get_client,
16
+ get_console,
17
+ handle_error,
18
+ is_tty,
19
+ resolve_format,
20
+ validate_path,
21
+ )
22
+ from dwf_cli.core.errors import ConflictError, DWFError
23
+ from dwf_cli.core.output import OutputFormat
24
+ from dwf_cli.core.validator import validate_body
25
+ from dwf_cli.schemas import lookup as lookup_schema
26
+
27
+ _HELP_CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
28
+
29
+ app = typer.Typer(
30
+ help="Data model and object management.\n\n"
31
+ "Examples:\n"
32
+ " dwf-cli datamodel list -t entity --search User\n"
33
+ " dwf-cli datamodel show User\n"
34
+ ' dwf-cli datamodel create --data \'[{"className":"TestClass"}]\'\n'
35
+ " dwf-cli datamodel create -t relation --data '[...]'\n"
36
+ " dwf-cli datamodel create -t external --data '[...]'\n"
37
+ " dwf-cli datamodel object list -c User -p 0 -s 10\n"
38
+ ' dwf-cli datamodel object create -c User --data \'{"name":"Alice"}\'\n'
39
+ ' dwf-cli datamodel attribute create --data \'{"attributeName":"age","valueType":"Integer"}\'',
40
+ context_settings=_HELP_CONTEXT_SETTINGS,
41
+ )
42
+
43
+ _object_sub = typer.Typer(
44
+ help="Object CRUD operations.", context_settings=_HELP_CONTEXT_SETTINGS
45
+ )
46
+ _attribute_sub = typer.Typer(
47
+ help="Attribute CRUD and binding operations.",
48
+ context_settings=_HELP_CONTEXT_SETTINGS,
49
+ )
50
+
51
+ app.add_typer(_object_sub, name="object")
52
+ app.add_typer(_attribute_sub, name="attribute")
53
+
54
+
55
+ DRY_RUN_EXIT_CODE = 10
56
+
57
+
58
+ _DATAMODEL_COLUMNS: list[tuple[str, str]] = [
59
+ ("className", "ClassName"),
60
+ ("displayName", "DisplayName"),
61
+ ("classType", "ClassType"),
62
+ ("isSystem", "System"),
63
+ ("zoneName", "Zone"),
64
+ ("state", "State"),
65
+ ]
66
+
67
+
68
+ def _extract_response(result: dict[str, Any]) -> tuple[list[dict], dict | None]:
69
+ payload = result.get("data", result)
70
+ if isinstance(payload, dict):
71
+ raw = payload.get("data", payload.get("items", []))
72
+ page_info = payload.get("pageInfo")
73
+ if isinstance(raw, dict):
74
+ items = list(raw.values())
75
+ elif isinstance(raw, list):
76
+ items = raw
77
+ else:
78
+ items = []
79
+ else:
80
+ items = payload if isinstance(payload, list) else []
81
+ page_info = result.get("pageInfo")
82
+ return items, page_info
83
+
84
+
85
+ def _print_table(
86
+ items: list[dict[str, Any]],
87
+ page_info: dict[str, Any] | None,
88
+ console: Console,
89
+ columns: list[tuple[str, str]] | None = None,
90
+ title: str | None = None,
91
+ ) -> None:
92
+ cols = columns or _DATAMODEL_COLUMNS
93
+ table = Table(
94
+ title=title,
95
+ show_header=True,
96
+ header_style="bold cyan",
97
+ show_lines=False,
98
+ )
99
+ table.add_column("#", style="dim", width=4)
100
+ for _, header in cols:
101
+ table.add_column(header)
102
+
103
+ for idx, item in enumerate(items, 1):
104
+ row: list[str] = [str(idx)]
105
+ for key, _ in cols:
106
+ val = item.get(key, "")
107
+ if key == "isSystem":
108
+ val = "[green]Yes[/green]" if val else "[dim]No[/dim]"
109
+ elif key == "state":
110
+ val = "[green]Active[/green]" if val == 1 else "[yellow]Draft[/yellow]"
111
+ else:
112
+ val = str(val)
113
+ row.append(val)
114
+ table.add_row(*row)
115
+
116
+ console.print(table)
117
+
118
+ if page_info:
119
+ total = page_info.get("totalCount", "?")
120
+ page_idx = page_info.get("pageIndex", "?")
121
+ page_sz = page_info.get("pageSize", "?")
122
+ console.print(f"[dim]Page {page_idx}, Size {page_sz}, Total {total}[/dim]")
123
+
124
+
125
+ def _read_payload(data: str, file: Path | None) -> Any:
126
+ if file:
127
+ file = validate_path(file)
128
+ return json_mod.loads(file.read_text(encoding="utf-8"))
129
+ if data:
130
+ return json_mod.loads(data)
131
+ raise DWFError("Provide --data or --file")
132
+
133
+
134
+ def _do_validate(command_path: str, payload: Any) -> None:
135
+ schema = lookup_schema(command_path)
136
+ if schema is None:
137
+ typer.echo(
138
+ f"No schema found for '{command_path}', skipping validation.", err=True
139
+ )
140
+ return
141
+ errors = validate_body(schema, payload)
142
+ if errors:
143
+ error_json = json_mod.dumps(
144
+ {"error": "validation_failed", "errors": errors},
145
+ ensure_ascii=False,
146
+ indent=2,
147
+ )
148
+ typer.echo(error_json, err=True)
149
+ raise typer.Exit(code=2)
150
+
151
+
152
+ def _empty_ok(ctx: typer.Context, msg: str = "No results.") -> None:
153
+ if is_tty():
154
+ get_console().print(f"[dim]{msg}[/dim]")
155
+ else:
156
+ typer.echo("[]")
157
+
158
+
159
+ @app.command(name="list")
160
+ def list_models(
161
+ ctx: typer.Context,
162
+ model_type: Annotated[
163
+ ModelType,
164
+ typer.Option(
165
+ "--type",
166
+ "-t",
167
+ help="Model type: entity(e), relation(r), external(x)",
168
+ case_sensitive=False,
169
+ ),
170
+ ] = ModelType.entity,
171
+ page: Annotated[int, typer.Option("--page", "-p", help="Page index (0-based)")] = 0,
172
+ page_size: Annotated[
173
+ int, typer.Option("--page-size", "-s", help="Items per page")
174
+ ] = 25,
175
+ page_all: Annotated[
176
+ bool,
177
+ typer.Option("--page-all", help="Fetch all pages automatically"),
178
+ ] = False,
179
+ system: Annotated[
180
+ bool,
181
+ typer.Option("--system", help="Include system models (entity only)"),
182
+ ] = False,
183
+ search: Annotated[
184
+ str | None,
185
+ typer.Option("--search", help="Fuzzy search keyword"),
186
+ ] = None,
187
+ search_fields: Annotated[
188
+ str | None,
189
+ typer.Option(
190
+ "--search-fields",
191
+ help="Comma-separated fields to search (default: className,displayName,zoneName,note)",
192
+ ),
193
+ ] = None,
194
+ fmt: Annotated[
195
+ OutputFormat,
196
+ typer.Option("--format", "-f", help="Output format: table/json"),
197
+ ] = OutputFormat.table,
198
+ ) -> None:
199
+ try:
200
+ client = get_client(ctx)
201
+ sf = [s.strip() for s in search_fields.split(",")] if search_fields else None
202
+ if page_all:
203
+ all_items: list[dict[str, Any]] = []
204
+ page_idx = 0
205
+ while True:
206
+ r = datamodel_api.list_models(
207
+ client,
208
+ model_type,
209
+ page=page_idx,
210
+ page_size=page_size,
211
+ with_page_info=True,
212
+ is_system=system,
213
+ search=search,
214
+ search_fields=sf,
215
+ )
216
+ p_items, p_info = _extract_response(r)
217
+ all_items.extend(p_items)
218
+ total = p_info.get("totalCount", 0) if p_info else 0
219
+ if not p_items or (total > 0 and len(all_items) >= total):
220
+ break
221
+ page_idx += 1
222
+ result = {
223
+ "data": {
224
+ "data": all_items,
225
+ "pageInfo": {"totalCount": len(all_items)},
226
+ }
227
+ }
228
+ else:
229
+ result = datamodel_api.list_models(
230
+ client,
231
+ model_type,
232
+ page=page,
233
+ page_size=page_size,
234
+ with_page_info=True,
235
+ is_system=system,
236
+ search=search,
237
+ search_fields=sf,
238
+ )
239
+ items, page_info = _extract_response(result)
240
+
241
+ if not items:
242
+ _empty_ok(ctx, "No data models found.")
243
+ return
244
+
245
+ fmt = resolve_format(fmt)
246
+
247
+ if fmt == OutputFormat.json:
248
+ typer.echo(json_mod.dumps(result, ensure_ascii=False, indent=2))
249
+ return
250
+
251
+ _print_table(items, page_info, get_console())
252
+ except DWFError as exc:
253
+ handle_error(exc)
254
+
255
+
256
+ @app.command()
257
+ def show(
258
+ ctx: typer.Context,
259
+ class_name: Annotated[str, typer.Argument(help="Class name (e.g. User, Quartz)")],
260
+ with_sys_attr: Annotated[
261
+ bool,
262
+ typer.Option("--with-sys-attr", help="Include system attributes"),
263
+ ] = False,
264
+ fmt: Annotated[
265
+ OutputFormat,
266
+ typer.Option("--format", "-f", help="Output format: table/json"),
267
+ ] = OutputFormat.table,
268
+ ) -> None:
269
+ try:
270
+ client = get_client(ctx)
271
+ result = datamodel_api.get_attributes(
272
+ client,
273
+ class_name,
274
+ need_sys_attr=with_sys_attr,
275
+ )
276
+
277
+ if isinstance(result, dict):
278
+ payload = result.get("data", result)
279
+ items = payload if isinstance(payload, list) else [payload]
280
+ elif isinstance(result, list):
281
+ items = result
282
+ else:
283
+ items = []
284
+
285
+ if not items:
286
+ _empty_ok(ctx, f"No attributes found for '{class_name}'.")
287
+ return
288
+
289
+ fmt = resolve_format(fmt)
290
+
291
+ if fmt == OutputFormat.json:
292
+ typer.echo(json_mod.dumps(result, ensure_ascii=False, indent=2))
293
+ return
294
+
295
+ _ATTR_COLUMNS: list[tuple[str, str]] = [
296
+ ("attributeName", "Name"),
297
+ ("displayName", "DisplayName"),
298
+ ("valueType", "DataType"),
299
+ ("valueLength", "Length"),
300
+ ("nullable", "Nullable"),
301
+ ("visible", "Visible"),
302
+ ("editable", "Editable"),
303
+ ]
304
+ table = Table(
305
+ title=f"Attributes of [bold]{class_name}[/bold]",
306
+ show_header=True,
307
+ header_style="bold cyan",
308
+ show_lines=False,
309
+ )
310
+ table.add_column("#", style="dim", width=4)
311
+ for _, header in _ATTR_COLUMNS:
312
+ table.add_column(header)
313
+
314
+ for idx, item in enumerate(items, 1):
315
+ row: list[str] = [str(idx)]
316
+ for key, _ in _ATTR_COLUMNS:
317
+ val = item.get(key, "")
318
+ if key in ("nullable", "visible", "editable"):
319
+ val = "[green]Yes[/green]" if val else "[dim]No[/dim]"
320
+ elif key == "valueLength":
321
+ val = str(val) if val else "-"
322
+ else:
323
+ val = str(val)
324
+ row.append(val)
325
+ table.add_row(*row)
326
+
327
+ get_console().print(table)
328
+ get_console().print(f"[dim]Total {len(items)} attributes[/dim]")
329
+ except DWFError as exc:
330
+ handle_error(exc)
331
+
332
+
333
+ @app.command()
334
+ def get(
335
+ ctx: typer.Context,
336
+ class_name: Annotated[str, typer.Argument(help="Class name")],
337
+ fmt: Annotated[
338
+ OutputFormat,
339
+ typer.Option("--format", "-f", help="Output format: table/json"),
340
+ ] = OutputFormat.table,
341
+ ) -> None:
342
+ try:
343
+ client = get_client(ctx)
344
+ result = datamodel_api.get_class(client, class_name)
345
+
346
+ fmt = resolve_format(fmt)
347
+
348
+ if fmt == OutputFormat.json:
349
+ typer.echo(json_mod.dumps(result, ensure_ascii=False, indent=2))
350
+ return
351
+
352
+ console = get_console()
353
+ data = result.get("data", result) if isinstance(result, dict) else result
354
+ if isinstance(data, dict):
355
+ for key, value in data.items():
356
+ console.print(f"[bold]{key}[/bold]: {value}")
357
+ except DWFError as exc:
358
+ handle_error(exc)
359
+
360
+
361
+ class ExternalMode(str, Enum):
362
+ sql = "sql"
363
+ view = "view"
364
+
365
+
366
+ @app.command()
367
+ def create(
368
+ ctx: typer.Context,
369
+ data: Annotated[str, typer.Option("--data", "-d", help="JSON data string")] = "",
370
+ file: Annotated[
371
+ Path | None, typer.Option("--file", help="Path to JSON data file")
372
+ ] = None,
373
+ model_type: Annotated[
374
+ ModelType,
375
+ typer.Option(
376
+ "--type",
377
+ "-t",
378
+ help="Create type: entity(e), relation(r), external(x)",
379
+ case_sensitive=False,
380
+ ),
381
+ ] = ModelType.entity,
382
+ with_parent_oid: Annotated[
383
+ bool,
384
+ typer.Option("--with-parent-oid", help="Append withParentOid query param"),
385
+ ] = False,
386
+ external_mode: Annotated[
387
+ ExternalMode,
388
+ typer.Option(
389
+ "--external-mode",
390
+ help="External create mode: view (default) or sql",
391
+ case_sensitive=False,
392
+ ),
393
+ ] = ExternalMode.view,
394
+ if_not_exists: Annotated[
395
+ bool,
396
+ typer.Option("--if-not-exists", help="Skip if class already exists"),
397
+ ] = False,
398
+ validate: Annotated[
399
+ bool,
400
+ typer.Option(
401
+ "--validate", help="Validate input against schema before executing"
402
+ ),
403
+ ] = False,
404
+ dry_run: Annotated[
405
+ bool,
406
+ typer.Option("--dry-run", help="Preview without executing"),
407
+ ] = False,
408
+ fmt: Annotated[
409
+ OutputFormat,
410
+ typer.Option("--format", "-f", help="Output format: table/json"),
411
+ ] = OutputFormat.table,
412
+ ) -> None:
413
+ try:
414
+ raw_payload = _read_payload(data, file)
415
+
416
+ resolved_type = model_type.value
417
+ if resolved_type in ("e",):
418
+ resolved_type = "entity"
419
+ elif resolved_type in ("r",):
420
+ resolved_type = "relation"
421
+ elif resolved_type in ("x",):
422
+ resolved_type = "external"
423
+
424
+ if resolved_type == "entity":
425
+ payload = raw_payload if isinstance(raw_payload, list) else [raw_payload]
426
+
427
+ if validate:
428
+ _do_validate("datamodel create", payload)
429
+
430
+ if dry_run:
431
+ typer.echo(
432
+ json_mod.dumps(
433
+ {
434
+ "action": "create",
435
+ "resource": "entity",
436
+ "data": payload,
437
+ "with_parent_oid": with_parent_oid,
438
+ "if_not_exists": if_not_exists,
439
+ "reversible": False,
440
+ },
441
+ ensure_ascii=False,
442
+ indent=2,
443
+ )
444
+ )
445
+ raise typer.Exit(code=DRY_RUN_EXIT_CODE)
446
+
447
+ client = get_client(ctx)
448
+ try:
449
+ result = datamodel_api.create_entity(
450
+ client, payload, with_parent_oid=with_parent_oid
451
+ )
452
+ if fmt == OutputFormat.json:
453
+ typer.echo(
454
+ json_mod.dumps(
455
+ {
456
+ "success": True,
457
+ "action": "create",
458
+ "resource": "entity",
459
+ "data": result,
460
+ },
461
+ ensure_ascii=False,
462
+ indent=2,
463
+ )
464
+ )
465
+ else:
466
+ typer.echo(f"Created: {result}", err=True)
467
+ except ConflictError:
468
+ if if_not_exists:
469
+ typer.echo(
470
+ "Entity already exists, skipped (--if-not-exists).", err=True
471
+ )
472
+ else:
473
+ raise
474
+
475
+ elif resolved_type == "relation":
476
+ payload = raw_payload if isinstance(raw_payload, list) else [raw_payload]
477
+
478
+ if validate:
479
+ _do_validate("datamodel relation create", payload)
480
+
481
+ if dry_run:
482
+ typer.echo(
483
+ json_mod.dumps(
484
+ {
485
+ "action": "create",
486
+ "resource": "relation",
487
+ "data": payload,
488
+ "if_not_exists": if_not_exists,
489
+ "reversible": False,
490
+ },
491
+ ensure_ascii=False,
492
+ indent=2,
493
+ )
494
+ )
495
+ raise typer.Exit(code=DRY_RUN_EXIT_CODE)
496
+
497
+ client = get_client(ctx)
498
+ try:
499
+ result = datamodel_api.create_relations(client, payload)
500
+ if fmt == OutputFormat.json:
501
+ typer.echo(
502
+ json_mod.dumps(
503
+ {
504
+ "success": True,
505
+ "action": "create",
506
+ "resource": "relation",
507
+ "data": result,
508
+ },
509
+ ensure_ascii=False,
510
+ indent=2,
511
+ )
512
+ )
513
+ else:
514
+ typer.echo(f"Created: {result}", err=True)
515
+ except ConflictError:
516
+ if if_not_exists:
517
+ typer.echo(
518
+ "Relation already exists, skipped (--if-not-exists).", err=True
519
+ )
520
+ else:
521
+ raise
522
+
523
+ elif resolved_type == "external":
524
+ if external_mode == ExternalMode.view:
525
+ class_list = (
526
+ raw_payload if isinstance(raw_payload, list) else [raw_payload]
527
+ )
528
+
529
+ if validate:
530
+ _do_validate("datamodel create", class_list)
531
+
532
+ if dry_run:
533
+ typer.echo(
534
+ json_mod.dumps(
535
+ {
536
+ "action": "create",
537
+ "resource": "external-class",
538
+ "external_mode": "view",
539
+ "data": class_list,
540
+ "with_parent_oid": with_parent_oid,
541
+ "if_not_exists": if_not_exists,
542
+ },
543
+ ensure_ascii=False,
544
+ indent=2,
545
+ )
546
+ )
547
+ raise typer.Exit(code=DRY_RUN_EXIT_CODE)
548
+
549
+ client = get_client(ctx)
550
+ try:
551
+ result = datamodel_api.create_entity(
552
+ client, class_list, with_parent_oid=with_parent_oid
553
+ )
554
+ if fmt == OutputFormat.json:
555
+ typer.echo(
556
+ json_mod.dumps(
557
+ {
558
+ "success": True,
559
+ "action": "create",
560
+ "resource": "external-class",
561
+ "external_mode": "view",
562
+ "data": result,
563
+ },
564
+ ensure_ascii=False,
565
+ indent=2,
566
+ )
567
+ )
568
+ else:
569
+ typer.echo(f"Created: {result}", err=True)
570
+ except ConflictError:
571
+ if if_not_exists:
572
+ typer.echo(
573
+ "External class already exists, skipped (--if-not-exists).",
574
+ err=True,
575
+ )
576
+ else:
577
+ raise
578
+ else:
579
+ if not isinstance(raw_payload, dict):
580
+ raise DWFError(
581
+ "External SQL mode requires a JSON object with 'classList' and optional 'customSqlMap'"
582
+ )
583
+
584
+ class_list = raw_payload.get("classList", [])
585
+ sql_map = raw_payload.get("customSqlMap")
586
+
587
+ if validate:
588
+ _do_validate("datamodel external create", raw_payload)
589
+
590
+ if dry_run:
591
+ typer.echo(
592
+ json_mod.dumps(
593
+ {
594
+ "action": "create",
595
+ "resource": "external-class",
596
+ "external_mode": "sql",
597
+ "classList": class_list,
598
+ "customSqlMap": sql_map,
599
+ "if_not_exists": if_not_exists,
600
+ },
601
+ ensure_ascii=False,
602
+ indent=2,
603
+ )
604
+ )
605
+ raise typer.Exit(code=DRY_RUN_EXIT_CODE)
606
+
607
+ client = get_client(ctx)
608
+ try:
609
+ result = datamodel_api.create_external_class(
610
+ client, class_list, custom_sql_map=sql_map
611
+ )
612
+ if fmt == OutputFormat.json:
613
+ typer.echo(
614
+ json_mod.dumps(
615
+ {
616
+ "success": True,
617
+ "action": "create",
618
+ "resource": "external-class",
619
+ "external_mode": "sql",
620
+ "data": result,
621
+ },
622
+ ensure_ascii=False,
623
+ indent=2,
624
+ )
625
+ )
626
+ else:
627
+ typer.echo(f"Created: {result}", err=True)
628
+ except ConflictError:
629
+ if if_not_exists:
630
+ typer.echo(
631
+ "External class already exists, skipped (--if-not-exists).",
632
+ err=True,
633
+ )
634
+ else:
635
+ raise
636
+ except DWFError as exc:
637
+ handle_error(exc)
638
+
639
+
640
+ @app.command()
641
+ def update(
642
+ ctx: typer.Context,
643
+ data: Annotated[str, typer.Option("--data", "-d", help="JSON data string")] = "",
644
+ file: Annotated[
645
+ Path | None, typer.Option("--file", help="Path to JSON data file")
646
+ ] = None,
647
+ model_type: Annotated[
648
+ ModelType,
649
+ typer.Option(
650
+ "--type",
651
+ "-t",
652
+ help="Update type: entity(e), relation(r), external(x)",
653
+ case_sensitive=False,
654
+ ),
655
+ ] = ModelType.entity,
656
+ external_mode: Annotated[
657
+ ExternalMode,
658
+ typer.Option(
659
+ "--external-mode",
660
+ help="External update mode: view (default) or sql",
661
+ case_sensitive=False,
662
+ ),
663
+ ] = ExternalMode.view,
664
+ validate: Annotated[
665
+ bool,
666
+ typer.Option(
667
+ "--validate", help="Validate input against schema before executing"
668
+ ),
669
+ ] = False,
670
+ dry_run: Annotated[
671
+ bool,
672
+ typer.Option("--dry-run", help="Preview without executing"),
673
+ ] = False,
674
+ fmt: Annotated[
675
+ OutputFormat,
676
+ typer.Option("--format", "-f", help="Output format: table/json"),
677
+ ] = OutputFormat.table,
678
+ ) -> None:
679
+ try:
680
+ raw_payload = _read_payload(data, file)
681
+
682
+ resolved_type = model_type.value
683
+ if resolved_type in ("e",):
684
+ resolved_type = "entity"
685
+ elif resolved_type in ("r",):
686
+ resolved_type = "relation"
687
+ elif resolved_type in ("x",):
688
+ resolved_type = "external"
689
+
690
+ if resolved_type == "external":
691
+ if external_mode == ExternalMode.view:
692
+ if validate:
693
+ _do_validate("datamodel update", raw_payload)
694
+
695
+ if dry_run:
696
+ typer.echo(
697
+ json_mod.dumps(
698
+ {
699
+ "action": "update",
700
+ "resource": "external-class",
701
+ "external_mode": "view",
702
+ "data": raw_payload,
703
+ },
704
+ ensure_ascii=False,
705
+ indent=2,
706
+ )
707
+ )
708
+ raise typer.Exit(code=DRY_RUN_EXIT_CODE)
709
+
710
+ client = get_client(ctx)
711
+ result = datamodel_api.update_entity(client, raw_payload)
712
+ if fmt == OutputFormat.json:
713
+ typer.echo(
714
+ json_mod.dumps(
715
+ {
716
+ "success": True,
717
+ "action": "update",
718
+ "resource": "external-class",
719
+ "external_mode": "view",
720
+ "data": result,
721
+ },
722
+ ensure_ascii=False,
723
+ indent=2,
724
+ )
725
+ )
726
+ else:
727
+ typer.echo("Updated.", err=True)
728
+ else:
729
+ if not isinstance(raw_payload, dict):
730
+ raise DWFError(
731
+ "External SQL mode requires a JSON object with 'classList' and optional 'customSqlMap'"
732
+ )
733
+
734
+ class_list = raw_payload.get("classList", [])
735
+ sql_map = raw_payload.get("customSqlMap")
736
+
737
+ if validate:
738
+ _do_validate("datamodel update --type external", raw_payload)
739
+
740
+ if dry_run:
741
+ typer.echo(
742
+ json_mod.dumps(
743
+ {
744
+ "action": "update",
745
+ "resource": "external-class",
746
+ "external_mode": "sql",
747
+ "classList": class_list,
748
+ "customSqlMap": sql_map,
749
+ },
750
+ ensure_ascii=False,
751
+ indent=2,
752
+ )
753
+ )
754
+ raise typer.Exit(code=DRY_RUN_EXIT_CODE)
755
+
756
+ client = get_client(ctx)
757
+ result = datamodel_api.update_external_class(
758
+ client, class_list, custom_sql_map=sql_map
759
+ )
760
+ if fmt == OutputFormat.json:
761
+ typer.echo(
762
+ json_mod.dumps(
763
+ {
764
+ "success": True,
765
+ "action": "update",
766
+ "resource": "external-class",
767
+ "external_mode": "sql",
768
+ "data": result,
769
+ },
770
+ ensure_ascii=False,
771
+ indent=2,
772
+ )
773
+ )
774
+ else:
775
+ typer.echo(f"Updated: {result}", err=True)
776
+ else:
777
+ schema_key = (
778
+ "datamodel update"
779
+ if resolved_type == "entity"
780
+ else "datamodel relation update"
781
+ )
782
+ resource = "class" if resolved_type == "entity" else "relation"
783
+
784
+ if validate:
785
+ _do_validate(schema_key, raw_payload)
786
+
787
+ if dry_run:
788
+ typer.echo(
789
+ json_mod.dumps(
790
+ {
791
+ "action": "update",
792
+ "resource": resource,
793
+ "data": raw_payload,
794
+ "reversible": True,
795
+ },
796
+ ensure_ascii=False,
797
+ indent=2,
798
+ )
799
+ )
800
+ raise typer.Exit(code=DRY_RUN_EXIT_CODE)
801
+
802
+ client = get_client(ctx)
803
+ if resolved_type == "relation":
804
+ result = datamodel_api.update_relation(client, raw_payload)
805
+ else:
806
+ result = datamodel_api.update_entity(client, raw_payload)
807
+ if fmt == OutputFormat.json:
808
+ typer.echo(
809
+ json_mod.dumps(
810
+ {
811
+ "success": True,
812
+ "action": "update",
813
+ "resource": resolved_type,
814
+ "data": result,
815
+ },
816
+ ensure_ascii=False,
817
+ indent=2,
818
+ )
819
+ )
820
+ else:
821
+ typer.echo("Updated.", err=True)
822
+ except DWFError as exc:
823
+ handle_error(exc)
824
+
825
+
826
+ @app.command()
827
+ def delete(
828
+ ctx: typer.Context,
829
+ class_name: Annotated[str, typer.Argument(help="Class name to delete")],
830
+ cascade: Annotated[
831
+ bool,
832
+ typer.Option("--cascade", help="Cascade delete relations, operations, views"),
833
+ ] = False,
834
+ dry_run: Annotated[
835
+ bool,
836
+ typer.Option("--dry-run", help="Preview without executing"),
837
+ ] = False,
838
+ yes: Annotated[
839
+ bool,
840
+ typer.Option("--force", help="Skip confirmation prompt"),
841
+ ] = False,
842
+ fmt: Annotated[
843
+ OutputFormat,
844
+ typer.Option("--format", "-f", help="Output format: table/json"),
845
+ ] = OutputFormat.table,
846
+ ) -> None:
847
+ try:
848
+ if dry_run:
849
+ typer.echo(
850
+ json_mod.dumps(
851
+ {
852
+ "action": "delete",
853
+ "resource": "class",
854
+ "target": class_name,
855
+ "cascade": cascade,
856
+ "reversible": False,
857
+ },
858
+ ensure_ascii=False,
859
+ indent=2,
860
+ )
861
+ )
862
+ raise typer.Exit(code=DRY_RUN_EXIT_CODE)
863
+
864
+ if is_tty() and not yes:
865
+ confirm = typer.confirm(f"Delete class '{class_name}'?", default=False)
866
+ if not confirm:
867
+ typer.echo("Cancelled.", err=True)
868
+ raise typer.Exit(code=0)
869
+
870
+ client = get_client(ctx)
871
+ result = datamodel_api.delete_class(client, class_name, cascade=cascade)
872
+ if fmt == OutputFormat.json:
873
+ typer.echo(
874
+ json_mod.dumps(
875
+ {
876
+ "success": True,
877
+ "action": "delete",
878
+ "resource": "class",
879
+ "target": class_name,
880
+ "data": result,
881
+ },
882
+ ensure_ascii=False,
883
+ indent=2,
884
+ )
885
+ )
886
+ else:
887
+ typer.echo(f"Deleted: {class_name}", err=True)
888
+ except DWFError as exc:
889
+ handle_error(exc)
890
+
891
+
892
+ @_attribute_sub.command(name="list")
893
+ def list_attributes(
894
+ ctx: typer.Context,
895
+ keyword: Annotated[
896
+ str | None,
897
+ typer.Option("--keyword", "-k", help="Search keyword"),
898
+ ] = None,
899
+ fmt: Annotated[
900
+ OutputFormat,
901
+ typer.Option("--format", "-f", help="Output format: table/json"),
902
+ ] = OutputFormat.table,
903
+ ) -> None:
904
+ try:
905
+ client = get_client(ctx)
906
+ result = datamodel_api.list_attributes(client, keyword=keyword)
907
+
908
+ if isinstance(result, dict):
909
+ payload = result.get("data", result)
910
+ items = payload if isinstance(payload, list) else [payload]
911
+ elif isinstance(result, list):
912
+ items = result
913
+ else:
914
+ items = []
915
+
916
+ if not items:
917
+ _empty_ok(ctx, "No attributes found.")
918
+ return
919
+
920
+ fmt = resolve_format(fmt)
921
+
922
+ if fmt == OutputFormat.json:
923
+ typer.echo(json_mod.dumps(result, ensure_ascii=False, indent=2))
924
+ return
925
+
926
+ _ATTR_COLUMNS: list[tuple[str, str]] = [
927
+ ("attributeName", "Name"),
928
+ ("displayName", "DisplayName"),
929
+ ("valueType", "DataType"),
930
+ ("valueLength", "Length"),
931
+ ("nullable", "Nullable"),
932
+ ]
933
+ table = Table(
934
+ title="All Attributes",
935
+ show_header=True,
936
+ header_style="bold cyan",
937
+ show_lines=False,
938
+ )
939
+ table.add_column("#", style="dim", width=4)
940
+ for _, header in _ATTR_COLUMNS:
941
+ table.add_column(header)
942
+ for idx, item in enumerate(items, 1):
943
+ row: list[str] = [str(idx)]
944
+ for key, _ in _ATTR_COLUMNS:
945
+ val = item.get(key, "")
946
+ if key == "nullable":
947
+ val = "[green]Yes[/green]" if val else "[dim]No[/dim]"
948
+ elif key == "valueLength":
949
+ val = str(val) if val else "-"
950
+ else:
951
+ val = str(val)
952
+ row.append(val)
953
+ table.add_row(*row)
954
+ get_console().print(table)
955
+ get_console().print(f"[dim]Total {len(items)} attributes[/dim]")
956
+ except DWFError as exc:
957
+ handle_error(exc)
958
+
959
+
960
+ # ── Object CRUD subgroup ──────────────────────────────────────────
961
+
962
+
963
+ @_object_sub.command(name="list")
964
+ def list_objects(
965
+ ctx: typer.Context,
966
+ class_name: Annotated[str, typer.Option("--class-name", "-c", help="Class name")],
967
+ page: Annotated[int, typer.Option("--page", "-p", help="Page index (0-based)")] = 0,
968
+ page_size: Annotated[
969
+ int, typer.Option("--page-size", "-s", help="Items per page")
970
+ ] = 25,
971
+ page_all: Annotated[
972
+ bool,
973
+ typer.Option("--page-all", help="Fetch all pages automatically"),
974
+ ] = False,
975
+ condition: Annotated[
976
+ str | None,
977
+ typer.Option("--condition", help="SQL WHERE clause (simple mode only)"),
978
+ ] = None,
979
+ data: Annotated[
980
+ str, typer.Option("--data", "-d", help="Full QueryObjReq JSON (advanced mode)")
981
+ ] = "",
982
+ file: Annotated[
983
+ Path | None,
984
+ typer.Option("--file", help="Path to QueryObjReq JSON file (advanced mode)"),
985
+ ] = None,
986
+ fmt: Annotated[
987
+ OutputFormat,
988
+ typer.Option("--format", "-f", help="Output format: table/json"),
989
+ ] = OutputFormat.table,
990
+ ) -> None:
991
+ try:
992
+ if (data or file) and condition:
993
+ raise DWFError("--condition is incompatible with --data/--file")
994
+
995
+ client = get_client(ctx)
996
+
997
+ if data or file:
998
+ body = _read_payload(data, file)
999
+ if not isinstance(body, dict):
1000
+ raise DWFError("--data/--file must contain a JSON object")
1001
+
1002
+ if page_all:
1003
+ all_items: list[dict[str, Any]] = []
1004
+ page_idx = 0
1005
+ while True:
1006
+ r = datamodel_api.query_objects(
1007
+ client,
1008
+ class_name,
1009
+ body=dict(body),
1010
+ page=page_idx,
1011
+ page_size=page_size,
1012
+ )
1013
+ p_items, p_info = _extract_response(r)
1014
+ all_items.extend(p_items)
1015
+ total = p_info.get("totalCount", 0) if p_info else 0
1016
+ if not p_items or (total > 0 and len(all_items) >= total):
1017
+ break
1018
+ page_idx += 1
1019
+ result = {
1020
+ "data": {
1021
+ "data": all_items,
1022
+ "pageInfo": {"totalCount": len(all_items)},
1023
+ }
1024
+ }
1025
+ else:
1026
+ result = datamodel_api.query_objects(
1027
+ client,
1028
+ class_name,
1029
+ body=dict(body),
1030
+ page=page,
1031
+ page_size=page_size,
1032
+ )
1033
+ else:
1034
+ if page_all:
1035
+ all_items = []
1036
+ page_idx = 0
1037
+ while True:
1038
+ r = datamodel_api.list_objects(
1039
+ client,
1040
+ class_name,
1041
+ page=page_idx,
1042
+ page_size=page_size,
1043
+ condition=condition,
1044
+ )
1045
+ p_items, p_info = _extract_response(r)
1046
+ all_items.extend(p_items)
1047
+ total = p_info.get("totalCount", 0) if p_info else 0
1048
+ if not p_items or (total > 0 and len(all_items) >= total):
1049
+ break
1050
+ page_idx += 1
1051
+ result = {
1052
+ "data": {
1053
+ "data": all_items,
1054
+ "pageInfo": {"totalCount": len(all_items)},
1055
+ }
1056
+ }
1057
+ else:
1058
+ result = datamodel_api.list_objects(
1059
+ client,
1060
+ class_name,
1061
+ page=page,
1062
+ page_size=page_size,
1063
+ condition=condition,
1064
+ )
1065
+
1066
+ items, page_info = _extract_response(result)
1067
+
1068
+ if not items:
1069
+ _empty_ok(ctx, f"No objects found for '{class_name}'.")
1070
+ return
1071
+
1072
+ fmt = resolve_format(fmt)
1073
+
1074
+ if fmt == OutputFormat.json:
1075
+ typer.echo(json_mod.dumps(result, ensure_ascii=False, indent=2))
1076
+ return
1077
+
1078
+ if items and not isinstance(items[0], dict):
1079
+ typer.echo(json_mod.dumps(result, ensure_ascii=False, indent=2))
1080
+ return
1081
+
1082
+ cols = list(items[0].keys()) if items else []
1083
+ col_tuples = [(c, c) for c in cols]
1084
+ _print_table(items, page_info, get_console(), columns=col_tuples)
1085
+ except DWFError as exc:
1086
+ handle_error(exc)
1087
+
1088
+
1089
+ @_object_sub.command(name="get")
1090
+ def get_object(
1091
+ ctx: typer.Context,
1092
+ oid: Annotated[str, typer.Argument(help="Object OID")],
1093
+ class_name: Annotated[str, typer.Option("--class-name", "-c", help="Class name")],
1094
+ fmt: Annotated[
1095
+ OutputFormat,
1096
+ typer.Option("--format", "-f", help="Output format: table/json"),
1097
+ ] = OutputFormat.table,
1098
+ ) -> None:
1099
+ try:
1100
+ client = get_client(ctx)
1101
+ result = datamodel_api.get_object(client, class_name, oid)
1102
+
1103
+ fmt = resolve_format(fmt)
1104
+
1105
+ if fmt == OutputFormat.json:
1106
+ typer.echo(json_mod.dumps(result, ensure_ascii=False, indent=2))
1107
+ return
1108
+
1109
+ console = get_console()
1110
+ data = result.get("data", result) if isinstance(result, dict) else result
1111
+ if isinstance(data, dict):
1112
+ for key, value in data.items():
1113
+ console.print(f"[bold]{key}[/bold]: {value}")
1114
+ except DWFError as exc:
1115
+ handle_error(exc)
1116
+
1117
+
1118
+ @_object_sub.command(name="create")
1119
+ def create_objects(
1120
+ ctx: typer.Context,
1121
+ class_name: Annotated[str, typer.Option("--class-name", "-c", help="Class name")],
1122
+ data: Annotated[str, typer.Option("--data", "-d", help="JSON data string")] = "",
1123
+ file: Annotated[
1124
+ Path | None, typer.Option("--file", help="Path to JSON data file")
1125
+ ] = None,
1126
+ if_not_exists: Annotated[
1127
+ bool,
1128
+ typer.Option("--if-not-exists", help="Skip if object already exists"),
1129
+ ] = False,
1130
+ validate: Annotated[
1131
+ bool,
1132
+ typer.Option(
1133
+ "--validate", help="Validate input against schema before executing"
1134
+ ),
1135
+ ] = False,
1136
+ dry_run: Annotated[
1137
+ bool,
1138
+ typer.Option("--dry-run", help="Preview without executing"),
1139
+ ] = False,
1140
+ fmt: Annotated[
1141
+ OutputFormat,
1142
+ typer.Option("--format", "-f", help="Output format: table/json"),
1143
+ ] = OutputFormat.table,
1144
+ ) -> None:
1145
+ try:
1146
+ payload = _read_payload(data, file)
1147
+ if isinstance(payload, dict):
1148
+ payload = [payload]
1149
+
1150
+ if validate:
1151
+ _do_validate("datamodel object create", payload)
1152
+
1153
+ if dry_run:
1154
+ typer.echo(
1155
+ json_mod.dumps(
1156
+ {
1157
+ "action": "create",
1158
+ "resource": class_name,
1159
+ "data": payload,
1160
+ "if_not_exists": if_not_exists,
1161
+ "reversible": False,
1162
+ },
1163
+ ensure_ascii=False,
1164
+ indent=2,
1165
+ )
1166
+ )
1167
+ raise typer.Exit(code=DRY_RUN_EXIT_CODE)
1168
+
1169
+ client = get_client(ctx)
1170
+ try:
1171
+ result = datamodel_api.create_objects(client, class_name, payload)
1172
+ if fmt == OutputFormat.json:
1173
+ typer.echo(
1174
+ json_mod.dumps(
1175
+ {
1176
+ "success": True,
1177
+ "action": "create",
1178
+ "resource": class_name,
1179
+ "count": len(payload),
1180
+ "data": result,
1181
+ },
1182
+ ensure_ascii=False,
1183
+ indent=2,
1184
+ )
1185
+ )
1186
+ else:
1187
+ typer.echo(f"Created {len(payload)} object(s).", err=True)
1188
+ except ConflictError:
1189
+ if if_not_exists:
1190
+ typer.echo(
1191
+ "Object already exists, skipped (--if-not-exists).", err=True
1192
+ )
1193
+ else:
1194
+ raise
1195
+ except DWFError as exc:
1196
+ handle_error(exc)
1197
+
1198
+
1199
+ @_object_sub.command(name="update")
1200
+ def update_objects(
1201
+ ctx: typer.Context,
1202
+ class_name: Annotated[str, typer.Option("--class-name", "-c", help="Class name")],
1203
+ data: Annotated[str, typer.Option("--data", "-d", help="JSON data string")] = "",
1204
+ file: Annotated[
1205
+ Path | None, typer.Option("--file", help="Path to JSON data file")
1206
+ ] = None,
1207
+ force_update: Annotated[
1208
+ bool,
1209
+ typer.Option("--force-update", help="Force update"),
1210
+ ] = False,
1211
+ validate: Annotated[
1212
+ bool,
1213
+ typer.Option(
1214
+ "--validate", help="Validate input against schema before executing"
1215
+ ),
1216
+ ] = False,
1217
+ dry_run: Annotated[
1218
+ bool,
1219
+ typer.Option("--dry-run", help="Preview without executing"),
1220
+ ] = False,
1221
+ fmt: Annotated[
1222
+ OutputFormat,
1223
+ typer.Option("--format", "-f", help="Output format: table/json"),
1224
+ ] = OutputFormat.table,
1225
+ ) -> None:
1226
+ try:
1227
+ payload = _read_payload(data, file)
1228
+ if isinstance(payload, dict):
1229
+ payload = [payload]
1230
+
1231
+ if validate:
1232
+ _do_validate("datamodel object update", payload)
1233
+
1234
+ if dry_run:
1235
+ typer.echo(
1236
+ json_mod.dumps(
1237
+ {
1238
+ "action": "update",
1239
+ "resource": class_name,
1240
+ "data": payload,
1241
+ "reversible": True,
1242
+ },
1243
+ ensure_ascii=False,
1244
+ indent=2,
1245
+ )
1246
+ )
1247
+ raise typer.Exit(code=DRY_RUN_EXIT_CODE)
1248
+
1249
+ client = get_client(ctx)
1250
+ result = datamodel_api.update_objects(
1251
+ client, class_name, payload, force_update=force_update
1252
+ )
1253
+ if fmt == OutputFormat.json:
1254
+ typer.echo(
1255
+ json_mod.dumps(
1256
+ {
1257
+ "success": True,
1258
+ "action": "update",
1259
+ "resource": class_name,
1260
+ "count": len(payload),
1261
+ "data": result,
1262
+ },
1263
+ ensure_ascii=False,
1264
+ indent=2,
1265
+ )
1266
+ )
1267
+ else:
1268
+ typer.echo(f"Updated {len(payload)} object(s).", err=True)
1269
+ except DWFError as exc:
1270
+ handle_error(exc)
1271
+
1272
+
1273
+ @_object_sub.command(name="delete")
1274
+ def delete_objects(
1275
+ ctx: typer.Context,
1276
+ class_name: Annotated[str, typer.Option("--class-name", "-c", help="Class name")],
1277
+ oids: Annotated[
1278
+ str | None,
1279
+ typer.Option("--oids", help="Comma-separated OID list"),
1280
+ ] = None,
1281
+ file: Annotated[
1282
+ Path | None,
1283
+ typer.Option("--file", help="Path to JSON file containing OID array"),
1284
+ ] = None,
1285
+ dry_run: Annotated[
1286
+ bool,
1287
+ typer.Option("--dry-run", help="Preview without executing"),
1288
+ ] = False,
1289
+ yes: Annotated[
1290
+ bool,
1291
+ typer.Option("--force", help="Skip confirmation prompt"),
1292
+ ] = False,
1293
+ fmt: Annotated[
1294
+ OutputFormat,
1295
+ typer.Option("--format", "-f", help="Output format: table/json"),
1296
+ ] = OutputFormat.table,
1297
+ ) -> None:
1298
+ try:
1299
+ oid_list: list[str] = []
1300
+ if file:
1301
+ file = validate_path(file)
1302
+ oid_list = json_mod.loads(file.read_text(encoding="utf-8"))
1303
+ elif oids:
1304
+ oid_list = [o.strip() for o in oids.split(",") if o.strip()]
1305
+ else:
1306
+ typer.echo("Error: provide --oids or --file", err=True)
1307
+ raise typer.Exit(code=1)
1308
+
1309
+ if dry_run:
1310
+ typer.echo(
1311
+ json_mod.dumps(
1312
+ {
1313
+ "action": "delete",
1314
+ "resource": class_name,
1315
+ "targets": oid_list,
1316
+ "reversible": False,
1317
+ },
1318
+ ensure_ascii=False,
1319
+ indent=2,
1320
+ )
1321
+ )
1322
+ raise typer.Exit(code=DRY_RUN_EXIT_CODE)
1323
+
1324
+ if is_tty() and not yes:
1325
+ confirm = typer.confirm(
1326
+ f"Delete {len(oid_list)} object(s) from '{class_name}'?",
1327
+ default=False,
1328
+ )
1329
+ if not confirm:
1330
+ typer.echo("Cancelled.", err=True)
1331
+ raise typer.Exit(code=0)
1332
+
1333
+ client = get_client(ctx)
1334
+ result = datamodel_api.delete_objects(client, class_name, oid_list)
1335
+ if fmt == OutputFormat.json:
1336
+ typer.echo(
1337
+ json_mod.dumps(
1338
+ {
1339
+ "success": True,
1340
+ "action": "delete",
1341
+ "resource": class_name,
1342
+ "count": len(oid_list),
1343
+ "data": result,
1344
+ },
1345
+ ensure_ascii=False,
1346
+ indent=2,
1347
+ )
1348
+ )
1349
+ else:
1350
+ typer.echo(f"Deleted {len(oid_list)} object(s).", err=True)
1351
+ except DWFError as exc:
1352
+ handle_error(exc)
1353
+
1354
+
1355
+ @_object_sub.command(name="count")
1356
+ def count_objects(
1357
+ ctx: typer.Context,
1358
+ class_name: Annotated[str, typer.Option("--class-name", "-c", help="Class name")],
1359
+ condition: Annotated[
1360
+ str | None,
1361
+ typer.Option("--condition", help="SQL WHERE clause, e.g. \"and name='test'\""),
1362
+ ] = None,
1363
+ fmt: Annotated[
1364
+ OutputFormat,
1365
+ typer.Option("--format", "-f", help="Output format: table/json"),
1366
+ ] = OutputFormat.table,
1367
+ ) -> None:
1368
+ try:
1369
+ client = get_client(ctx)
1370
+ result = datamodel_api.count_objects(client, class_name, condition=condition)
1371
+
1372
+ fmt = resolve_format(fmt)
1373
+
1374
+ if fmt == OutputFormat.json:
1375
+ typer.echo(json_mod.dumps(result, ensure_ascii=False, indent=2))
1376
+ return
1377
+
1378
+ data = result.get("data", result) if isinstance(result, dict) else result
1379
+ get_console().print(f"Count for '{class_name}': {data}")
1380
+ except DWFError as exc:
1381
+ handle_error(exc)
1382
+
1383
+
1384
+ # ── Import / Export ──────────────────────────────────────────────
1385
+
1386
+
1387
+ @app.command(name="import")
1388
+ def import_data_cmd(
1389
+ ctx: typer.Context,
1390
+ file: Annotated[Path, typer.Argument(help="Path to data file to import")],
1391
+ update_if_exist: Annotated[
1392
+ bool,
1393
+ typer.Option("--update-if-exist", help="Update existing records on conflict"),
1394
+ ] = False,
1395
+ dry_run: Annotated[
1396
+ bool,
1397
+ typer.Option("--dry-run", help="Preview without executing"),
1398
+ ] = False,
1399
+ ) -> None:
1400
+ try:
1401
+ file = validate_path(file)
1402
+ if not file.exists():
1403
+ raise DWFError(f"File not found: {file}")
1404
+
1405
+ if dry_run:
1406
+ typer.echo(
1407
+ json_mod.dumps(
1408
+ {
1409
+ "action": "import",
1410
+ "file": str(file),
1411
+ "update_if_exist": update_if_exist,
1412
+ },
1413
+ ensure_ascii=False,
1414
+ indent=2,
1415
+ )
1416
+ )
1417
+ raise typer.Exit(code=DRY_RUN_EXIT_CODE)
1418
+
1419
+ client = get_client(ctx)
1420
+ uuid_result = datamodel_api.get_import_uuid(
1421
+ client, update_if_exist=update_if_exist
1422
+ )
1423
+ uuid_val = (
1424
+ uuid_result.get("data", uuid_result)
1425
+ if isinstance(uuid_result, dict)
1426
+ else uuid_result
1427
+ )
1428
+
1429
+ result = datamodel_api.import_data(client, str(uuid_val), "", str(file))
1430
+ typer.echo(f"Import started: {result}", err=True)
1431
+ except DWFError as exc:
1432
+ handle_error(exc)
1433
+
1434
+
1435
+ @app.command(name="export")
1436
+ def export_data_cmd(
1437
+ ctx: typer.Context,
1438
+ class_name: Annotated[str, typer.Option("--class-name", "-c", help="Class name")],
1439
+ attributes: Annotated[
1440
+ str,
1441
+ typer.Option("--attributes", "-a", help="Comma-separated attribute names"),
1442
+ ],
1443
+ output: Annotated[
1444
+ Path | None,
1445
+ typer.Option("--output", "-o", help="Output file path (default: stdout)"),
1446
+ ] = None,
1447
+ condition: Annotated[
1448
+ str | None,
1449
+ typer.Option("--condition", help="SQL WHERE condition"),
1450
+ ] = None,
1451
+ join_to_one: Annotated[
1452
+ bool,
1453
+ typer.Option("--join-to-one", help="Merge into one sheet (relation classes)"),
1454
+ ] = False,
1455
+ title_only: Annotated[
1456
+ bool,
1457
+ typer.Option("--title-only", help="Export headers only"),
1458
+ ] = False,
1459
+ dry_run: Annotated[
1460
+ bool,
1461
+ typer.Option("--dry-run", help="Preview without executing"),
1462
+ ] = False,
1463
+ ) -> None:
1464
+ try:
1465
+ attr_list = [a.strip() for a in attributes.split(",") if a.strip()]
1466
+ if not attr_list:
1467
+ raise DWFError("--attributes cannot be empty")
1468
+
1469
+ if dry_run:
1470
+ typer.echo(
1471
+ json_mod.dumps(
1472
+ {
1473
+ "action": "export",
1474
+ "resource": class_name,
1475
+ "attributes": attr_list,
1476
+ "condition": condition,
1477
+ "join_to_one": join_to_one,
1478
+ "title_only": title_only,
1479
+ },
1480
+ ensure_ascii=False,
1481
+ indent=2,
1482
+ )
1483
+ )
1484
+ raise typer.Exit(code=DRY_RUN_EXIT_CODE)
1485
+
1486
+ client = get_client(ctx)
1487
+ response = datamodel_api.export_data(
1488
+ client,
1489
+ class_name,
1490
+ attr_list,
1491
+ condition=condition,
1492
+ join_to_one=join_to_one,
1493
+ title_only=title_only,
1494
+ )
1495
+
1496
+ if output:
1497
+ output = validate_path(output)
1498
+ output.write_bytes(response.content)
1499
+ typer.echo(f"Exported to {output}", err=True)
1500
+ else:
1501
+ typer.echo(response.content, nl=False)
1502
+ except DWFError as exc:
1503
+ handle_error(exc)
1504
+
1505
+
1506
+ # ── Excel Quick Start ────────────────────────────────────────────
1507
+
1508
+
1509
+ @app.command(name="excel-upload")
1510
+ def excel_upload_cmd(
1511
+ ctx: typer.Context,
1512
+ file: Annotated[Path, typer.Argument(help="Path to Excel file")],
1513
+ dry_run: Annotated[
1514
+ bool,
1515
+ typer.Option("--dry-run", help="Preview without executing"),
1516
+ ] = False,
1517
+ ) -> None:
1518
+ try:
1519
+ file = validate_path(file)
1520
+ if not file.exists():
1521
+ raise DWFError(f"File not found: {file}")
1522
+
1523
+ if dry_run:
1524
+ typer.echo(
1525
+ json_mod.dumps(
1526
+ {"action": "excel-upload", "file": str(file)},
1527
+ ensure_ascii=False,
1528
+ indent=2,
1529
+ )
1530
+ )
1531
+ raise typer.Exit(code=DRY_RUN_EXIT_CODE)
1532
+
1533
+ client = get_client(ctx)
1534
+ result = datamodel_api.upload_excel(client, str(file))
1535
+ typer.echo(json_mod.dumps(result, ensure_ascii=False, indent=2))
1536
+ except DWFError as exc:
1537
+ handle_error(exc)
1538
+
1539
+
1540
+ @app.command(name="excel-confirm")
1541
+ def excel_confirm_cmd(
1542
+ ctx: typer.Context,
1543
+ data: Annotated[
1544
+ str, typer.Option("--data", "-d", help="JSON from excel-upload output")
1545
+ ] = "",
1546
+ file: Annotated[
1547
+ Path | None,
1548
+ typer.Option("--file", help="Path to JSON file from excel-upload output"),
1549
+ ] = None,
1550
+ skip_validate: Annotated[
1551
+ bool,
1552
+ typer.Option("--skip-validate", help="Skip class/attribute validation"),
1553
+ ] = False,
1554
+ skip_cleanup: Annotated[
1555
+ bool,
1556
+ typer.Option("--skip-cleanup", help="Skip temp file deletion after confirm"),
1557
+ ] = False,
1558
+ dry_run: Annotated[
1559
+ bool,
1560
+ typer.Option("--dry-run", help="Validate only, do not submit"),
1561
+ ] = False,
1562
+ ) -> None:
1563
+ try:
1564
+ payload = _read_payload(data, file)
1565
+
1566
+ upload_data = (
1567
+ payload.get("data", payload) if isinstance(payload, dict) else payload
1568
+ )
1569
+ if not isinstance(upload_data, dict):
1570
+ raise DWFError(
1571
+ "Invalid payload: expected object with uuid and allSheetInfo"
1572
+ )
1573
+
1574
+ uuid_val = upload_data.get("uuid")
1575
+ if not uuid_val:
1576
+ raise DWFError("Invalid payload: missing uuid")
1577
+
1578
+ all_sheet_info = upload_data.get("allSheetInfo", [])
1579
+ if not all_sheet_info:
1580
+ raise DWFError("Invalid payload: allSheetInfo is empty")
1581
+
1582
+ sheet = all_sheet_info[0]
1583
+ if not sheet.get("useThisSheet", True):
1584
+ raise DWFError(
1585
+ f"First sheet '{sheet.get('sheetName', '?')}' is marked as unused"
1586
+ )
1587
+
1588
+ class_name = sheet.get("className", "")
1589
+ if not class_name:
1590
+ raise DWFError("Sheet missing className")
1591
+
1592
+ client = get_client(ctx)
1593
+ errors: list[str] = []
1594
+
1595
+ if not skip_validate:
1596
+ typer.echo(f"Validating class '{class_name}'...", err=True)
1597
+ valid = datamodel_api.check_class_name_valid(client, class_name)
1598
+ if not valid:
1599
+ errors.append(
1600
+ f"Class name '{class_name}' is not valid or already exists"
1601
+ )
1602
+
1603
+ col_list = sheet.get("infoFromColumnList", [])
1604
+ for col in col_list:
1605
+ if not col.get("useThisColumn", True):
1606
+ continue
1607
+ attr_name = col.get("firstRowData", "")
1608
+ data_type = col.get("dataType", "")
1609
+ if not attr_name or not data_type:
1610
+ continue
1611
+ typer.echo(
1612
+ f"Validating attribute '{attr_name}:{data_type}'...", err=True
1613
+ )
1614
+ attr_valid = datamodel_api.check_attribute_type_valid(
1615
+ client, attr_name, data_type
1616
+ )
1617
+ if not attr_valid:
1618
+ errors.append(
1619
+ f"Attribute '{attr_name}' with type '{data_type}' is not valid"
1620
+ )
1621
+
1622
+ if errors:
1623
+ raise DWFError(
1624
+ "Validation failed",
1625
+ detail="; ".join(errors),
1626
+ )
1627
+
1628
+ enriched_columns: list[dict[str, Any]] = []
1629
+ for col in sheet.get("infoFromColumnList", []):
1630
+ if not col.get("useThisColumn", True):
1631
+ continue
1632
+ enriched = dict(col)
1633
+ enriched.setdefault("attributeName", col.get("firstRowData", ""))
1634
+ enriched.setdefault("displayName", col.get("secondRowData", ""))
1635
+ enriched_columns.append(enriched)
1636
+
1637
+ confirm_payload = {
1638
+ "uuid": uuid_val,
1639
+ "useFirstRowAsClassName": upload_data.get("useFirstRowAsClassName", True),
1640
+ "useSecondRowAsDisplayName": upload_data.get(
1641
+ "useSecondRowAsDisplayName", True
1642
+ ),
1643
+ "allSheetInfo": [
1644
+ {
1645
+ "sheetName": sheet.get("sheetName", ""),
1646
+ "className": class_name,
1647
+ "displayName": sheet.get("displayName", class_name),
1648
+ "zoneName": sheet.get("zoneName", "CUS"),
1649
+ "isTree": sheet.get("isTree", False),
1650
+ "useThisSheet": True,
1651
+ "infoFromColumnList": enriched_columns,
1652
+ }
1653
+ ],
1654
+ }
1655
+
1656
+ if dry_run:
1657
+ typer.echo(
1658
+ json_mod.dumps(
1659
+ {
1660
+ "action": "excel-confirm",
1661
+ "dry_run": True,
1662
+ "validated": not skip_validate,
1663
+ "payload": confirm_payload,
1664
+ },
1665
+ ensure_ascii=False,
1666
+ indent=2,
1667
+ )
1668
+ )
1669
+ raise typer.Exit(code=DRY_RUN_EXIT_CODE)
1670
+
1671
+ typer.echo(f"Confirming class '{class_name}'...", err=True)
1672
+ result = datamodel_api.confirm_excel(client, confirm_payload)
1673
+
1674
+ if not skip_cleanup:
1675
+ typer.echo(f"Cleaning up temp file (uuid={uuid_val})...", err=True)
1676
+ try:
1677
+ datamodel_api.delete_temp_excel(client, uuid_val)
1678
+ except DWFError as cleanup_exc:
1679
+ typer.echo(f"Warning: cleanup failed: {cleanup_exc}", err=True)
1680
+
1681
+ typer.echo(
1682
+ json_mod.dumps(
1683
+ {
1684
+ "confirmed": True,
1685
+ "className": class_name,
1686
+ "uuid": uuid_val,
1687
+ "cleanup": not skip_cleanup,
1688
+ "result": result,
1689
+ },
1690
+ ensure_ascii=False,
1691
+ indent=2,
1692
+ )
1693
+ )
1694
+ except DWFError as exc:
1695
+ handle_error(exc)
1696
+
1697
+
1698
+ @app.command(name="excel-quickstart")
1699
+ def excel_quickstart_cmd(
1700
+ ctx: typer.Context,
1701
+ file: Annotated[Path, typer.Argument(help="Path to Excel file")],
1702
+ skip_validate: Annotated[
1703
+ bool,
1704
+ typer.Option("--skip-validate", help="Skip class/attribute validation"),
1705
+ ] = False,
1706
+ skip_cleanup: Annotated[
1707
+ bool,
1708
+ typer.Option("--skip-cleanup", help="Skip temp file deletion after confirm"),
1709
+ ] = False,
1710
+ dry_run: Annotated[
1711
+ bool,
1712
+ typer.Option("--dry-run", help="Upload and validate only, do not confirm"),
1713
+ ] = False,
1714
+ ) -> None:
1715
+ try:
1716
+ file = validate_path(file)
1717
+ if not file.exists():
1718
+ raise DWFError(f"File not found: {file}")
1719
+
1720
+ client = get_client(ctx)
1721
+
1722
+ typer.echo(f"Uploading {file.name}...", err=True)
1723
+ upload_result = datamodel_api.upload_excel(client, str(file))
1724
+
1725
+ upload_data = (
1726
+ upload_result.get("data", upload_result)
1727
+ if isinstance(upload_result, dict)
1728
+ else upload_result
1729
+ )
1730
+ if not isinstance(upload_data, dict) or not upload_data.get("uuid"):
1731
+ raise DWFError("Upload succeeded but response missing uuid")
1732
+
1733
+ uuid_val = upload_data["uuid"]
1734
+ all_sheet_info = upload_data.get("allSheetInfo", [])
1735
+ if not all_sheet_info:
1736
+ raise DWFError("Upload succeeded but allSheetInfo is empty")
1737
+
1738
+ sheet = all_sheet_info[0]
1739
+ class_name = sheet.get("className", "")
1740
+ if not class_name:
1741
+ raise DWFError("First sheet missing className")
1742
+
1743
+ errors: list[str] = []
1744
+
1745
+ if not skip_validate:
1746
+ typer.echo(f"Validating class '{class_name}'...", err=True)
1747
+ valid = datamodel_api.check_class_name_valid(client, class_name)
1748
+ if not valid:
1749
+ errors.append(
1750
+ f"Class name '{class_name}' is not valid or already exists"
1751
+ )
1752
+
1753
+ for col in sheet.get("infoFromColumnList", []):
1754
+ if not col.get("useThisColumn", True):
1755
+ continue
1756
+ attr_name = col.get("firstRowData", "")
1757
+ data_type = col.get("dataType", "")
1758
+ if not attr_name or not data_type:
1759
+ continue
1760
+ typer.echo(
1761
+ f"Validating attribute '{attr_name}:{data_type}'...", err=True
1762
+ )
1763
+ attr_valid = datamodel_api.check_attribute_type_valid(
1764
+ client, attr_name, data_type
1765
+ )
1766
+ if not attr_valid:
1767
+ errors.append(
1768
+ f"Attribute '{attr_name}' with type '{data_type}' is not valid"
1769
+ )
1770
+
1771
+ if errors:
1772
+ if not skip_cleanup:
1773
+ try:
1774
+ datamodel_api.delete_temp_excel(client, uuid_val)
1775
+ except DWFError:
1776
+ pass
1777
+ raise DWFError(
1778
+ "Validation failed",
1779
+ detail="; ".join(errors),
1780
+ )
1781
+
1782
+ enriched_columns: list[dict[str, Any]] = []
1783
+ for col in sheet.get("infoFromColumnList", []):
1784
+ if not col.get("useThisColumn", True):
1785
+ continue
1786
+ enriched = dict(col)
1787
+ enriched.setdefault("attributeName", col.get("firstRowData", ""))
1788
+ enriched.setdefault("displayName", col.get("secondRowData", ""))
1789
+ enriched_columns.append(enriched)
1790
+
1791
+ confirm_payload = {
1792
+ "uuid": uuid_val,
1793
+ "useFirstRowAsClassName": upload_data.get("useFirstRowAsClassName", True),
1794
+ "useSecondRowAsDisplayName": upload_data.get(
1795
+ "useSecondRowAsDisplayName", True
1796
+ ),
1797
+ "allSheetInfo": [
1798
+ {
1799
+ "sheetName": sheet.get("sheetName", ""),
1800
+ "className": class_name,
1801
+ "displayName": sheet.get("displayName", class_name),
1802
+ "zoneName": sheet.get("zoneName", "CUS"),
1803
+ "isTree": sheet.get("isTree", False),
1804
+ "useThisSheet": True,
1805
+ "infoFromColumnList": enriched_columns,
1806
+ }
1807
+ ],
1808
+ }
1809
+
1810
+ if dry_run:
1811
+ typer.echo(
1812
+ json_mod.dumps(
1813
+ {
1814
+ "action": "excel-quickstart",
1815
+ "dry_run": True,
1816
+ "uploaded": True,
1817
+ "uuid": uuid_val,
1818
+ "validated": not skip_validate,
1819
+ "payload": confirm_payload,
1820
+ },
1821
+ ensure_ascii=False,
1822
+ indent=2,
1823
+ )
1824
+ )
1825
+ raise typer.Exit(code=DRY_RUN_EXIT_CODE)
1826
+
1827
+ typer.echo(f"Confirming class '{class_name}'...", err=True)
1828
+ result = datamodel_api.confirm_excel(client, confirm_payload)
1829
+
1830
+ if not skip_cleanup:
1831
+ typer.echo(f"Cleaning up temp file (uuid={uuid_val})...", err=True)
1832
+ try:
1833
+ datamodel_api.delete_temp_excel(client, uuid_val)
1834
+ except DWFError as cleanup_exc:
1835
+ typer.echo(f"Warning: cleanup failed: {cleanup_exc}", err=True)
1836
+
1837
+ typer.echo(
1838
+ json_mod.dumps(
1839
+ {
1840
+ "confirmed": True,
1841
+ "className": class_name,
1842
+ "uuid": uuid_val,
1843
+ "cleanup": not skip_cleanup,
1844
+ "result": result,
1845
+ },
1846
+ ensure_ascii=False,
1847
+ indent=2,
1848
+ )
1849
+ )
1850
+ except DWFError as exc:
1851
+ handle_error(exc)
1852
+
1853
+
1854
+ # ── Attribute CRUD subgroup ──────────────────────────────────────
1855
+
1856
+
1857
+ @_attribute_sub.command(name="create")
1858
+ def create_attributes(
1859
+ ctx: typer.Context,
1860
+ data: Annotated[str, typer.Option("--data", "-d", help="JSON data string")] = "",
1861
+ file: Annotated[
1862
+ Path | None, typer.Option("--file", help="Path to JSON data file")
1863
+ ] = None,
1864
+ if_not_exists: Annotated[
1865
+ bool,
1866
+ typer.Option("--if-not-exists", help="Skip if attribute already exists"),
1867
+ ] = False,
1868
+ validate: Annotated[
1869
+ bool,
1870
+ typer.Option(
1871
+ "--validate", help="Validate input against schema before executing"
1872
+ ),
1873
+ ] = False,
1874
+ dry_run: Annotated[
1875
+ bool,
1876
+ typer.Option("--dry-run", help="Preview without executing"),
1877
+ ] = False,
1878
+ fmt: Annotated[
1879
+ OutputFormat,
1880
+ typer.Option("--format", "-f", help="Output format: table/json"),
1881
+ ] = OutputFormat.table,
1882
+ ) -> None:
1883
+ try:
1884
+ payload = _read_payload(data, file)
1885
+ if isinstance(payload, dict):
1886
+ payload = [payload]
1887
+
1888
+ if validate:
1889
+ _do_validate("datamodel attribute create", payload)
1890
+
1891
+ if dry_run:
1892
+ typer.echo(
1893
+ json_mod.dumps(
1894
+ {
1895
+ "action": "create",
1896
+ "resource": "attribute",
1897
+ "data": payload,
1898
+ "if_not_exists": if_not_exists,
1899
+ },
1900
+ ensure_ascii=False,
1901
+ indent=2,
1902
+ )
1903
+ )
1904
+ raise typer.Exit(code=DRY_RUN_EXIT_CODE)
1905
+
1906
+ client = get_client(ctx)
1907
+ try:
1908
+ result = datamodel_api.create_attributes(client, payload)
1909
+ if fmt == OutputFormat.json:
1910
+ typer.echo(
1911
+ json_mod.dumps(
1912
+ {
1913
+ "success": True,
1914
+ "action": "create",
1915
+ "resource": "attribute",
1916
+ "data": result,
1917
+ },
1918
+ ensure_ascii=False,
1919
+ indent=2,
1920
+ )
1921
+ )
1922
+ else:
1923
+ typer.echo(f"Created: {result}", err=True)
1924
+ except ConflictError:
1925
+ if if_not_exists:
1926
+ typer.echo(
1927
+ "Attribute already exists, skipped (--if-not-exists).", err=True
1928
+ )
1929
+ else:
1930
+ raise
1931
+ except DWFError as exc:
1932
+ handle_error(exc)
1933
+
1934
+
1935
+ @_attribute_sub.command(name="update")
1936
+ def update_attribute(
1937
+ ctx: typer.Context,
1938
+ data: Annotated[str, typer.Option("--data", "-d", help="JSON data string")] = "",
1939
+ file: Annotated[
1940
+ Path | None, typer.Option("--file", help="Path to JSON data file")
1941
+ ] = None,
1942
+ validate: Annotated[
1943
+ bool,
1944
+ typer.Option(
1945
+ "--validate", help="Validate input against schema before executing"
1946
+ ),
1947
+ ] = False,
1948
+ dry_run: Annotated[
1949
+ bool,
1950
+ typer.Option("--dry-run", help="Preview without executing"),
1951
+ ] = False,
1952
+ fmt: Annotated[
1953
+ OutputFormat,
1954
+ typer.Option("--format", "-f", help="Output format: table/json"),
1955
+ ] = OutputFormat.table,
1956
+ ) -> None:
1957
+ try:
1958
+ payload = _read_payload(data, file)
1959
+
1960
+ if validate:
1961
+ _do_validate("datamodel attribute update", payload)
1962
+
1963
+ if dry_run:
1964
+ typer.echo(
1965
+ json_mod.dumps(
1966
+ {"action": "update", "resource": "attribute", "data": payload},
1967
+ ensure_ascii=False,
1968
+ indent=2,
1969
+ )
1970
+ )
1971
+ raise typer.Exit(code=DRY_RUN_EXIT_CODE)
1972
+
1973
+ client = get_client(ctx)
1974
+ result = datamodel_api.update_attribute(client, payload)
1975
+ if fmt == OutputFormat.json:
1976
+ typer.echo(
1977
+ json_mod.dumps(
1978
+ {
1979
+ "success": True,
1980
+ "action": "update",
1981
+ "resource": "attribute",
1982
+ "data": result,
1983
+ },
1984
+ ensure_ascii=False,
1985
+ indent=2,
1986
+ )
1987
+ )
1988
+ else:
1989
+ typer.echo(f"Updated: {result}", err=True)
1990
+ except DWFError as exc:
1991
+ handle_error(exc)
1992
+
1993
+
1994
+ @_attribute_sub.command(name="delete")
1995
+ def delete_attribute(
1996
+ ctx: typer.Context,
1997
+ name: Annotated[str, typer.Argument(help="Attribute name")],
1998
+ force_delete: Annotated[
1999
+ bool,
2000
+ typer.Option("--hard-delete", help="Force delete even if bound to classes"),
2001
+ ] = False,
2002
+ dry_run: Annotated[
2003
+ bool,
2004
+ typer.Option("--dry-run", help="Preview without executing"),
2005
+ ] = False,
2006
+ yes: Annotated[
2007
+ bool,
2008
+ typer.Option("--force", help="Skip confirmation prompt"),
2009
+ ] = False,
2010
+ fmt: Annotated[
2011
+ OutputFormat,
2012
+ typer.Option("--format", "-f", help="Output format: table/json"),
2013
+ ] = OutputFormat.table,
2014
+ ) -> None:
2015
+ try:
2016
+ if dry_run:
2017
+ typer.echo(
2018
+ json_mod.dumps(
2019
+ {
2020
+ "action": "delete",
2021
+ "resource": "attribute",
2022
+ "target": name,
2023
+ "force": force_delete,
2024
+ },
2025
+ ensure_ascii=False,
2026
+ indent=2,
2027
+ )
2028
+ )
2029
+ raise typer.Exit(code=DRY_RUN_EXIT_CODE)
2030
+
2031
+ if is_tty() and not yes:
2032
+ confirm = typer.confirm(f"Delete attribute '{name}'?", default=False)
2033
+ if not confirm:
2034
+ typer.echo("Cancelled.", err=True)
2035
+ raise typer.Exit(code=0)
2036
+
2037
+ client = get_client(ctx)
2038
+ result = datamodel_api.delete_attribute(client, name, force=force_delete)
2039
+ if fmt == OutputFormat.json:
2040
+ typer.echo(
2041
+ json_mod.dumps(
2042
+ {
2043
+ "success": True,
2044
+ "action": "delete",
2045
+ "resource": "attribute",
2046
+ "target": name,
2047
+ "data": result,
2048
+ },
2049
+ ensure_ascii=False,
2050
+ indent=2,
2051
+ )
2052
+ )
2053
+ else:
2054
+ typer.echo(f"Deleted: {result}", err=True)
2055
+ except DWFError as exc:
2056
+ handle_error(exc)
2057
+
2058
+
2059
+ @_attribute_sub.command(name="get")
2060
+ def get_attribute(
2061
+ ctx: typer.Context,
2062
+ name: Annotated[str, typer.Argument(help="Attribute name")],
2063
+ fmt: Annotated[
2064
+ OutputFormat,
2065
+ typer.Option("--format", "-f", help="Output format: table/json"),
2066
+ ] = OutputFormat.table,
2067
+ ) -> None:
2068
+ try:
2069
+ client = get_client(ctx)
2070
+ result = datamodel_api.get_attribute(client, name)
2071
+
2072
+ fmt = resolve_format(fmt)
2073
+
2074
+ if fmt == OutputFormat.json:
2075
+ typer.echo(json_mod.dumps(result, ensure_ascii=False, indent=2))
2076
+ return
2077
+
2078
+ data = result.get("data", result) if isinstance(result, dict) else result
2079
+ console = get_console()
2080
+ if isinstance(data, dict):
2081
+ for key, value in data.items():
2082
+ console.print(f"[bold]{key}[/bold]: {value}")
2083
+ except DWFError as exc:
2084
+ handle_error(exc)
2085
+
2086
+
2087
+ @_attribute_sub.command(name="bind-classes")
2088
+ def get_bind_classes(
2089
+ ctx: typer.Context,
2090
+ name: Annotated[str, typer.Argument(help="Attribute name")],
2091
+ fmt: Annotated[
2092
+ OutputFormat,
2093
+ typer.Option("--format", "-f", help="Output format: table/json"),
2094
+ ] = OutputFormat.table,
2095
+ ) -> None:
2096
+ try:
2097
+ client = get_client(ctx)
2098
+ result = datamodel_api.get_attribute_bind_classes(client, name)
2099
+
2100
+ if isinstance(result, dict):
2101
+ payload = result.get("data", result)
2102
+ items = payload if isinstance(payload, list) else [payload]
2103
+ elif isinstance(result, list):
2104
+ items = result
2105
+ else:
2106
+ items = []
2107
+
2108
+ if not items:
2109
+ _empty_ok(ctx, f"No classes bound to attribute '{name}'.")
2110
+ return
2111
+
2112
+ fmt = resolve_format(fmt)
2113
+
2114
+ if fmt == OutputFormat.json:
2115
+ typer.echo(json_mod.dumps(result, ensure_ascii=False, indent=2))
2116
+ return
2117
+
2118
+ _print_table(items, None, get_console())
2119
+ except DWFError as exc:
2120
+ handle_error(exc)
2121
+
2122
+
2123
+ @_attribute_sub.command(name="bind")
2124
+ def bind_attributes(
2125
+ ctx: typer.Context,
2126
+ class_name: Annotated[str, typer.Option("--class-name", "-c", help="Class name")],
2127
+ data: Annotated[
2128
+ str, typer.Option("--data", "-d", help="JSON attributes string")
2129
+ ] = "",
2130
+ file: Annotated[
2131
+ Path | None, typer.Option("--file", help="Path to JSON attributes file")
2132
+ ] = None,
2133
+ is_relation: Annotated[
2134
+ bool,
2135
+ typer.Option("--is-relation", help="Target is a relation class"),
2136
+ ] = False,
2137
+ validate: Annotated[
2138
+ bool,
2139
+ typer.Option(
2140
+ "--validate", help="Validate input against schema before executing"
2141
+ ),
2142
+ ] = False,
2143
+ dry_run: Annotated[
2144
+ bool,
2145
+ typer.Option("--dry-run", help="Preview without executing"),
2146
+ ] = False,
2147
+ fmt: Annotated[
2148
+ OutputFormat,
2149
+ typer.Option("--format", "-f", help="Output format: table/json"),
2150
+ ] = OutputFormat.table,
2151
+ ) -> None:
2152
+ try:
2153
+ payload = _read_payload(data, file)
2154
+ if isinstance(payload, dict):
2155
+ payload = [payload]
2156
+
2157
+ if validate:
2158
+ _do_validate("datamodel attribute bind", payload)
2159
+
2160
+ if dry_run:
2161
+ typer.echo(
2162
+ json_mod.dumps(
2163
+ {
2164
+ "action": "bind",
2165
+ "resource": "attribute",
2166
+ "class": class_name,
2167
+ "is_relation": is_relation,
2168
+ "data": payload,
2169
+ },
2170
+ ensure_ascii=False,
2171
+ indent=2,
2172
+ )
2173
+ )
2174
+ raise typer.Exit(code=DRY_RUN_EXIT_CODE)
2175
+
2176
+ client = get_client(ctx)
2177
+ result = datamodel_api.bind_attributes(
2178
+ client, class_name, payload, is_relation=is_relation
2179
+ )
2180
+ if fmt == OutputFormat.json:
2181
+ typer.echo(
2182
+ json_mod.dumps(
2183
+ {
2184
+ "success": True,
2185
+ "action": "bind",
2186
+ "resource": "attribute",
2187
+ "class": class_name,
2188
+ "data": result,
2189
+ },
2190
+ ensure_ascii=False,
2191
+ indent=2,
2192
+ )
2193
+ )
2194
+ else:
2195
+ typer.echo(f"Bound: {result}", err=True)
2196
+ except DWFError as exc:
2197
+ handle_error(exc)
2198
+
2199
+
2200
+ @_attribute_sub.command(name="unbind")
2201
+ def unbind_attribute(
2202
+ ctx: typer.Context,
2203
+ class_name: Annotated[str, typer.Option("--class-name", "-c", help="Class name")],
2204
+ attr_name: Annotated[str, typer.Argument(help="Attribute name to unbind")],
2205
+ is_relation: Annotated[
2206
+ bool,
2207
+ typer.Option("--is-relation", help="Target is a relation class"),
2208
+ ] = False,
2209
+ dry_run: Annotated[
2210
+ bool,
2211
+ typer.Option("--dry-run", help="Preview without executing"),
2212
+ ] = False,
2213
+ yes: Annotated[
2214
+ bool,
2215
+ typer.Option("--force", help="Skip confirmation prompt"),
2216
+ ] = False,
2217
+ fmt: Annotated[
2218
+ OutputFormat,
2219
+ typer.Option("--format", "-f", help="Output format: table/json"),
2220
+ ] = OutputFormat.table,
2221
+ ) -> None:
2222
+ try:
2223
+ if dry_run:
2224
+ typer.echo(
2225
+ json_mod.dumps(
2226
+ {
2227
+ "action": "unbind",
2228
+ "resource": "attribute",
2229
+ "class": class_name,
2230
+ "attribute": attr_name,
2231
+ "is_relation": is_relation,
2232
+ },
2233
+ ensure_ascii=False,
2234
+ indent=2,
2235
+ )
2236
+ )
2237
+ raise typer.Exit(code=DRY_RUN_EXIT_CODE)
2238
+
2239
+ if is_tty() and not yes:
2240
+ confirm = typer.confirm(
2241
+ f"Unbind attribute '{attr_name}' from '{class_name}'?",
2242
+ default=False,
2243
+ )
2244
+ if not confirm:
2245
+ typer.echo("Cancelled.", err=True)
2246
+ raise typer.Exit(code=0)
2247
+
2248
+ client = get_client(ctx)
2249
+ result = datamodel_api.unbind_attribute(
2250
+ client, class_name, attr_name, is_relation=is_relation
2251
+ )
2252
+ if fmt == OutputFormat.json:
2253
+ typer.echo(
2254
+ json_mod.dumps(
2255
+ {
2256
+ "success": True,
2257
+ "action": "unbind",
2258
+ "resource": "attribute",
2259
+ "class": class_name,
2260
+ "attribute": attr_name,
2261
+ "data": result,
2262
+ },
2263
+ ensure_ascii=False,
2264
+ indent=2,
2265
+ )
2266
+ )
2267
+ else:
2268
+ typer.echo(f"Unbound: {result}", err=True)
2269
+ except DWFError as exc:
2270
+ handle_error(exc)