ocmo-cli 0.8.19__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 (81) hide show
  1. LICENSE +201 -0
  2. NOTICE +16 -0
  3. ocmo_cli/__init__.py +3 -0
  4. ocmo_cli/__main__.py +4 -0
  5. ocmo_cli/_address.py +182 -0
  6. ocmo_cli/_audit_item.py +33 -0
  7. ocmo_cli/_auth_resolve.py +66 -0
  8. ocmo_cli/_body_payload.py +203 -0
  9. ocmo_cli/_click_groups.py +227 -0
  10. ocmo_cli/_client.py +351 -0
  11. ocmo_cli/_client_commands.py +35 -0
  12. ocmo_cli/_command_registry.py +29 -0
  13. ocmo_cli/_commands_map.py +65 -0
  14. ocmo_cli/_config.py +183 -0
  15. ocmo_cli/_config_content.py +43 -0
  16. ocmo_cli/_delete_item_output.py +129 -0
  17. ocmo_cli/_diff_input.py +144 -0
  18. ocmo_cli/_dry_run.py +292 -0
  19. ocmo_cli/_errors.py +151 -0
  20. ocmo_cli/_exit.py +13 -0
  21. ocmo_cli/_export_metadata.py +47 -0
  22. ocmo_cli/_generated_registry.py +45 -0
  23. ocmo_cli/_get_cast_output.py +52 -0
  24. ocmo_cli/_get_item_list.py +23 -0
  25. ocmo_cli/_globalpermission_output.py +98 -0
  26. ocmo_cli/_import_metadata.py +140 -0
  27. ocmo_cli/_import_plan.py +221 -0
  28. ocmo_cli/_item_output.py +223 -0
  29. ocmo_cli/_lazy_mixin.py +29 -0
  30. ocmo_cli/_ls_wide.py +286 -0
  31. ocmo_cli/_mutating.py +39 -0
  32. ocmo_cli/_notice.py +17 -0
  33. ocmo_cli/_options.py +77 -0
  34. ocmo_cli/_output.py +424 -0
  35. ocmo_cli/_output_manifest.py +332 -0
  36. ocmo_cli/_resolve_draft.py +50 -0
  37. ocmo_cli/_resolve_options.py +143 -0
  38. ocmo_cli/_resolve_output.py +228 -0
  39. ocmo_cli/_resolve_parameters_output.py +72 -0
  40. ocmo_cli/_resolve_series.py +89 -0
  41. ocmo_cli/_resolve_series_chart.py +409 -0
  42. ocmo_cli/_resolve_write.py +185 -0
  43. ocmo_cli/_resolver_output.py +34 -0
  44. ocmo_cli/_resource_aliases.py +22 -0
  45. ocmo_cli/_sdk_dispatch.py +310 -0
  46. ocmo_cli/_typing.py +27 -0
  47. ocmo_cli/_version_output.py +39 -0
  48. ocmo_cli/_whoami_output.py +43 -0
  49. ocmo_cli/commands/__init__.py +1 -0
  50. ocmo_cli/commands/api.py +125 -0
  51. ocmo_cli/commands/api_health.py +12 -0
  52. ocmo_cli/commands/apply.py +153 -0
  53. ocmo_cli/commands/auth.py +786 -0
  54. ocmo_cli/commands/can_i.py +51 -0
  55. ocmo_cli/commands/completion.py +59 -0
  56. ocmo_cli/commands/config_cmd.py +235 -0
  57. ocmo_cli/commands/describe.py +109 -0
  58. ocmo_cli/commands/diff.py +86 -0
  59. ocmo_cli/commands/edit.py +200 -0
  60. ocmo_cli/commands/export.py +242 -0
  61. ocmo_cli/commands/generated.py +907 -0
  62. ocmo_cli/commands/gp_move.py +108 -0
  63. ocmo_cli/commands/import_.py +595 -0
  64. ocmo_cli/commands/item_relocate.py +187 -0
  65. ocmo_cli/commands/ls.py +481 -0
  66. ocmo_cli/commands/resolve.py +658 -0
  67. ocmo_cli/commands/resolve_draft.py +102 -0
  68. ocmo_cli/commands/resolve_parameters.py +65 -0
  69. ocmo_cli/commands/resolve_series.py +109 -0
  70. ocmo_cli/commands/schema.py +112 -0
  71. ocmo_cli/commands/timeline.py +135 -0
  72. ocmo_cli/commands/version_cmd.py +102 -0
  73. ocmo_cli/commands/whoami.py +12 -0
  74. ocmo_cli/main.py +241 -0
  75. ocmo_cli/output_formats.yaml +295 -0
  76. ocmo_cli-0.8.19.dist-info/METADATA +169 -0
  77. ocmo_cli-0.8.19.dist-info/RECORD +81 -0
  78. ocmo_cli-0.8.19.dist-info/WHEEL +4 -0
  79. ocmo_cli-0.8.19.dist-info/entry_points.txt +2 -0
  80. ocmo_cli-0.8.19.dist-info/licenses/LICENSE +201 -0
  81. ocmo_cli-0.8.19.dist-info/licenses/NOTICE +16 -0
@@ -0,0 +1,187 @@
1
+ """Hand-written ``ocmo move item`` and ``ocmo copy item`` commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import click
8
+
9
+ from .._address import (
10
+ parse_address_or_exit,
11
+ parse_simple_address_or_exit,
12
+ resolve_relocate_target,
13
+ )
14
+ from .._click_groups import attach_resource_command, ensure_resource_alias_group
15
+ from .._client import OcmoCtx
16
+ from .._dry_run import format_generated_dry_run
17
+ from .._errors import sdk_command
18
+ from .._exit import USAGE_ERROR
19
+ from .._mutating import run_mutating
20
+ from .._options import dry_run_option, namespace_option, yes_option
21
+ from .._output import err, status
22
+
23
+ _MOVE_ITEM_HELP = """\
24
+ Move a tree item or folder subtree to a new path.
25
+
26
+ TARGET uses Unix-style semantics: a trailing ``/`` places the source inside that
27
+ directory (keeping its leaf name); otherwise TARGET is the exact destination path.
28
+
29
+ \b
30
+ Examples:
31
+ ocmo -n prod move item app/web app/archive/web
32
+ ocmo -n prod move item b/c/d a/
33
+ """
34
+
35
+ _COPY_ITEM_HELP = """\
36
+ Copy a tree item or folder subtree to a new path.
37
+
38
+ When SOURCE includes ``@tag`` or ``@version``, that version is copied; otherwise
39
+ the API default tag (``latest``) is used.
40
+
41
+ TARGET uses Unix-style semantics: a trailing ``/`` places the source inside that
42
+ directory (keeping its leaf name); otherwise TARGET is the exact destination path.
43
+
44
+ \b
45
+ Examples:
46
+ ocmo -n prod copy item app/web app/staging/web
47
+ ocmo -n prod copy item app/web@stable app/staging/
48
+ ocmo -n prod copy item b/c/d a/
49
+ """
50
+
51
+
52
+ def _parse_source_target(
53
+ source: str,
54
+ target: str,
55
+ *,
56
+ allow_source_version: bool,
57
+ ) -> tuple[str, str | None, str]:
58
+ source_path, source_version = parse_address_or_exit(source)
59
+ target_path, target_version = parse_simple_address_or_exit(target)
60
+ if target_version:
61
+ err("TARGET does not support @version.")
62
+ raise SystemExit(USAGE_ERROR)
63
+
64
+ if source_version and not allow_source_version:
65
+ err("SOURCE does not support @version or @tag for move.")
66
+ raise SystemExit(USAGE_ERROR)
67
+
68
+ return source_path, source_version, target_path
69
+
70
+
71
+ def _relocate_confirm_message(action: str, source_path: str, destination_path: str) -> str:
72
+ return f"{action.capitalize()} item {source_path!r} to {destination_path!r}. Continue?"
73
+
74
+
75
+ def _relocate_success_message(action: str, source_path: str, destination_path: str) -> str:
76
+ past = "moved" if action == "move" else "copied"
77
+ return f"Item {source_path!r} was {past} to {destination_path!r}."
78
+
79
+
80
+ def _run_item_relocate(
81
+ ctx: OcmoCtx,
82
+ *,
83
+ op_id: str,
84
+ action: str,
85
+ source: str,
86
+ target: str,
87
+ namespace: str | None,
88
+ dry_run: bool,
89
+ yes: bool,
90
+ allow_source_version: bool,
91
+ ) -> None:
92
+ source_path, source_version, target_path = _parse_source_target(
93
+ source,
94
+ target,
95
+ allow_source_version=allow_source_version,
96
+ )
97
+ destination_path = resolve_relocate_target(source_path, target_path)
98
+
99
+ sdk_kwargs: dict[str, Any] = {"target_path": destination_path}
100
+ if op_id == "copy_item" and source_version:
101
+ sdk_kwargs["tag_to_copy"] = source_version
102
+
103
+ ns = ctx.require_namespace(namespace)
104
+
105
+ def _action() -> None:
106
+ view = ctx.namespace_view(namespace)
107
+ if op_id == "move_item":
108
+ view.move_item(source_path, **sdk_kwargs)
109
+ else:
110
+ view.copy_item(source_path, **sdk_kwargs)
111
+ status(_relocate_success_message(action, source_path, destination_path))
112
+
113
+ run_mutating(
114
+ ctx,
115
+ dry_run=dry_run,
116
+ yes=yes,
117
+ plan_lines=format_generated_dry_run(
118
+ op_id=op_id,
119
+ action=action,
120
+ resource="item",
121
+ path=source_path,
122
+ version=source_version if op_id == "copy_item" else None,
123
+ namespace=ns,
124
+ args=[],
125
+ kwargs=sdk_kwargs,
126
+ client_scope=False,
127
+ ),
128
+ confirm_message=_relocate_confirm_message(action, source_path, destination_path),
129
+ action=_action,
130
+ )
131
+
132
+
133
+ def _build_relocate_command(action: str) -> click.Command:
134
+ help_text = _MOVE_ITEM_HELP if action == "move" else _COPY_ITEM_HELP
135
+ op_id = "move_item" if action == "move" else "copy_item"
136
+ allow_source_version = action == "copy"
137
+
138
+ @click.command("item", help=help_text)
139
+ @click.argument("source")
140
+ @click.argument("target")
141
+ @namespace_option()
142
+ @yes_option()
143
+ @dry_run_option()
144
+ @click.pass_obj
145
+ @sdk_command
146
+ def relocate_cmd(
147
+ ctx: OcmoCtx,
148
+ source: str,
149
+ target: str,
150
+ namespace: str | None,
151
+ dry_run: bool,
152
+ yes: bool,
153
+ ) -> None:
154
+ _run_item_relocate(
155
+ ctx,
156
+ op_id=op_id,
157
+ action=action,
158
+ source=source,
159
+ target=target,
160
+ namespace=namespace,
161
+ dry_run=dry_run,
162
+ yes=yes,
163
+ allow_source_version=allow_source_version,
164
+ )
165
+
166
+ return relocate_cmd
167
+
168
+
169
+ move_item_cmd = _build_relocate_command("move")
170
+ copy_item_cmd = _build_relocate_command("copy")
171
+
172
+
173
+ def register_item_relocate_commands(root: click.Group) -> None:
174
+ """Register hand-written ``move item`` / ``copy item`` commands."""
175
+ move_group = ensure_resource_alias_group(
176
+ root,
177
+ "move",
178
+ help="Move items within the tree.",
179
+ )
180
+ attach_resource_command(move_group, move_item_cmd, canonical="item", aliases=[])
181
+
182
+ copy_group = ensure_resource_alias_group(
183
+ root,
184
+ "copy",
185
+ help="Copy items within the tree.",
186
+ )
187
+ attach_resource_command(copy_group, copy_item_cmd, canonical="item", aliases=[])
@@ -0,0 +1,481 @@
1
+ """ocmo ls / ocmo tree — list or render the item tree under a path."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ import click
9
+
10
+ from .._address import parse_address_or_exit
11
+ from .._client import OcmoCtx
12
+ from .._errors import sdk_command
13
+ from .._exit import USAGE_ERROR
14
+ from .._ls_wide import (
15
+ LS_WIDE_SORT_CHOICES,
16
+ basic_ls_row,
17
+ enrich_ls_rows,
18
+ sort_wide_rows,
19
+ )
20
+ from .._options import namespace_option, output_option
21
+ from .._output import as_dict, emit, emit_table, err
22
+ from .._output_manifest import columns_for_format, get_command_spec, resolve_effective_format
23
+
24
+ if TYPE_CHECKING:
25
+ from ocmo import NamespaceView, OcmoClient
26
+
27
+ _LS_HELP = """\
28
+ List the direct children of a tree path, or the namespace root when ADDRESS is omitted.
29
+
30
+ \b
31
+ Examples:
32
+ ocmo -n prod ls
33
+ ocmo -n prod ls app/
34
+ ocmo -n prod ls app/ -R
35
+ ocmo -n prod ls app/ --hide-folders
36
+ ocmo -n prod ls --hide-system
37
+ ocmo -n prod ls -o json
38
+ ocmo -n prod ls -o wide
39
+ ocmo -n prod ls -o wide --sort updated
40
+ ocmo -n prod ls -o wide --sort created
41
+ ocmo -n prod ls -o name
42
+ ocmo -n prod ls -o path
43
+ """
44
+
45
+ _TREE_HELP = """\
46
+ Render the subtree under a path in tree format.
47
+
48
+ \b
49
+ Examples:
50
+ ocmo -n prod tree
51
+ ocmo -n prod tree app/
52
+ ocmo -n prod tree --depth 2
53
+ ocmo -n prod tree --hide-system
54
+ ocmo -n prod tree --emoji
55
+ """
56
+
57
+ _TREE_TYPE_EMOJI: dict[str, str] = {
58
+ "folder": "📁",
59
+ "config": "📄",
60
+ "template": "📜",
61
+ "secret": "🔒",
62
+ "resolver": "⚡",
63
+ }
64
+
65
+
66
+ def _navigate(
67
+ view: NamespaceView,
68
+ address: str,
69
+ *,
70
+ recursive: bool,
71
+ limit: int | None,
72
+ ) -> tuple[str, dict[str, Any]]:
73
+ kwargs: dict[str, Any] = {}
74
+ if recursive:
75
+ kwargs["recursive"] = True
76
+ if limit is not None:
77
+ kwargs["limit"] = limit
78
+
79
+ if address:
80
+ path, _version = parse_address_or_exit(address)
81
+ result = view.navigate_path(path=path, **kwargs)
82
+ else:
83
+ path = ""
84
+ result = view.navigate_root(**kwargs)
85
+
86
+ data = as_dict(result)
87
+ return path, data
88
+
89
+
90
+ @click.command("ls", help=_LS_HELP)
91
+ @click.argument("address", default="", required=False)
92
+ @namespace_option()
93
+ @output_option("ls")
94
+ @click.option(
95
+ "-R",
96
+ "--recursive",
97
+ is_flag=True,
98
+ default=False,
99
+ help="Fetch the full subtree (not just direct children).",
100
+ )
101
+ @click.option(
102
+ "--limit",
103
+ default=None,
104
+ type=int,
105
+ help="Maximum number of items to return.",
106
+ )
107
+ @click.option(
108
+ "--hide-folders",
109
+ is_flag=True,
110
+ default=False,
111
+ help="Omit folder nodes from the listing.",
112
+ )
113
+ @click.option(
114
+ "--hide-system",
115
+ is_flag=True,
116
+ default=False,
117
+ help="Omit built-in namespace items (_permissions, _webhooks, …).",
118
+ )
119
+ @click.option(
120
+ "--sort",
121
+ "sort_by",
122
+ type=click.Choice(LS_WIDE_SORT_CHOICES, case_sensitive=False),
123
+ default=None,
124
+ help="Sort wide output by path, type, created, or update time (requires -o wide).",
125
+ )
126
+ @click.pass_obj
127
+ @sdk_command
128
+ def ls_cmd(
129
+ ctx: OcmoCtx,
130
+ address: str,
131
+ namespace: str | None,
132
+ output_fmt: str | None,
133
+ recursive: bool,
134
+ limit: int | None,
135
+ hide_folders: bool,
136
+ hide_system: bool,
137
+ sort_by: str | None,
138
+ ) -> None:
139
+ view = ctx.namespace_view(namespace)
140
+ client = ctx.client()
141
+ fmt = resolve_effective_format(output_fmt, ctx.output, get_command_spec("ls"))
142
+ _validate_ls_sort(sort_by, fmt)
143
+
144
+ path, data = _navigate(view, address, recursive=recursive, limit=limit)
145
+ system_paths = _load_system_paths(client) if hide_system else frozenset()
146
+ rows = _filter_navigation_rows(
147
+ _navigation_rows(data),
148
+ hide_folders=hide_folders,
149
+ hide_system=hide_system,
150
+ system_paths=system_paths,
151
+ )
152
+
153
+ if fmt == "wide":
154
+ _render_wide_table(
155
+ client,
156
+ view,
157
+ ctx.require_namespace(namespace),
158
+ rows,
159
+ sort_by=sort_by or "path",
160
+ command_name="ls",
161
+ )
162
+ elif fmt == "table":
163
+ _render_table(rows, command_name="ls")
164
+ elif fmt in ("name", "path"):
165
+ emit(rows, fmt)
166
+ elif fmt == "json":
167
+ import json as _json
168
+
169
+ payload = _filter_navigate_data(
170
+ data,
171
+ hide_folders=hide_folders,
172
+ hide_system=hide_system,
173
+ system_paths=system_paths,
174
+ )
175
+ print(_json.dumps(payload, default=str, indent=2))
176
+ elif fmt == "yaml" or (fmt and fmt.startswith("jsonpath=")):
177
+ emit(
178
+ _filter_navigate_data(
179
+ data,
180
+ hide_folders=hide_folders,
181
+ hide_system=hide_system,
182
+ system_paths=system_paths,
183
+ ),
184
+ fmt,
185
+ )
186
+ else:
187
+ err(f"Unsupported output format for ocmo ls: {fmt!r}")
188
+ raise SystemExit(USAGE_ERROR)
189
+
190
+
191
+ @click.command("tree", help=_TREE_HELP)
192
+ @click.argument("address", default="", required=False)
193
+ @namespace_option()
194
+ @click.option(
195
+ "--depth",
196
+ default=None,
197
+ type=click.IntRange(1),
198
+ help="Maximum nesting depth to display (default: unlimited).",
199
+ )
200
+ @click.option(
201
+ "--hide-system",
202
+ is_flag=True,
203
+ default=False,
204
+ help="Omit built-in namespace items (_permissions, _webhooks, …).",
205
+ )
206
+ @click.option(
207
+ "--emoji",
208
+ is_flag=True,
209
+ default=False,
210
+ help="Show item-type emojis before names instead of [type] suffixes.",
211
+ )
212
+ @click.pass_obj
213
+ @sdk_command
214
+ def tree_cmd(
215
+ ctx: OcmoCtx,
216
+ address: str,
217
+ namespace: str | None,
218
+ depth: int | None,
219
+ hide_system: bool,
220
+ emoji: bool,
221
+ ) -> None:
222
+ if ctx.output is not None:
223
+ err("ocmo tree does not support -o/--output; output is always a tree view.")
224
+ raise SystemExit(USAGE_ERROR)
225
+
226
+ view = ctx.namespace_view(namespace)
227
+ client = ctx.client()
228
+
229
+ path, data = _navigate(view, address, recursive=True, limit=None)
230
+ system_paths = _load_system_paths(client) if hide_system else frozenset()
231
+ rows = _filter_navigation_rows(
232
+ _navigation_rows(data),
233
+ hide_folders=False,
234
+ hide_system=hide_system,
235
+ system_paths=system_paths,
236
+ )
237
+
238
+ tree_rows = _build_tree_hierarchy(
239
+ rows,
240
+ root_prefix=path.strip("/") if address else "",
241
+ )
242
+ _render_plain_tree(tree_rows, prefix="", connector="", max_depth=depth, use_emoji=emoji)
243
+
244
+
245
+ def _validate_ls_sort(sort_by: str | None, fmt: str | None) -> None:
246
+ if sort_by is not None and fmt != "wide":
247
+ err("--sort is only supported with -o wide.")
248
+ raise SystemExit(USAGE_ERROR)
249
+
250
+
251
+ _DEFAULT_BUILTIN_NAMESPACE_PATHS = frozenset(
252
+ {
253
+ "_permissions",
254
+ "_permissions.schema",
255
+ "_webhooks",
256
+ "_webhooks.schema",
257
+ "_webhooks_secret",
258
+ "_git_sync",
259
+ "_git_sync.schema",
260
+ "_git_sync_secret",
261
+ }
262
+ )
263
+
264
+
265
+ def _load_system_paths(client: OcmoClient) -> frozenset[str]:
266
+ """Return built-in namespace tree paths from ``/api/version`` when available."""
267
+ version_info = None
268
+ if hasattr(client, "version_info"):
269
+ try:
270
+ version_info = client.version_info()
271
+ except Exception:
272
+ version_info = None
273
+ if not version_info:
274
+ return _DEFAULT_BUILTIN_NAMESPACE_PATHS
275
+
276
+ payload = version_info.get("builtin_namespace_paths") if isinstance(version_info, dict) else None
277
+ if not isinstance(payload, dict):
278
+ return _DEFAULT_BUILTIN_NAMESPACE_PATHS
279
+
280
+ order = payload.get("order")
281
+ if isinstance(order, list) and order:
282
+ return frozenset(str(path).strip("/") for path in order if str(path).strip("/"))
283
+
284
+ paths: set[str] = set()
285
+ for section in ("config", "secret", "schema"):
286
+ values = payload.get(section)
287
+ if isinstance(values, list):
288
+ paths.update(str(path).strip("/") for path in values if str(path).strip("/"))
289
+ return frozenset(paths) if paths else _DEFAULT_BUILTIN_NAMESPACE_PATHS
290
+
291
+
292
+ def _node_path(node: Any) -> str:
293
+ item = node if isinstance(node, dict) else vars(node)
294
+ return str(item.get("path") or "").strip("/")
295
+
296
+
297
+ def _node_type(node: Any) -> str:
298
+ item = node if isinstance(node, dict) else vars(node)
299
+ return item.get("node_type") or ""
300
+
301
+
302
+ def _filter_navigation_rows(
303
+ rows: Sequence[Any],
304
+ *,
305
+ hide_folders: bool,
306
+ hide_system: bool,
307
+ system_paths: frozenset[str],
308
+ ) -> list[Any]:
309
+ if not hide_folders and not hide_system:
310
+ return list(rows)
311
+ filtered: list[Any] = []
312
+ for node in rows:
313
+ if hide_folders and _node_type(node) == "folder":
314
+ continue
315
+ if hide_system and _node_path(node) in system_paths:
316
+ continue
317
+ item = node if isinstance(node, dict) else dict(vars(node))
318
+ children = item.get("children") or []
319
+ if children:
320
+ item = {
321
+ **item,
322
+ "children": _filter_navigation_rows(
323
+ children,
324
+ hide_folders=hide_folders,
325
+ hide_system=hide_system,
326
+ system_paths=system_paths,
327
+ ),
328
+ }
329
+ filtered.append(item)
330
+ return filtered
331
+
332
+
333
+ def _filter_navigate_data(
334
+ data: dict[str, Any],
335
+ *,
336
+ hide_folders: bool,
337
+ hide_system: bool,
338
+ system_paths: frozenset[str],
339
+ ) -> dict[str, Any]:
340
+ if not hide_folders and not hide_system:
341
+ return data
342
+ payload = dict(data)
343
+ payload["children"] = _filter_navigation_rows(
344
+ payload.get("children") or [],
345
+ hide_folders=hide_folders,
346
+ hide_system=hide_system,
347
+ system_paths=system_paths,
348
+ )
349
+ item = payload.get("item")
350
+ if payload.get("is_leaf") and item:
351
+ if hide_folders and _node_type(item) == "folder":
352
+ payload["item"] = None
353
+ elif hide_system and _node_path(item) in system_paths:
354
+ payload["item"] = None
355
+ if payload.get("children"):
356
+ payload["children_count"] = len(payload["children"])
357
+ elif (hide_folders or hide_system) and not payload.get("is_leaf"):
358
+ payload["children_count"] = 0
359
+ return payload
360
+
361
+
362
+ def _navigation_rows(data: dict[str, Any]) -> list[Any]:
363
+ """Return the nodes to list for a navigate response.
364
+
365
+ Folder paths expose direct children; leaf paths expose the matched item itself.
366
+ """
367
+ children = data.get("children") or []
368
+ if children:
369
+ return children
370
+ item = data.get("item")
371
+ if data.get("is_leaf") and item:
372
+ return [item]
373
+ return []
374
+
375
+
376
+ def _build_tree_hierarchy(nodes: Sequence[Any], *, root_prefix: str = "") -> list[dict[str, Any]]:
377
+ """Nest flat navigate rows (recursive API) into a path hierarchy."""
378
+ root_prefix = root_prefix.strip("/")
379
+
380
+ prepared: list[dict[str, Any]] = []
381
+ for raw in nodes:
382
+ item = raw if isinstance(raw, dict) else dict(vars(raw))
383
+ path = (item.get("path") or "").strip("/")
384
+ if not path or (root_prefix and path == root_prefix):
385
+ continue
386
+ prepared.append({**item, "children": []})
387
+
388
+ if not prepared:
389
+ return []
390
+
391
+ by_path = {node["path"]: node for node in prepared}
392
+ roots: list[dict[str, Any]] = []
393
+
394
+ for node in sorted(prepared, key=lambda item: item["path"]):
395
+ path = node["path"]
396
+ parent_path = path.rsplit("/", 1)[0] if "/" in path else None
397
+
398
+ if parent_path and parent_path in by_path:
399
+ by_path[parent_path]["children"].append(node)
400
+ continue
401
+
402
+ if root_prefix:
403
+ if parent_path == root_prefix:
404
+ roots.append(node)
405
+ else:
406
+ roots.append(node)
407
+ elif parent_path is None:
408
+ roots.append(node)
409
+ else:
410
+ roots.append(node)
411
+
412
+ def _sort_children(items: list[dict[str, Any]]) -> None:
413
+ items.sort(key=lambda item: item.get("path") or "")
414
+ for item in items:
415
+ if item["children"]:
416
+ _sort_children(item["children"])
417
+
418
+ _sort_children(roots)
419
+ return roots
420
+
421
+
422
+ def _tree_node_label(node: dict[str, Any], *, use_emoji: bool) -> str:
423
+ node_name = node.get("name") or node.get("path") or str(node)
424
+ node_type = node.get("node_type") or ""
425
+ if use_emoji:
426
+ icon = _TREE_TYPE_EMOJI.get(node_type, "")
427
+ return f"{icon} {node_name}" if icon else node_name
428
+ type_str = f" [{node_type}]" if node_type else ""
429
+ return f"{node_name}{type_str}"
430
+
431
+
432
+ def _render_plain_tree(
433
+ children: Sequence[Any],
434
+ prefix: str,
435
+ connector: str = "",
436
+ *,
437
+ max_depth: int | None = None,
438
+ current_depth: int = 1,
439
+ use_emoji: bool = False,
440
+ ) -> None:
441
+ for i, item in enumerate(children):
442
+ is_last = i == len(children) - 1
443
+ branch = "└── " if is_last else "├── "
444
+ node = item if isinstance(item, dict) else vars(item)
445
+ print(f"{prefix}{connector}{branch}{_tree_node_label(node, use_emoji=use_emoji)}")
446
+ sub = node.get("children") or []
447
+ if sub and (max_depth is None or current_depth < max_depth):
448
+ child_prefix = prefix + connector + (" " if is_last else "│ ")
449
+ _render_plain_tree(
450
+ sub,
451
+ prefix=child_prefix,
452
+ max_depth=max_depth,
453
+ current_depth=current_depth + 1,
454
+ use_emoji=use_emoji,
455
+ )
456
+
457
+
458
+ def _render_table(children: Sequence[Any], *, command_name: str) -> None:
459
+ rows = []
460
+ for item in children:
461
+ node = item if isinstance(item, dict) else vars(item)
462
+ rows.append(basic_ls_row(node))
463
+ rows.sort(key=lambda row: row["path"])
464
+ spec = get_command_spec(command_name)
465
+ emit_table(rows, columns_for_format(spec, "table", rows))
466
+
467
+
468
+ def _render_wide_table(
469
+ client: OcmoClient,
470
+ view: NamespaceView,
471
+ namespace: str,
472
+ children: Sequence[Any],
473
+ *,
474
+ sort_by: str = "path",
475
+ command_name: str,
476
+ ) -> None:
477
+ nodes = [item if isinstance(item, dict) else vars(item) for item in children]
478
+ rows = enrich_ls_rows(client=client, view=view, namespace=namespace, nodes=nodes)
479
+ sort_wide_rows(rows, sort_by)
480
+ spec = get_command_spec(command_name)
481
+ emit_table(rows, columns_for_format(spec, "wide", rows))