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,43 @@
1
+ """Normalize config document bodies before upload."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from ._output import yaml_dumps
9
+
10
+ _CONFIG_METADATA_KEY = "_ocmo"
11
+
12
+
13
+ def prepare_config_apply_content(content: str, *, source_name: str) -> str:
14
+ """Convert JSON config sources to YAML syntax for storage."""
15
+ if not _is_json_config_source(source_name, content):
16
+ return content
17
+
18
+ try:
19
+ doc = json.loads(content)
20
+ except json.JSONDecodeError:
21
+ return content
22
+
23
+ if isinstance(doc, dict):
24
+ _inject_json_cast_metadata(doc)
25
+
26
+ return yaml_dumps(doc)
27
+
28
+
29
+ def _is_json_config_source(source_name: str, content: str) -> bool:
30
+ if source_name != "<stdin>":
31
+ return source_name.lower().endswith(".json")
32
+ stripped = content.lstrip()
33
+ return stripped.startswith("{") or stripped.startswith("[")
34
+
35
+
36
+ def _inject_json_cast_metadata(doc: dict[str, Any]) -> None:
37
+ """Preserve JSON resolve semantics when the on-disk source was JSON."""
38
+ existing = doc.get(_CONFIG_METADATA_KEY)
39
+ cast_block = {"format": "json"}
40
+ if existing is None:
41
+ doc[_CONFIG_METADATA_KEY] = {"cast": cast_block}
42
+ elif isinstance(existing, dict) and "cast" not in existing:
43
+ doc[_CONFIG_METADATA_KEY] = {**existing, "cast": cast_block}
@@ -0,0 +1,129 @@
1
+ """Output formatting for ``ocmo delete item``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any
7
+
8
+ from ._output import emit, emit_table
9
+ from ._output_manifest import get_command_spec, resolve_effective_format
10
+
11
+ _DELETE_PREVIEW_RE = re.compile(r"^[^:]+::\s*(\w+)::\s*(.+)$")
12
+
13
+ _TYPE_BY_LABEL = {
14
+ "folder": "folder",
15
+ "config": "config",
16
+ "template": "template",
17
+ "secret": "secret",
18
+ "resolver": "resolver",
19
+ }
20
+
21
+
22
+ def _split_path_version(raw_path: str) -> tuple[str, int | None]:
23
+ if "@" not in raw_path:
24
+ return raw_path, None
25
+ path, suffix = raw_path.rsplit("@", 1)
26
+ if suffix.isdigit():
27
+ return path, int(suffix)
28
+ return raw_path, None
29
+
30
+
31
+ def parse_delete_preview_line(line: str) -> dict[str, Any]:
32
+ """Parse API delete lines like ``my-ns:: Config:: app/foo``."""
33
+ trimmed = line.strip()
34
+ match = _DELETE_PREVIEW_RE.match(trimmed)
35
+ if not match:
36
+ path = trimmed
37
+ node_type = "config"
38
+ else:
39
+ node_type = _TYPE_BY_LABEL.get(match[1].lower(), "config")
40
+ path = match[2]
41
+
42
+ path, version_number = _split_path_version(path)
43
+ name = path.rsplit("/", 1)[-1] if path else path
44
+ entry: dict[str, Any] = {"path": path, "name": name, "node_type": node_type}
45
+ if version_number is not None:
46
+ entry["version"] = version_number
47
+ return entry
48
+
49
+
50
+ def parse_delete_preview_lines(lines: list[str]) -> list[dict[str, Any]]:
51
+ return [parse_delete_preview_line(line) for line in lines]
52
+
53
+
54
+ def _normalize_path(path: str) -> str:
55
+ return path.strip("/")
56
+
57
+
58
+ def is_folder_delete(entries: list[dict[str, Any]], target_path: str | None) -> bool:
59
+ if not target_path:
60
+ return False
61
+ norm = _normalize_path(target_path)
62
+ return any(
63
+ entry.get("node_type") == "folder" and _normalize_path(entry.get("path") or "") == norm for entry in entries
64
+ )
65
+
66
+
67
+ def delete_item_table_rows(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
68
+ rows: list[dict[str, Any]] = []
69
+ for entry in entries:
70
+ row: dict[str, Any] = {
71
+ "type": entry.get("node_type") or "",
72
+ "path": entry.get("path") or "",
73
+ }
74
+ if entry.get("version") is not None:
75
+ row["version"] = entry["version"]
76
+ rows.append(row)
77
+ rows.sort(key=lambda row: (row["path"], row.get("version") or 0))
78
+ return rows
79
+
80
+
81
+ def delete_item_table_columns(rows: list[dict[str, Any]]) -> list[str]:
82
+ if any(row.get("version") is not None for row in rows):
83
+ return ["type", "version", "path"]
84
+ return ["type", "path"]
85
+
86
+
87
+ def emit_delete_item_output(
88
+ result: Any,
89
+ *,
90
+ target_path: str | None,
91
+ version: str | None,
92
+ output_fmt: str | None,
93
+ ctx_fmt: str | None,
94
+ ) -> None:
95
+ """Render delete results as ``ocmo tree`` (folders) or ``ocmo ls`` (other items)."""
96
+ from .commands.ls import _build_tree_hierarchy, _render_plain_tree
97
+
98
+ command_key = "delete item"
99
+ spec = get_command_spec(command_key)
100
+ effective_fmt = resolve_effective_format(output_fmt, ctx_fmt, spec)
101
+
102
+ delete_lines = list(getattr(result, "delete", None) or [])
103
+ entries = parse_delete_preview_lines(delete_lines)
104
+
105
+ use_tree = effective_fmt == "table" and version is None and is_folder_delete(entries, target_path)
106
+
107
+ if use_tree:
108
+ tree_rows = _build_tree_hierarchy(entries)
109
+ _render_plain_tree(tree_rows, prefix="", connector="", use_emoji=False)
110
+ return
111
+
112
+ rows = delete_item_table_rows(entries)
113
+
114
+ if effective_fmt == "table":
115
+ emit_table(rows, delete_item_table_columns(rows))
116
+ return
117
+
118
+ if effective_fmt == "name":
119
+ for entry in entries:
120
+ print(entry.get("path") or "")
121
+ return
122
+
123
+ if effective_fmt == "path":
124
+ for entry in entries:
125
+ print(entry.get("path") or "")
126
+ return
127
+
128
+ payload = {"delete": entries}
129
+ emit(payload, effective_fmt)
@@ -0,0 +1,144 @@
1
+ """Parse ``ocmo diff`` addresses and render unified diff output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ from ocmo._generated.types import UNSET
10
+
11
+ from ._address import AddressError, _validate_path, parse_address
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class DiffSpec:
16
+ path: str
17
+ from_ref: str | None = None
18
+ to_ref: str | None = None
19
+ to_path: str | None = None
20
+
21
+
22
+ def diff_sdk_kwargs(spec: DiffSpec, *, reveal: bool = False) -> tuple[str, dict[str, Any]]:
23
+ """Return ``(path, kwargs)`` for ``NamespaceView.diff_item``."""
24
+ kwargs: dict[str, Any] = {}
25
+ if spec.from_ref is not None:
26
+ kwargs["from_"] = spec.from_ref
27
+ if spec.to_ref is not None:
28
+ kwargs["to"] = spec.to_ref
29
+ if spec.to_path is not None:
30
+ kwargs["to_path"] = spec.to_path
31
+ if reveal:
32
+ kwargs["reveal"] = True
33
+ return spec.path, kwargs
34
+
35
+
36
+ def parse_diff_spec(
37
+ addresses: tuple[str, ...],
38
+ *,
39
+ version_flag: str | None = None,
40
+ from_version: str | None = None,
41
+ to_version: str | None = None,
42
+ ) -> DiffSpec:
43
+ """Resolve diff operands from positional addresses and version flags."""
44
+ if not addresses:
45
+ raise AddressError("At least one address is required.")
46
+ if len(addresses) > 2:
47
+ raise AddressError("Expected one or two addresses.")
48
+
49
+ if len(addresses) == 2:
50
+ if version_flag:
51
+ raise AddressError(
52
+ "Cannot use --version with two addresses; " "use @suffix or --from-version / --to-version."
53
+ )
54
+ if ".." in addresses[0] or ".." in addresses[1]:
55
+ raise AddressError("Version range (..) is only supported with a single address.")
56
+ from_path, from_ref = parse_address(addresses[0])
57
+ to_path, to_ref = parse_address(addresses[1])
58
+ return DiffSpec(
59
+ path=from_path,
60
+ from_ref=from_version or from_ref,
61
+ to_ref=to_version or to_ref,
62
+ to_path=to_path if to_path != from_path else None,
63
+ )
64
+
65
+ raw = addresses[0]
66
+ if "@" in raw:
67
+ path, version_part = raw.split("@", 1)
68
+ if ".." in version_part:
69
+ if version_flag:
70
+ raise AddressError(
71
+ f"Conflicting version specifications: address suffix '@{version_part}' "
72
+ f"and --version '{version_flag}'. Provide only one."
73
+ )
74
+ _validate_path(path)
75
+ range_from, range_to = _parse_version_range(version_part)
76
+ return DiffSpec(
77
+ path=path,
78
+ from_ref=from_version or range_from,
79
+ to_ref=to_version or range_to,
80
+ )
81
+
82
+ path, version = parse_address(raw, version_flag=version_flag)
83
+ return DiffSpec(
84
+ path=path,
85
+ from_ref=from_version,
86
+ to_ref=to_version or version,
87
+ )
88
+
89
+
90
+ def _parse_version_range(version_part: str) -> tuple[str, str]:
91
+ left, sep, right = version_part.partition("..")
92
+ if not sep or not left or not right:
93
+ raise AddressError(f"Invalid version range '@{version_part}'; expected path@FROM..TO.")
94
+ return left, right
95
+
96
+
97
+ def diff_side_label(side: Any) -> str:
98
+ requested = getattr(side, "requested", None) or ""
99
+ path = getattr(side, "path", "")
100
+ if requested:
101
+ return f"{path}@{requested}"
102
+ version = getattr(side, "version", None)
103
+ if version is not None:
104
+ return f"{path}@v{version}"
105
+ return path
106
+
107
+
108
+ def _side_text(side: Any) -> str:
109
+ data = getattr(side, "data", UNSET)
110
+ if data is UNSET or data is None:
111
+ return ""
112
+ return str(data)
113
+
114
+
115
+ def render_unified_diff(from_text: str, to_text: str, *, from_label: str, to_label: str) -> str:
116
+ if from_text == to_text:
117
+ return "No differences\n"
118
+ lines = difflib.unified_diff(
119
+ from_text.splitlines(keepends=True),
120
+ to_text.splitlines(keepends=True),
121
+ fromfile=from_label,
122
+ tofile=to_label,
123
+ )
124
+ rendered = "".join(lines)
125
+ return rendered or "No differences\n"
126
+
127
+
128
+ def render_diff_response(result: Any) -> str:
129
+ """Build unified diff text from an SDK ``DiffResponseSchema``."""
130
+ if getattr(result, "decryption_required", False):
131
+ return ""
132
+
133
+ from_text = _side_text(result.from_side)
134
+ to_text = _side_text(result.to_side)
135
+
136
+ if getattr(result, "identical", False) is True:
137
+ return "No differences\n"
138
+
139
+ return render_unified_diff(
140
+ from_text,
141
+ to_text,
142
+ from_label=diff_side_label(result.from_side),
143
+ to_label=diff_side_label(result.to_side),
144
+ )
ocmo_cli/_dry_run.py ADDED
@@ -0,0 +1,292 @@
1
+ """User-facing dry-run plan messages for CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ def quote(value: str) -> str:
9
+ return f"'{value}'"
10
+
11
+
12
+ def namespace_clause(namespace: str | None, *, client_scope: bool) -> str:
13
+ if client_scope or not namespace:
14
+ return ""
15
+ return f" in namespace {quote(namespace)}"
16
+
17
+
18
+ def versioned_path(path: str | None, version: str | None) -> str:
19
+ if not path:
20
+ return "?"
21
+ if version:
22
+ return f"{quote(path)}@{version}"
23
+ return quote(path)
24
+
25
+
26
+ def file_clause(file_path: str | None) -> str:
27
+ if not file_path:
28
+ return ""
29
+ label = "<stdin>" if file_path == "-" else file_path
30
+ return f" from {quote(label)}"
31
+
32
+
33
+ def body_dict(kwargs: dict[str, Any]) -> dict[str, Any]:
34
+ body = kwargs.get("body")
35
+ return body if isinstance(body, dict) else {}
36
+
37
+
38
+ def primary_target(
39
+ op_id: str,
40
+ *,
41
+ path: str | None,
42
+ args: list[Any],
43
+ kwargs: dict[str, Any],
44
+ ) -> str | None:
45
+ if path:
46
+ return path
47
+ if args:
48
+ return str(args[0])
49
+ for key in ("path", "name", "id", "namespace"):
50
+ value = kwargs.get(key)
51
+ if value not in (None, ""):
52
+ return str(value)
53
+ payload = body_dict(kwargs)
54
+ for key in ("name", "id", "path"):
55
+ value = payload.get(key)
56
+ if value not in (None, ""):
57
+ return str(value)
58
+ return None
59
+
60
+
61
+ def format_generated_dry_run(
62
+ *,
63
+ op_id: str,
64
+ action: str,
65
+ resource: str,
66
+ path: str | None,
67
+ version: str | None,
68
+ namespace: str | None,
69
+ args: list[Any],
70
+ kwargs: dict[str, Any],
71
+ client_scope: bool,
72
+ file_path: str | None = None,
73
+ gp_create_position: float | None = None,
74
+ cast_format: str | None = None,
75
+ ) -> list[str]:
76
+ """Return human-readable dry-run plan lines (without the ``[dry-run]`` prefix)."""
77
+ ns = namespace_clause(namespace, client_scope=client_scope)
78
+ target = primary_target(op_id, path=path, args=args, kwargs=kwargs)
79
+ payload = body_dict(kwargs)
80
+ lines: list[str] = []
81
+
82
+ if op_id == "create_namespace":
83
+ name = target or "?"
84
+ line = f"Would create namespace {quote(name)}."
85
+ description = payload.get("description")
86
+ if description:
87
+ line += f" Description: {description!r}."
88
+ lines.append(line)
89
+ return lines
90
+
91
+ if op_id == "update_namespace":
92
+ lines.append(f"Would update namespace {quote(target or '?')}.")
93
+ return lines
94
+
95
+ if op_id == "delete_namespace":
96
+ lines.append(f"Would delete namespace {quote(target or '?')}.")
97
+ return lines
98
+
99
+ if op_id == "create_global_permission":
100
+ rule_id = target or payload.get("id") or "?"
101
+ lines.append(f"Would create global permission rule {quote(str(rule_id))}{file_clause(file_path)}.")
102
+ if gp_create_position is not None:
103
+ lines.append(f"Would set rule position to {gp_create_position}.")
104
+ return lines
105
+
106
+ if op_id == "update_global_permission":
107
+ lines.append(f"Would update global permission rule {quote(target or '?')}{file_clause(file_path)}.")
108
+ return lines
109
+
110
+ if op_id == "delete_global_permission":
111
+ lines.append(f"Would delete global permission rule {quote(target or '?')}.")
112
+ return lines
113
+
114
+ if op_id == "move_global_permission":
115
+ position = payload.get("position", kwargs.get("position"))
116
+ pos_text = f" to position {position}" if position is not None else ""
117
+ lines.append(f"Would move global permission rule {quote(target or '?')}{pos_text}.")
118
+ return lines
119
+
120
+ if op_id == "move_item":
121
+ destination = payload.get("target_path") or kwargs.get("target_path")
122
+ dest = str(destination or "?")
123
+ lines.append(f"Would move item {quote(target or '?')} to {quote(dest)}{ns}.")
124
+ lines.append(f"After move, item will be available at {quote(dest)}.")
125
+ return lines
126
+
127
+ if op_id == "copy_item":
128
+ destination = payload.get("target_path") or kwargs.get("target_path")
129
+ dest = str(destination or "?")
130
+ tag = kwargs.get("tag_to_copy")
131
+ tag_text = f" (tag {quote(str(tag))})" if tag else ""
132
+ lines.append(f"Would copy item {quote(target or '?')} to {quote(dest)}" f"{tag_text}{ns}.")
133
+ lines.append(f"After copy, item will be available at {quote(dest)}.")
134
+ return lines
135
+
136
+ if op_id == "set_tag":
137
+ if action == "untag":
138
+ tag = kwargs.get("tag")
139
+ body = kwargs.get("body")
140
+ if tag is None and isinstance(body, dict):
141
+ tag = body.get("tag")
142
+ lines.append(f"Would remove tag {quote(str(tag or '?'))} from item {quote(target or '?')}{ns}.")
143
+ return lines
144
+ tag = kwargs.get("tag")
145
+ body = kwargs.get("body")
146
+ if tag is None and isinstance(body, dict):
147
+ tag = body.get("tag")
148
+ lines.append(f"Would tag item {quote(target or '?')} as {quote(str(tag or '?'))}{ns}.")
149
+ return lines
150
+
151
+ if op_id == "rotate_resolver_token":
152
+ token_number = kwargs.get("token_number")
153
+ token_text = f" (token #{token_number})" if token_number is not None else ""
154
+ lines.append(f"Would rotate resolver token for {quote(target or '?')}{token_text}{ns}.")
155
+ return lines
156
+
157
+ if op_id == "create_lock":
158
+ reason = payload.get("reason") or kwargs.get("reason")
159
+ reason_text = f" (reason: {reason!r})" if reason else ""
160
+ expires = payload.get("expires_at") or kwargs.get("expires_at")
161
+ expires_text = f", expires {expires}" if expires else ""
162
+ lines.append(f"Would create lock on {quote(target or '?')}{reason_text}{expires_text}{ns}.")
163
+ return lines
164
+
165
+ if op_id == "replace_lock":
166
+ reason = payload.get("reason") or kwargs.get("reason")
167
+ reason_text = f" (reason: {reason!r})" if reason else ""
168
+ expires = payload.get("expires_at") or kwargs.get("expires_at")
169
+ expires_text = f", expires {expires}" if expires else ""
170
+ lines.append(f"Would update lock on {quote(target or '?')}{reason_text}{expires_text}{ns}.")
171
+ return lines
172
+
173
+ if op_id == "delete_lock":
174
+ lines.append(f"Would remove lock on {quote(target or '?')}{ns}.")
175
+ return lines
176
+
177
+ if op_id == "search_root" and resource == "item":
178
+ types = kwargs.get("types") or []
179
+ limit = kwargs.get("limit")
180
+ type_text = f" (types: {', '.join(types)})" if types else ""
181
+ limit_text = f", limit {limit}" if limit is not None else ""
182
+ lines.append(f"Would list items{type_text}{limit_text}{ns}.")
183
+ return lines
184
+
185
+ if op_id == "list_cast_formats" and resource == "cast":
186
+ if cast_format:
187
+ lines.append(f"Would show cast format schema for {quote(cast_format)}{ns}.")
188
+ else:
189
+ lines.append(f"Would list cast formats{ns}.")
190
+ return lines
191
+
192
+ if op_id == "propagate_config":
193
+ lines.append(f"Would propagate config {versioned_path(target, version)} to descendants{ns}.")
194
+ return lines
195
+
196
+ if op_id == "delete_item":
197
+ if version:
198
+ lines.append(
199
+ f"Would delete version {quote(version)} of item {quote(target or '?')}{ns} " f"(not the item itself)."
200
+ )
201
+ else:
202
+ lines.append(f"Would delete item {quote(target or '?')}{ns}.")
203
+ return lines
204
+
205
+ resource_label = _RESOURCE_LABELS.get(resource, resource)
206
+ if action == "create":
207
+ lines.append(
208
+ f"Would create {resource_label} {versioned_path(target, version)}" f"{file_clause(file_path)}{ns}."
209
+ )
210
+ return lines
211
+
212
+ if action == "update":
213
+ lines.append(
214
+ f"Would update {resource_label} {versioned_path(target, version)}" f"{file_clause(file_path)}{ns}."
215
+ )
216
+ return lines
217
+
218
+ if action == "delete":
219
+ lines.append(f"Would delete {resource_label} {versioned_path(target, version)}{ns}.")
220
+ return lines
221
+
222
+ lines.append(f"Would {action} {resource_label} {versioned_path(target, version)}{ns}.")
223
+ return lines
224
+
225
+
226
+ def format_apply_dry_run(
227
+ *,
228
+ kind: str,
229
+ path: str,
230
+ source_name: str,
231
+ namespace: str | None,
232
+ ) -> str:
233
+ ns = namespace_clause(namespace, client_scope=False)
234
+ return f"Would create or update {kind} {quote(path)} from {quote(source_name)}{ns}."
235
+
236
+
237
+ def format_resolve_dry_run(
238
+ *,
239
+ path: str,
240
+ namespace: str,
241
+ cast: str | None = None,
242
+ parameters: dict[str, Any] | None = None,
243
+ ) -> list[str]:
244
+ lines = [f"Would resolve {quote(path)} in namespace {quote(namespace)}."]
245
+ if cast:
246
+ lines.append(f"Cast output as {cast}.")
247
+ if parameters:
248
+ params = ", ".join(f"{key}={value!r}" for key, value in sorted(parameters.items()))
249
+ lines.append(f"Parameters: {params}.")
250
+ return lines
251
+
252
+
253
+ def format_describe_dry_run(*, path: str, namespace: str | None, char_count: int) -> str:
254
+ ns = namespace_clause(namespace, client_scope=False)
255
+ return f"Would update description for {quote(path)}{ns} ({char_count} characters)."
256
+
257
+
258
+ def format_edit_dry_run(*, resource: str, path: str, namespace: str | None) -> str:
259
+ ns = namespace_clause(namespace, client_scope=False)
260
+ label = _RESOURCE_LABELS.get(resource, resource)
261
+ return f"Would edit {label} {quote(path)} via $EDITOR{ns}."
262
+
263
+
264
+ def format_export_dry_run(*, item_path: str, dest_file: str, namespace: str) -> str:
265
+ return f"Would export {quote(item_path)} from namespace {quote(namespace)} " f"to {quote(dest_file)}."
266
+
267
+
268
+ def format_import_dry_run_header(count: int) -> str:
269
+ return f"Would import {count} item{'s' if count != 1 else ''}:"
270
+
271
+
272
+ _RESOURCE_LABELS: dict[str, str] = {
273
+ "namespace": "namespace",
274
+ "globalpermission": "global permission rule",
275
+ "config": "config",
276
+ "template": "template",
277
+ "secret": "secret",
278
+ "resolver": "resolver",
279
+ "item": "item",
280
+ "lock": "lock",
281
+ "token": "resolver token",
282
+ }
283
+
284
+
285
+ def emit_dry_run_plan(lines: list[str] | str) -> None:
286
+ """Print one or more dry-run plan lines to stderr."""
287
+ from ._output import status # deferred
288
+
289
+ if isinstance(lines, str):
290
+ lines = [lines]
291
+ for line in lines:
292
+ status(f"[dry-run] {line}")