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,221 @@
1
+ """Import plan building and validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ import json
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ from ._import_metadata import import_file_text, metadata_tree_path, read_file_metadata
11
+
12
+ if TYPE_CHECKING:
13
+ from ocmo import NamespaceView
14
+
15
+ VALID_IMPORT_TYPES = frozenset({"config", "template", "secret", "resolver"})
16
+
17
+
18
+ def try_parse_config_document(content_bytes: bytes) -> tuple[dict[str, Any] | None, bool]:
19
+ """Return ``(document, is_json)`` when *content_bytes* is a YAML/JSON object."""
20
+ text = content_bytes.decode("utf-8", errors="replace")
21
+ try:
22
+ doc = json.loads(text)
23
+ if isinstance(doc, dict):
24
+ return doc, True
25
+ except json.JSONDecodeError:
26
+ pass
27
+
28
+ import yaml # deferred
29
+
30
+ try:
31
+ doc = yaml.safe_load(text)
32
+ except Exception:
33
+ return None, False
34
+ if isinstance(doc, dict):
35
+ return doc, False
36
+ return None, False
37
+
38
+
39
+ def classify_file_kind(content_bytes: bytes) -> str:
40
+ """Classify a source file as ``config`` or ``template`` by parsing content."""
41
+ doc, _ = try_parse_config_document(content_bytes)
42
+ return "config" if doc is not None else "template"
43
+
44
+
45
+ def parse_type_override(spec: str) -> tuple[str, str]:
46
+ """Parse ``GLOB=TYPE`` from ``--type-override``."""
47
+ if "=" not in spec:
48
+ raise ValueError(f"expected GLOB=TYPE, got {spec!r}")
49
+ pattern, item_type = spec.split("=", 1)
50
+ pattern = pattern.strip()
51
+ item_type = item_type.strip().lower()
52
+ if not pattern:
53
+ raise ValueError(f"type override pattern must not be empty in {spec!r}")
54
+ _validate_import_type(item_type)
55
+ return pattern, item_type
56
+
57
+
58
+ def load_type_map(path: Path) -> list[tuple[str, str]]:
59
+ """Load glob → item-type mappings from a YAML file."""
60
+ import yaml # deferred
61
+
62
+ raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
63
+ if isinstance(raw, dict) and "types" in raw:
64
+ raw = raw["types"]
65
+ if not isinstance(raw, dict):
66
+ raise ValueError(f"type map {path} must be a YAML mapping")
67
+
68
+ mappings: list[tuple[str, str]] = []
69
+ for pattern, item_type in raw.items():
70
+ pattern = str(pattern).strip()
71
+ item_type = str(item_type).strip().lower()
72
+ if not pattern:
73
+ raise ValueError(f"type map {path} contains an empty pattern")
74
+ _validate_import_type(item_type)
75
+ mappings.append((pattern, item_type))
76
+ return mappings
77
+
78
+
79
+ def explicit_import_type(
80
+ rel_str: str,
81
+ *,
82
+ type_map: list[tuple[str, str]],
83
+ type_overrides: list[tuple[str, str]],
84
+ ) -> str | None:
85
+ """Return the explicitly mapped type for *rel_str*, if any."""
86
+ resolved: str | None = None
87
+ for pattern, item_type in [*type_map, *type_overrides]:
88
+ if _matches_glob(rel_str, pattern):
89
+ resolved = item_type
90
+ return resolved
91
+
92
+
93
+ def resolve_import_type(
94
+ rel_str: str,
95
+ *,
96
+ type_map: list[tuple[str, str]],
97
+ type_overrides: list[tuple[str, str]],
98
+ metadata_type: str | None,
99
+ content_bytes: bytes,
100
+ ) -> str:
101
+ """Pick the import item type for a source file."""
102
+ resolved = explicit_import_type(
103
+ rel_str,
104
+ type_map=type_map,
105
+ type_overrides=type_overrides,
106
+ )
107
+ if resolved:
108
+ return resolved
109
+ if metadata_type:
110
+ _validate_import_type(metadata_type)
111
+ return metadata_type
112
+ return classify_file_kind(content_bytes)
113
+
114
+
115
+ def _validate_import_type(item_type: str) -> None:
116
+ if item_type not in VALID_IMPORT_TYPES:
117
+ allowed = ", ".join(sorted(VALID_IMPORT_TYPES))
118
+ raise ValueError(f"invalid item type {item_type!r}; expected one of: {allowed}")
119
+
120
+
121
+ def _matches_glob(rel_str: str, pattern: str) -> bool:
122
+ return fnmatch.fnmatch(rel_str, pattern) or fnmatch.fnmatch(Path(rel_str).name, pattern)
123
+
124
+
125
+ def annotate_plan_conflicts(
126
+ plan: list[dict[str, Any]],
127
+ *,
128
+ namespace: str,
129
+ update: bool,
130
+ view: NamespaceView | None = None,
131
+ ) -> list[str]:
132
+ """Mark conflicting entries and return human-readable conflict messages."""
133
+ messages: list[str] = []
134
+ seen_paths: dict[str, str] = {}
135
+
136
+ for entry in plan:
137
+ entry.setdefault("status", "ok")
138
+ entry.setdefault("action", "create")
139
+ entry.pop("conflict", None)
140
+
141
+ if entry.get("conflict_reason"):
142
+ entry["status"] = "conflict"
143
+ entry["conflict"] = entry["conflict_reason"]
144
+ messages.append(f"{entry['source_rel']}: {entry['conflict_reason']}")
145
+ continue
146
+
147
+ tree_path = entry["tree_path"]
148
+ rel = str(entry["source_rel"])
149
+ if tree_path in seen_paths:
150
+ reason = f"tree path {tree_path!r} is already mapped from {seen_paths[tree_path]!r}"
151
+ entry["status"] = "conflict"
152
+ entry["conflict"] = reason
153
+ messages.append(f"{rel}: {reason}")
154
+ continue
155
+ seen_paths[tree_path] = rel
156
+
157
+ meta_ns = entry.get("metadata_namespace")
158
+ if meta_ns and meta_ns != namespace:
159
+ reason = f"metadata namespace {meta_ns!r} does not match {namespace!r}"
160
+ entry["status"] = "conflict"
161
+ entry["conflict"] = reason
162
+ messages.append(f"{rel}: {reason}")
163
+
164
+ if view is not None:
165
+ exists = _item_exists(view, tree_path)
166
+ if exists:
167
+ entry["action"] = "update"
168
+ if not update:
169
+ reason = f"item {tree_path!r} already exists (use --update)"
170
+ entry["status"] = "conflict"
171
+ entry["conflict"] = reason
172
+ messages.append(f"{rel}: {reason}")
173
+
174
+ return messages
175
+
176
+
177
+ def build_metadata_entry(
178
+ *,
179
+ file_path: Path,
180
+ rel: Path,
181
+ rel_str: str,
182
+ target_prefix: str,
183
+ content_bytes: bytes,
184
+ type_map: list[tuple[str, str]],
185
+ type_overrides: list[tuple[str, str]],
186
+ ) -> dict[str, Any] | None:
187
+ """Build one import plan entry from export metadata on *file_path*."""
188
+ meta = read_file_metadata(file_path)
189
+ item_path = meta.get("path", "").strip()
190
+ if not item_path:
191
+ return None
192
+
193
+ metadata_type = meta.get("node_type", "").strip() or None
194
+ node_type = resolve_import_type(
195
+ rel_str,
196
+ type_map=type_map,
197
+ type_overrides=type_overrides,
198
+ metadata_type=metadata_type,
199
+ content_bytes=content_bytes,
200
+ )
201
+ tree_path = metadata_tree_path(item_path, target_prefix)
202
+ body = import_file_text(content_bytes)
203
+
204
+ entry: dict[str, Any] = {
205
+ "kind": node_type,
206
+ "source_rel": rel,
207
+ "source_abs": file_path,
208
+ "tree_path": tree_path,
209
+ "metadata_namespace": meta.get("namespace", "").strip() or None,
210
+ "content": body,
211
+ }
212
+
213
+ return entry
214
+
215
+
216
+ def _item_exists(view: NamespaceView, path: str) -> bool:
217
+ try:
218
+ view.get_item(path=path)
219
+ return True
220
+ except Exception:
221
+ return False
@@ -0,0 +1,223 @@
1
+ """Tree item output formatting (config, template, secret, resolver)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from ._output import (
9
+ _parse_path,
10
+ _print_jsonpath_value,
11
+ _resolve_path,
12
+ as_dict,
13
+ format_datetime,
14
+ sanitize_for_output,
15
+ yaml_dumps,
16
+ )
17
+ from ._resolve_output import _err_meta, resolve_output_format
18
+
19
+ DOCUMENT_NODE_TYPES = frozenset({"config", "template", "secret", "resolver"})
20
+
21
+ DOCUMENT_OUTPUT_RESOURCES = frozenset(
22
+ {
23
+ "config",
24
+ "template",
25
+ "secret",
26
+ "resolver",
27
+ "globalpermission",
28
+ }
29
+ )
30
+
31
+ _METADATA_KEYS = ("path", "name", "node_type", "author")
32
+ _VERSION_METADATA_KEYS = ("version", "updater", "updated_at")
33
+
34
+
35
+ def uses_item_output(op_id: str, action: str, resource: str, result: Any | None) -> bool:
36
+ """True when API result should use resolve-style raw/json/yaml output."""
37
+ if action in ("create", "update") and resource in DOCUMENT_OUTPUT_RESOURCES:
38
+ return True
39
+ if op_id == "get_item" and result is not None:
40
+ return node_type_of(result) in DOCUMENT_NODE_TYPES
41
+ return False
42
+
43
+
44
+ def node_type_of(result: Any) -> str | None:
45
+ if hasattr(result, "node_type"):
46
+ value = result.node_type
47
+ return str(value) if value else None
48
+ if isinstance(result, dict):
49
+ value = result.get("node_type")
50
+ return str(value) if value else None
51
+ return None
52
+
53
+
54
+ def _get_field(obj: Any, key: str) -> Any:
55
+ if isinstance(obj, dict):
56
+ return obj.get(key)
57
+ return getattr(obj, key, None)
58
+
59
+
60
+ def _version_data(obj: Any) -> Any | None:
61
+ return _get_field(obj, "version_data")
62
+
63
+
64
+ def item_version_is_deleted(result: Any) -> bool:
65
+ """True when the fetched item points at a soft-deleted version."""
66
+ version_data = _version_data(result)
67
+ if version_data is None:
68
+ return False
69
+ return bool(_get_field(version_data, "deleted_at"))
70
+
71
+
72
+ def deleted_version_edit_error(path: str, item: Any) -> str:
73
+ """Human-readable error for attempting to edit a deleted version."""
74
+ version_data = _version_data(item)
75
+ version = _get_field(version_data, "version") if version_data else None
76
+ if version is not None:
77
+ return f"Cannot edit deleted version {version} of {path!r}."
78
+ return f"Cannot edit deleted version of {path!r}."
79
+
80
+
81
+ def item_metadata_rows(result: Any, *, resource: str | None = None) -> list[tuple[str, Any]]:
82
+ rows: list[tuple[str, Any]] = []
83
+
84
+ if resource == "namespace":
85
+ for key in ("name", "description", "permissions_tag", "webhooks_tag", "git_sync_tag"):
86
+ value = _get_field(result, key)
87
+ if value is not None and value != "":
88
+ rows.append((key, value))
89
+ return rows
90
+
91
+ if resource == "lock":
92
+ for key in ("path", "reason", "locked_by"):
93
+ value = _get_field(result, key)
94
+ if value is not None and value != "":
95
+ rows.append((key, value))
96
+ expires_at = _get_field(result, "expires_at")
97
+ if expires_at:
98
+ rows.append(("expires_at", format_datetime(expires_at)))
99
+ return rows
100
+
101
+ if resource == "globalpermission":
102
+ for key in ("id", "position", "created_at", "updated_at"):
103
+ value = _get_field(result, key)
104
+ if value is not None and value != "":
105
+ if key in ("created_at", "updated_at"):
106
+ value = format_datetime(value) or value
107
+ rows.append((key, value))
108
+ return rows
109
+
110
+ for key in _METADATA_KEYS:
111
+ value = _get_field(result, key)
112
+ if value is not None and value != "":
113
+ rows.append((key, value))
114
+
115
+ version_data = _version_data(result)
116
+ if version_data is not None:
117
+ deleted_at = _get_field(version_data, "deleted_at")
118
+ version = _get_field(version_data, "version")
119
+ if version is not None and version != "":
120
+ rows.append(("version", version))
121
+
122
+ if deleted_at:
123
+ formatted_deleted_at = format_datetime(deleted_at)
124
+ if formatted_deleted_at:
125
+ rows.append(("deleted_at", formatted_deleted_at))
126
+ deleted_by = _get_field(version_data, "updater")
127
+ if deleted_by:
128
+ rows.append(("deleted_by", deleted_by))
129
+ else:
130
+ for key in _VERSION_METADATA_KEYS[1:]:
131
+ value = _get_field(version_data, key)
132
+ if value is not None and value != "":
133
+ if key == "updated_at":
134
+ value = format_datetime(value) or value
135
+ rows.append((key, value))
136
+
137
+ description = _get_field(result, "description")
138
+ if description:
139
+ rows.append(("description", description))
140
+
141
+ return rows
142
+
143
+
144
+ def item_body(result: Any, *, resource: str | None = None) -> str:
145
+ """Return the document body for stdout in raw mode."""
146
+ if resource == "globalpermission":
147
+ rule = _get_field(result, "rule")
148
+ if rule is None:
149
+ return ""
150
+ payload = sanitize_for_output(as_dict(rule, fallback_vars=False) or rule)
151
+ return yaml_dumps(payload)
152
+
153
+ if resource in ("namespace", "lock"):
154
+ payload = sanitize_for_output(as_dict(result, fallback_vars=False) or result)
155
+ return yaml_dumps(payload)
156
+
157
+ node_type = node_type_of(result)
158
+ if node_type == "resolver":
159
+ body = _get_field(result, "configuration")
160
+ return body if isinstance(body, str) else ""
161
+ version_data = _version_data(result)
162
+ if version_data is not None:
163
+ data = _get_field(version_data, "data")
164
+ return data if isinstance(data, str) else ""
165
+ return ""
166
+
167
+
168
+ def emit_item_metadata(result: Any, *, no_color: bool = False, resource: str | None = None) -> None:
169
+ for key, value in item_metadata_rows(result, resource=resource):
170
+ _err_meta(f"# {key}: {value}", no_color=no_color)
171
+
172
+
173
+ def emit_item_raw(result: Any, *, no_color: bool = False, resource: str | None = None) -> None:
174
+ emit_item_metadata(result, no_color=no_color, resource=resource)
175
+ text = item_body(result, resource=resource)
176
+ print(text, end="" if text.endswith("\n") else "\n")
177
+
178
+
179
+ def emit_item_result(
180
+ result: Any,
181
+ fmt: str,
182
+ *,
183
+ no_color: bool = False,
184
+ resource: str | None = None,
185
+ ) -> None:
186
+ """Emit one tree item in resolve-compatible output formats."""
187
+ if fmt == "raw":
188
+ emit_item_raw(result, no_color=no_color, resource=resource)
189
+ return
190
+
191
+ if fmt == "name":
192
+ name = _get_field(result, "path") or _get_field(result, "name")
193
+ if name:
194
+ print(name)
195
+ return
196
+
197
+ payload = sanitize_for_output(as_dict(result, fallback_vars=False) or result)
198
+
199
+ if fmt == "json":
200
+ print(json.dumps(payload, default=str, indent=2))
201
+ elif fmt == "yaml":
202
+ print(yaml_dumps(payload), end="")
203
+ elif fmt.startswith("jsonpath="):
204
+ value = _resolve_path(payload, _parse_path(fmt[9:]))
205
+ _print_jsonpath_value(value)
206
+ else:
207
+ print(payload)
208
+
209
+
210
+ def item_output_format(
211
+ cli_fmt: str | None,
212
+ ctx_fmt: str | None,
213
+ *,
214
+ command_key: str = "get item",
215
+ ) -> str:
216
+ """Effective output format for tree item commands (default: raw)."""
217
+ return resolve_output_format(cli_fmt, ctx_fmt, command_key=command_key)
218
+
219
+
220
+ def item_output_includes_token_in_payload(fmt: str, field: str | None) -> bool:
221
+ if field:
222
+ return field in ("token1", "token")
223
+ return fmt in ("yaml", "json")
@@ -0,0 +1,29 @@
1
+ """Shared lazy-delegation helpers for startup-safe Click stubs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import click
8
+
9
+
10
+ class ClickLazyMixin:
11
+ """Delegate common Click entry points to a lazily loaded target."""
12
+
13
+ def _lazy_target(self) -> click.Command | click.Group:
14
+ raise NotImplementedError
15
+
16
+ def make_context(
17
+ self,
18
+ info_name: str | None,
19
+ args: list[str],
20
+ parent: click.Context | None = None,
21
+ **extra: Any,
22
+ ) -> click.Context:
23
+ return self._lazy_target().make_context(info_name, args, parent=parent, **extra)
24
+
25
+ def invoke(self, ctx: click.Context) -> Any:
26
+ return self._lazy_target().invoke(ctx)
27
+
28
+ def shell_complete(self, ctx: click.Context, incomplete: str) -> list[click.shell_completion.CompletionItem]:
29
+ return self._lazy_target().shell_complete(ctx, incomplete)