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,51 @@
1
+ """ocmo can-i — check permissions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ import click
8
+
9
+ from .._client import OcmoCtx
10
+ from .._errors import sdk_command
11
+ from .._options import can_i_output_option, namespace_option
12
+ from .._output import as_dict
13
+ from .._output_manifest import emit_command_output
14
+
15
+
16
+ @click.command(
17
+ "can-i",
18
+ help="Check whether the current principal may perform one or more operations.",
19
+ )
20
+ @click.argument("operations", nargs=-1, required=True, metavar="OPERATION...")
21
+ @namespace_option()
22
+ @can_i_output_option()
23
+ @click.option("--resource", default=None, help="Tree path for in-namespace permission probes.")
24
+ @click.pass_obj
25
+ @sdk_command
26
+ def can_i_cmd(
27
+ ctx: OcmoCtx,
28
+ operations: tuple[str, ...],
29
+ namespace: str | None,
30
+ output_fmt: str | None,
31
+ resource: str | None,
32
+ ) -> None:
33
+ """Examples:
34
+
35
+ ocmo can-i config:read config:write -n prod
36
+ ocmo can-i secret:read --namespace prod --resource app/db
37
+ """
38
+ ns = namespace or ctx.namespace
39
+ result = ctx.client().can_i(
40
+ operations=list(operations),
41
+ namespace=ns,
42
+ resource=resource,
43
+ )
44
+ data = as_dict(result)
45
+ emit_command_output("can-i", data, output_fmt, ctx_fmt=ctx.output)
46
+ allowed = data.get("allowed")
47
+ if isinstance(allowed, dict):
48
+ if not all(allowed.values()):
49
+ sys.exit(4)
50
+ elif not allowed:
51
+ sys.exit(4)
@@ -0,0 +1,59 @@
1
+ """ocmo completion — emit shell completion scripts.
2
+
3
+ Completion works offline (no network required).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+
10
+ import click
11
+ from click.shell_completion import get_completion_class
12
+
13
+ _SHELLS = ("bash", "zsh", "fish")
14
+
15
+
16
+ @click.command("completion")
17
+ @click.argument("shell", type=click.Choice(list(_SHELLS)), required=False)
18
+ def completion_cmd(shell: str | None) -> None:
19
+ """Emit a shell completion script.
20
+
21
+ \b
22
+ Usage:
23
+ # bash
24
+ source <(ocmo completion bash)
25
+
26
+ # zsh
27
+ source <(ocmo completion zsh)
28
+
29
+ # fish
30
+ ocmo completion fish | source
31
+ """
32
+ if not shell:
33
+ detected = _detect_shell()
34
+ if detected:
35
+ shell = detected
36
+ else:
37
+ raise click.UsageError(f"SHELL is required. Valid options: {', '.join(_SHELLS)}")
38
+
39
+ comp_cls = get_completion_class(shell)
40
+ if comp_cls is None:
41
+ raise click.UsageError(f"Unsupported shell: {shell}")
42
+
43
+ from ocmo_cli.main import cli
44
+
45
+ env_var = f"_{_prog_name().upper()}_COMPLETE"
46
+ comp = comp_cls(cli, ctx_args={}, prog_name=_prog_name(), complete_var=env_var)
47
+ click.echo(comp.source(), nl=False)
48
+
49
+
50
+ def _detect_shell() -> str | None:
51
+ shell_path = os.environ.get("SHELL", "")
52
+ for s in _SHELLS:
53
+ if s in shell_path:
54
+ return s
55
+ return None
56
+
57
+
58
+ def _prog_name() -> str:
59
+ return "ocmo"
@@ -0,0 +1,235 @@
1
+ """ocmo config — manage the local configuration file and contexts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import click
8
+
9
+ from .._auth_resolve import ensure_auth_entry
10
+ from .._click_groups import ConfigPathHelpGroup
11
+ from .._config import (
12
+ Context,
13
+ format_config_path_note,
14
+ load_config,
15
+ save_config,
16
+ )
17
+ from .._output import err
18
+
19
+ _SETTABLE_CONTEXT_KEYS = ("server", "namespace", "token-file", "auth")
20
+
21
+ _CONFIG_HELP = """\
22
+ Manage local CLI configuration (server, contexts, auth).
23
+ """
24
+
25
+
26
+ @click.group("config", cls=ConfigPathHelpGroup, help=_CONFIG_HELP)
27
+ def config_group() -> None:
28
+ """Manage local CLI configuration (server, contexts, auth)."""
29
+
30
+
31
+ @config_group.command("view")
32
+ @click.option(
33
+ "--show-secrets", is_flag=True, default=False, help="Show credential fields (CAUTION: may expose secrets)."
34
+ )
35
+ def view_cmd(show_secrets: bool) -> None:
36
+ """Print the current configuration file, redacting credentials by default."""
37
+ import yaml # deferred
38
+
39
+ cfg = load_config()
40
+ raw: dict[str, Any] = {
41
+ "current-context": cfg.current_context,
42
+ "contexts": {},
43
+ "auths": {},
44
+ }
45
+ for name, ctx in cfg.contexts.items():
46
+ raw["contexts"][name] = {
47
+ "server": ctx.server,
48
+ "namespace": ctx.namespace,
49
+ "auth": ctx.auth,
50
+ }
51
+ if ctx.token_file:
52
+ raw["contexts"][name]["token-file"] = ctx.token_file
53
+
54
+ for name, auth in cfg.auths.items():
55
+ entry: dict[str, Any] = {"mode": auth.mode}
56
+ if auth.issuer:
57
+ entry["issuer"] = auth.issuer
58
+ if auth.client_id:
59
+ entry["client_id"] = auth.client_id
60
+ if show_secrets:
61
+ if auth.client_secret:
62
+ entry["client_secret"] = auth.client_secret
63
+ if auth.token:
64
+ entry["token"] = auth.token
65
+ else:
66
+ if auth.client_secret:
67
+ entry["client_secret"] = "***REDACTED***"
68
+ if auth.token:
69
+ entry["token"] = "***REDACTED***"
70
+ if auth.client_secret_file:
71
+ entry["client_secret_file"] = auth.client_secret_file
72
+ if auth.token_file:
73
+ entry["token_file"] = auth.token_file
74
+ raw["auths"][name] = entry
75
+
76
+ print(yaml.safe_dump(raw, default_flow_style=False), end="")
77
+ print(f"\n# {format_config_path_note()}")
78
+
79
+
80
+ @config_group.command("get-contexts")
81
+ def get_contexts_cmd() -> None:
82
+ """List available contexts."""
83
+ cfg = load_config()
84
+ if not cfg.contexts:
85
+ print("No contexts configured.")
86
+ return
87
+ current = cfg.current_context
88
+ for name, ctx in cfg.contexts.items():
89
+ marker = "*" if name == current else " "
90
+ print(f"{marker} {name:20s} {ctx.server:40s} {ctx.namespace}")
91
+
92
+
93
+ @config_group.command("current-context")
94
+ def current_context_cmd() -> None:
95
+ """Print the name of the current context."""
96
+ cfg = load_config()
97
+ print(cfg.current_context or "(none)")
98
+
99
+
100
+ @config_group.command("current-ns")
101
+ def current_ns_cmd() -> None:
102
+ """Print the effective namespace (OCMO_NAMESPACE or current context)."""
103
+ cfg = load_config()
104
+ print(cfg.namespace() or "(none)")
105
+
106
+
107
+ @config_group.command("use-context")
108
+ @click.argument("name")
109
+ def use_context_cmd(name: str) -> None:
110
+ """Set the current context."""
111
+ cfg = load_config()
112
+ if name not in cfg.contexts:
113
+ available = list(cfg.contexts)
114
+ err(
115
+ f"Context {name!r} not found. "
116
+ + (f"Available: {', '.join(available)}" if available else "No contexts configured.")
117
+ )
118
+ raise SystemExit(1)
119
+ cfg.current_context = name
120
+ save_config(cfg)
121
+ print(f"Switched to context {name!r}.")
122
+
123
+
124
+ @config_group.command("set-context")
125
+ @click.argument("name")
126
+ @click.option("--server", default=None)
127
+ @click.option("--namespace", default=None)
128
+ @click.option("--auth", default=None)
129
+ @click.option("--token-file", default=None)
130
+ def set_context_cmd(
131
+ name: str,
132
+ server: str | None,
133
+ namespace: str | None,
134
+ auth: str | None,
135
+ token_file: str | None,
136
+ ) -> None:
137
+ """Create or update a named context."""
138
+ cfg = load_config()
139
+ ctx = cfg.contexts.get(name, Context())
140
+ if server:
141
+ ctx.server = server
142
+ if namespace:
143
+ ctx.namespace = namespace
144
+ if auth:
145
+ ctx.auth = auth
146
+ ensure_auth_entry(cfg, auth)
147
+ if token_file:
148
+ ctx.token_file = token_file
149
+ cfg.contexts[name] = ctx
150
+ save_config(cfg)
151
+ print(f"Context {name!r} updated.")
152
+
153
+
154
+ @config_group.command("set-auth")
155
+ @click.argument("name")
156
+ @click.option("--mode", default=None, help="Auth mode (default: oidc).")
157
+ @click.option("--issuer", default=None, help="OIDC issuer URL.")
158
+ @click.option("--client-id", default=None, help="OIDC client ID.")
159
+ @click.option("--client-secret", default=None, help="OIDC client secret (inline).")
160
+ @click.option("--client-secret-file", default=None, help="Path to OIDC client secret file.")
161
+ @click.option("--token", default=None, help="Bearer or resolver token (inline).")
162
+ @click.option("--token-file", default=None, help="Path to bearer/resolver token file.")
163
+ def set_auth_cmd(
164
+ name: str,
165
+ mode: str | None,
166
+ issuer: str | None,
167
+ client_id: str | None,
168
+ client_secret: str | None,
169
+ client_secret_file: str | None,
170
+ token: str | None,
171
+ token_file: str | None,
172
+ ) -> None:
173
+ """Create or update a named auth block.
174
+
175
+ ``issuer`` and ``client_id`` are optional in the config file; at runtime they
176
+ are resolved from environment variables or ``GET /api/version`` for the context
177
+ server when unset.
178
+ """
179
+ cfg = load_config()
180
+ entry = ensure_auth_entry(cfg, name, mode=mode or "oidc")
181
+ if mode:
182
+ entry.mode = mode
183
+ if issuer is not None:
184
+ entry.issuer = issuer
185
+ if client_id is not None:
186
+ entry.client_id = client_id
187
+ if client_secret is not None:
188
+ entry.client_secret = client_secret
189
+ if client_secret_file is not None:
190
+ entry.client_secret_file = client_secret_file
191
+ if token is not None:
192
+ entry.token = token
193
+ if token_file is not None:
194
+ entry.token_file = token_file
195
+ cfg.auths[name] = entry
196
+ save_config(cfg)
197
+ print(f"Auth {name!r} updated.")
198
+
199
+
200
+ @config_group.command("set")
201
+ @click.argument("key", type=click.Choice(list(_SETTABLE_CONTEXT_KEYS)))
202
+ @click.argument("value")
203
+ def set_cmd(key: str, value: str) -> None:
204
+ """Set a field in the current context.
205
+
206
+ \b
207
+ Supported keys:
208
+ server — API base URL
209
+ namespace — default namespace for -n
210
+ token-file — path to a resolver or bearer token file
211
+ auth — name of an auths: entry to attach
212
+ """
213
+ cfg = load_config()
214
+ if not cfg.current_context:
215
+ # Create a default context
216
+ cfg.current_context = "default"
217
+ if "default" not in cfg.contexts:
218
+ cfg.contexts["default"] = Context()
219
+
220
+ ctx_name = cfg.current_context
221
+ ctx = cfg.contexts.get(ctx_name, Context())
222
+
223
+ if key == "server":
224
+ ctx.server = value
225
+ elif key == "namespace":
226
+ ctx.namespace = value
227
+ elif key == "token-file":
228
+ ctx.token_file = value
229
+ elif key == "auth":
230
+ ctx.auth = value
231
+ ensure_auth_entry(cfg, value)
232
+
233
+ cfg.contexts[ctx_name] = ctx
234
+ save_config(cfg)
235
+ print(f"Set {key} = {value!r} in context {ctx_name!r}.")
@@ -0,0 +1,109 @@
1
+ """ocmo describe — read or set an item's description.
2
+
3
+ Read: no --description flag → fetch and print description to stdout.
4
+ Write: --description flag → POST description to the API.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ import click
13
+
14
+ from .._address import parse_address_or_exit
15
+ from .._client import OcmoCtx
16
+ from .._errors import sdk_command
17
+ from .._mutating import run_mutating
18
+ from .._options import namespace_option, yes_option
19
+ from .._output import as_dict
20
+
21
+ _DESCRIBE_HELP = """\
22
+ Read or set the Markdown description of a tree item.
23
+
24
+ Without --description, prints the current description. With --description, replaces it.
25
+
26
+ \b
27
+ Examples:
28
+ ocmo -n prod describe app/web
29
+ ocmo -n prod describe app/web --description "Production web config"
30
+ ocmo -n prod describe app/web --description ./NOTES.md
31
+ echo "My description" | ocmo -n prod describe app/web --description -
32
+ """
33
+
34
+
35
+ def resolve_description_value(value: str) -> str:
36
+ """Resolve --description to text from a literal, stdin, or file path."""
37
+ if value == "-":
38
+ return sys.stdin.read()
39
+ if value.startswith("/") or value.startswith("."):
40
+ return Path(value).read_text(encoding="utf-8")
41
+ return value
42
+
43
+
44
+ @click.command("describe", help=_DESCRIBE_HELP)
45
+ @click.argument("address")
46
+ @click.option(
47
+ "--description",
48
+ "description_value",
49
+ default=None,
50
+ metavar="TEXT|-|PATH",
51
+ help=("New description text, '-' for stdin, or a file path starting with '/' or '.'."),
52
+ )
53
+ @namespace_option()
54
+ @yes_option(help="Skip overwrite confirmation.")
55
+ @click.pass_obj
56
+ @sdk_command
57
+ def describe_cmd(
58
+ ctx: OcmoCtx,
59
+ address: str,
60
+ description_value: str | None,
61
+ namespace: str | None,
62
+ yes: bool,
63
+ ) -> None:
64
+ """Read or set the description of a tree item.
65
+
66
+ \b
67
+ Read (no --description):
68
+ ocmo -n prod describe app/web
69
+
70
+ Write (--description):
71
+ ocmo -n prod describe app/web --description "Notes for operators"
72
+ ocmo -n prod describe app/web --description ./NOTES.md
73
+ ocmo -n prod describe app/web --description - # from stdin
74
+ """
75
+ path, _version = parse_address_or_exit(address)
76
+
77
+ view = ctx.namespace_view(namespace)
78
+
79
+ if description_value is not None:
80
+ content = resolve_description_value(description_value)
81
+ ns = ctx.require_namespace(namespace)
82
+
83
+ from .._dry_run import format_describe_dry_run # deferred
84
+
85
+ def _action() -> None:
86
+ view.describe_item(path=path, description=content)
87
+ print(f"Description updated for {path!r}.")
88
+
89
+ run_mutating(
90
+ ctx,
91
+ dry_run=False,
92
+ yes=yes,
93
+ plan_lines=[
94
+ format_describe_dry_run(
95
+ path=path,
96
+ namespace=ns,
97
+ char_count=len(content),
98
+ )
99
+ ],
100
+ confirm_message=f"Overwrite description for {path!r}?",
101
+ action=_action,
102
+ abort_exit_code=1,
103
+ abort_via_err=True,
104
+ )
105
+ else:
106
+ item = view.get_item(path=path)
107
+ data = as_dict(item)
108
+ description = data.get("description") or ""
109
+ print(description, end="" if description.endswith("\n") else "\n")
@@ -0,0 +1,86 @@
1
+ """ocmo diff — coloured unified diff between two versions or two items."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ import click
8
+
9
+ from .._address import AddressError
10
+ from .._client import OcmoCtx
11
+ from .._diff_input import diff_sdk_kwargs, parse_diff_spec, render_diff_response
12
+ from .._errors import sdk_command
13
+ from .._exit import USAGE_ERROR
14
+ from .._options import namespace_option, tree_version_option
15
+
16
+
17
+ @click.command("diff")
18
+ @click.argument("addresses", nargs=-1, required=True)
19
+ @tree_version_option()
20
+ @click.option("--from-version", default=None, metavar="VER", help="Left-side version or tag.")
21
+ @click.option("--to-version", default=None, metavar="VER", help="Right-side version or tag.")
22
+ @click.option(
23
+ "--reveal",
24
+ is_flag=True,
25
+ default=False,
26
+ help="Decrypt secret values for diff (requires secret:read).",
27
+ )
28
+ @namespace_option()
29
+ @click.pass_obj
30
+ @sdk_command
31
+ def diff_cmd(
32
+ ctx: OcmoCtx,
33
+ addresses: tuple[str, ...],
34
+ version_flag: str | None,
35
+ from_version: str | None,
36
+ to_version: str | None,
37
+ reveal: bool,
38
+ namespace: str | None,
39
+ ) -> None:
40
+ """Show a unified diff between two versions or two items.
41
+
42
+ \b
43
+ Examples:
44
+ ocmo -n prod diff app/web
45
+ ocmo -n prod diff app/web@2..13
46
+ ocmo -n prod diff app/web --from-version 3 --to-version 5
47
+ ocmo -n prod diff app/web@2 app/staging/web@14
48
+ """
49
+ try:
50
+ spec = parse_diff_spec(
51
+ addresses,
52
+ version_flag=version_flag,
53
+ from_version=from_version,
54
+ to_version=to_version,
55
+ )
56
+ except AddressError as e:
57
+ print(f"Error: {e}", file=sys.stderr)
58
+ raise SystemExit(USAGE_ERROR)
59
+
60
+ path, kwargs = diff_sdk_kwargs(spec, reveal=reveal)
61
+ result = ctx.namespace_view(namespace).diff_item(path=path, **kwargs)
62
+
63
+ if getattr(result, "decryption_required", False):
64
+ print(
65
+ "Error: secret diff requires --reveal to compare decrypted values.",
66
+ file=sys.stderr,
67
+ )
68
+ raise SystemExit(USAGE_ERROR)
69
+
70
+ diff_text = render_diff_response(result)
71
+ if sys.stdout.isatty():
72
+ _print_coloured_diff(diff_text)
73
+ else:
74
+ print(diff_text, end="" if diff_text.endswith("\n") else "\n")
75
+
76
+
77
+ def _print_coloured_diff(text: str) -> None:
78
+ try:
79
+ from rich.console import Console
80
+ from rich.syntax import Syntax
81
+
82
+ console = Console()
83
+ syntax = Syntax(text, "diff", theme="monokai", line_numbers=False)
84
+ console.print(syntax)
85
+ except ImportError:
86
+ print(text, end="" if text.endswith("\n") else "\n")