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.
- LICENSE +201 -0
- NOTICE +16 -0
- ocmo_cli/__init__.py +3 -0
- ocmo_cli/__main__.py +4 -0
- ocmo_cli/_address.py +182 -0
- ocmo_cli/_audit_item.py +33 -0
- ocmo_cli/_auth_resolve.py +66 -0
- ocmo_cli/_body_payload.py +203 -0
- ocmo_cli/_click_groups.py +227 -0
- ocmo_cli/_client.py +351 -0
- ocmo_cli/_client_commands.py +35 -0
- ocmo_cli/_command_registry.py +29 -0
- ocmo_cli/_commands_map.py +65 -0
- ocmo_cli/_config.py +183 -0
- ocmo_cli/_config_content.py +43 -0
- ocmo_cli/_delete_item_output.py +129 -0
- ocmo_cli/_diff_input.py +144 -0
- ocmo_cli/_dry_run.py +292 -0
- ocmo_cli/_errors.py +151 -0
- ocmo_cli/_exit.py +13 -0
- ocmo_cli/_export_metadata.py +47 -0
- ocmo_cli/_generated_registry.py +45 -0
- ocmo_cli/_get_cast_output.py +52 -0
- ocmo_cli/_get_item_list.py +23 -0
- ocmo_cli/_globalpermission_output.py +98 -0
- ocmo_cli/_import_metadata.py +140 -0
- ocmo_cli/_import_plan.py +221 -0
- ocmo_cli/_item_output.py +223 -0
- ocmo_cli/_lazy_mixin.py +29 -0
- ocmo_cli/_ls_wide.py +286 -0
- ocmo_cli/_mutating.py +39 -0
- ocmo_cli/_notice.py +17 -0
- ocmo_cli/_options.py +77 -0
- ocmo_cli/_output.py +424 -0
- ocmo_cli/_output_manifest.py +332 -0
- ocmo_cli/_resolve_draft.py +50 -0
- ocmo_cli/_resolve_options.py +143 -0
- ocmo_cli/_resolve_output.py +228 -0
- ocmo_cli/_resolve_parameters_output.py +72 -0
- ocmo_cli/_resolve_series.py +89 -0
- ocmo_cli/_resolve_series_chart.py +409 -0
- ocmo_cli/_resolve_write.py +185 -0
- ocmo_cli/_resolver_output.py +34 -0
- ocmo_cli/_resource_aliases.py +22 -0
- ocmo_cli/_sdk_dispatch.py +310 -0
- ocmo_cli/_typing.py +27 -0
- ocmo_cli/_version_output.py +39 -0
- ocmo_cli/_whoami_output.py +43 -0
- ocmo_cli/commands/__init__.py +1 -0
- ocmo_cli/commands/api.py +125 -0
- ocmo_cli/commands/api_health.py +12 -0
- ocmo_cli/commands/apply.py +153 -0
- ocmo_cli/commands/auth.py +786 -0
- ocmo_cli/commands/can_i.py +51 -0
- ocmo_cli/commands/completion.py +59 -0
- ocmo_cli/commands/config_cmd.py +235 -0
- ocmo_cli/commands/describe.py +109 -0
- ocmo_cli/commands/diff.py +86 -0
- ocmo_cli/commands/edit.py +200 -0
- ocmo_cli/commands/export.py +242 -0
- ocmo_cli/commands/generated.py +907 -0
- ocmo_cli/commands/gp_move.py +108 -0
- ocmo_cli/commands/import_.py +595 -0
- ocmo_cli/commands/item_relocate.py +187 -0
- ocmo_cli/commands/ls.py +481 -0
- ocmo_cli/commands/resolve.py +658 -0
- ocmo_cli/commands/resolve_draft.py +102 -0
- ocmo_cli/commands/resolve_parameters.py +65 -0
- ocmo_cli/commands/resolve_series.py +109 -0
- ocmo_cli/commands/schema.py +112 -0
- ocmo_cli/commands/timeline.py +135 -0
- ocmo_cli/commands/version_cmd.py +102 -0
- ocmo_cli/commands/whoami.py +12 -0
- ocmo_cli/main.py +241 -0
- ocmo_cli/output_formats.yaml +295 -0
- ocmo_cli-0.8.19.dist-info/METADATA +169 -0
- ocmo_cli-0.8.19.dist-info/RECORD +81 -0
- ocmo_cli-0.8.19.dist-info/WHEEL +4 -0
- ocmo_cli-0.8.19.dist-info/entry_points.txt +2 -0
- ocmo_cli-0.8.19.dist-info/licenses/LICENSE +201 -0
- ocmo_cli-0.8.19.dist-info/licenses/NOTICE +16 -0
ocmo_cli/_errors.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Map SDK exceptions to CLI exit codes and human-readable messages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Any, ParamSpec, TypeVar
|
|
9
|
+
|
|
10
|
+
from . import _exit
|
|
11
|
+
|
|
12
|
+
P = ParamSpec("P")
|
|
13
|
+
R = TypeVar("R")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def handle_sdk_error(exc: Exception) -> None:
|
|
17
|
+
"""Print an error message and raise SystemExit with the right exit code.
|
|
18
|
+
|
|
19
|
+
Import the error hierarchy lazily so this module itself is cheap to import.
|
|
20
|
+
"""
|
|
21
|
+
# Lazy import to keep startup time low
|
|
22
|
+
from ocmo.errors import ( # noqa: I001
|
|
23
|
+
OcmoAPIError,
|
|
24
|
+
OcmoAuthError,
|
|
25
|
+
OcmoConfigError,
|
|
26
|
+
OcmoConflictError,
|
|
27
|
+
OcmoIncompatibleVersionError,
|
|
28
|
+
OcmoLockedError,
|
|
29
|
+
OcmoNotFoundError,
|
|
30
|
+
OcmoPayloadTooLargeError,
|
|
31
|
+
OcmoPermissionError,
|
|
32
|
+
OcmoTransportError,
|
|
33
|
+
OcmoValidationError,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
if isinstance(exc, OcmoConfigError):
|
|
37
|
+
_die(str(exc), _exit.FAILURE)
|
|
38
|
+
elif isinstance(exc, OcmoIncompatibleVersionError):
|
|
39
|
+
_die(str(exc), _exit.FAILURE)
|
|
40
|
+
elif isinstance(exc, OcmoPermissionError):
|
|
41
|
+
_die(str(exc), _exit.AUTH_ERROR)
|
|
42
|
+
elif isinstance(exc, OcmoAuthError):
|
|
43
|
+
_die(str(exc), _exit.AUTH_ERROR)
|
|
44
|
+
elif isinstance(exc, OcmoNotFoundError):
|
|
45
|
+
_die(str(exc), _exit.NOT_FOUND)
|
|
46
|
+
elif isinstance(exc, OcmoConflictError):
|
|
47
|
+
_die(str(exc), _exit.CONFLICT)
|
|
48
|
+
elif isinstance(exc, OcmoLockedError):
|
|
49
|
+
_die(
|
|
50
|
+
f"{exc} (lock path: {exc.lock_path}, reason: {exc.reason})",
|
|
51
|
+
_exit.LOCKED,
|
|
52
|
+
)
|
|
53
|
+
elif isinstance(exc, OcmoValidationError | OcmoPayloadTooLargeError):
|
|
54
|
+
detail = _format_validation_errors(getattr(exc, "errors", None))
|
|
55
|
+
_die(detail if detail else str(exc), _exit.VALIDATION_ERROR)
|
|
56
|
+
elif isinstance(exc, OcmoTransportError):
|
|
57
|
+
_die(str(exc), _exit.FAILURE)
|
|
58
|
+
elif isinstance(exc, OcmoAPIError):
|
|
59
|
+
msg = str(exc)
|
|
60
|
+
if exc.method and exc.path and exc.method != "HTTP":
|
|
61
|
+
msg = f"{exc.method} {exc.path}: {msg}"
|
|
62
|
+
elif exc.path:
|
|
63
|
+
msg = f"{exc.path}: {msg}"
|
|
64
|
+
code = exc.status_code if 400 <= exc.status_code < 600 else _exit.FAILURE
|
|
65
|
+
if exc.status_code == 404:
|
|
66
|
+
code = _exit.NOT_FOUND
|
|
67
|
+
elif exc.status_code == 409:
|
|
68
|
+
code = _exit.CONFLICT
|
|
69
|
+
elif exc.status_code == 422:
|
|
70
|
+
code = _exit.VALIDATION_ERROR
|
|
71
|
+
_die(msg, code)
|
|
72
|
+
else:
|
|
73
|
+
_die(str(exc), _exit.FAILURE)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _die(msg: str, code: int) -> None:
|
|
77
|
+
msg = msg.rstrip("\n")
|
|
78
|
+
if "\n" in msg or msg.startswith(" -"):
|
|
79
|
+
print(f"Error:\n{msg}", file=sys.stderr)
|
|
80
|
+
else:
|
|
81
|
+
print(f"Error: {msg}", file=sys.stderr)
|
|
82
|
+
raise SystemExit(code)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _format_validation_errors(errors: Any) -> str:
|
|
86
|
+
if not errors:
|
|
87
|
+
return ""
|
|
88
|
+
if isinstance(errors, list):
|
|
89
|
+
lines: list[str] = []
|
|
90
|
+
for item in errors:
|
|
91
|
+
if isinstance(item, dict):
|
|
92
|
+
loc = item.get("loc") or item.get("field")
|
|
93
|
+
msg = item.get("msg") or item.get("message") or item.get("error")
|
|
94
|
+
if loc and msg:
|
|
95
|
+
if isinstance(loc, list | tuple):
|
|
96
|
+
loc = _format_validation_location(loc)
|
|
97
|
+
lines.append(f" - {loc}: {msg}")
|
|
98
|
+
elif msg:
|
|
99
|
+
lines.append(f" - {msg}")
|
|
100
|
+
else:
|
|
101
|
+
lines.append(f" - {item}")
|
|
102
|
+
elif isinstance(item, str) and ": " in item:
|
|
103
|
+
field, _, message = item.partition(": ")
|
|
104
|
+
lines.append(f" - {field}: {message}")
|
|
105
|
+
else:
|
|
106
|
+
lines.append(f" - {item}")
|
|
107
|
+
return "\n".join(lines)
|
|
108
|
+
return f" - {errors}"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _format_validation_location(loc: tuple[Any, ...] | list[Any]) -> str:
|
|
112
|
+
parts = [str(part) for part in loc]
|
|
113
|
+
if not parts:
|
|
114
|
+
return ""
|
|
115
|
+
if parts[0] in {"body", "query", "path", "header", "form", "cookie"}:
|
|
116
|
+
source, *path = parts
|
|
117
|
+
if not path:
|
|
118
|
+
return source
|
|
119
|
+
field = _format_validation_field_path(path)
|
|
120
|
+
return field if source == "body" else f"{source}.{field}"
|
|
121
|
+
return _format_validation_field_path(parts)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _format_validation_field_path(parts: list[str]) -> str:
|
|
125
|
+
result = ""
|
|
126
|
+
for part in parts:
|
|
127
|
+
if part.isdigit():
|
|
128
|
+
result += f"[{part}]"
|
|
129
|
+
elif result:
|
|
130
|
+
result += f".{part}"
|
|
131
|
+
else:
|
|
132
|
+
result = part
|
|
133
|
+
return result
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def sdk_command(fn: Callable[P, R]) -> Callable[P, R]:
|
|
137
|
+
"""Decorator that wraps a click command body to catch SDK errors."""
|
|
138
|
+
|
|
139
|
+
@functools.wraps(fn)
|
|
140
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
141
|
+
try:
|
|
142
|
+
return fn(*args, **kwargs)
|
|
143
|
+
except KeyboardInterrupt:
|
|
144
|
+
raise SystemExit(_exit.INTERRUPTED)
|
|
145
|
+
except SystemExit:
|
|
146
|
+
raise
|
|
147
|
+
except Exception as exc:
|
|
148
|
+
handle_sdk_error(exc)
|
|
149
|
+
raise AssertionError("handle_sdk_error always exits") from exc
|
|
150
|
+
|
|
151
|
+
return wrapper
|
ocmo_cli/_exit.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Exit code constants — public contract, covered by tests."""
|
|
2
|
+
|
|
3
|
+
SUCCESS = 0
|
|
4
|
+
FAILURE = 1
|
|
5
|
+
USAGE_ERROR = 2
|
|
6
|
+
NOT_FOUND = 3
|
|
7
|
+
AUTH_ERROR = 4
|
|
8
|
+
CONFLICT = 5
|
|
9
|
+
LOCKED = 6
|
|
10
|
+
VALIDATION_ERROR = 7
|
|
11
|
+
HOOK_FAILURE = 8
|
|
12
|
+
VERIFY_FAILURE = 9
|
|
13
|
+
INTERRUPTED = 130
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Export metadata comments and extended attributes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ._item_output import item_metadata_rows
|
|
9
|
+
|
|
10
|
+
_XATTR_PREFIX = "user.ocmo."
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def export_metadata_rows(raw_item: Any, *, namespace: str) -> list[tuple[str, Any]]:
|
|
14
|
+
"""Metadata key/value pairs to embed in exports."""
|
|
15
|
+
rows = item_metadata_rows(raw_item)
|
|
16
|
+
if namespace:
|
|
17
|
+
rows = [("namespace", namespace), *rows]
|
|
18
|
+
return rows
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def metadata_file_prefix(rows: list[tuple[str, Any]], *, node_type: str) -> str:
|
|
22
|
+
"""Return a comment block to prepend to exported file content."""
|
|
23
|
+
if not rows or node_type == "secret":
|
|
24
|
+
return ""
|
|
25
|
+
|
|
26
|
+
lines: list[str] = []
|
|
27
|
+
for key, value in rows:
|
|
28
|
+
text = f"{key}: {value}"
|
|
29
|
+
if node_type == "template":
|
|
30
|
+
lines.append(f"{{# {text} #}}")
|
|
31
|
+
else:
|
|
32
|
+
lines.append(f"# {text}")
|
|
33
|
+
|
|
34
|
+
return "\n".join(lines) + "\n"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def write_export_xattrs(path: os.PathLike[str] | str, rows: list[tuple[str, Any]]) -> None:
|
|
38
|
+
"""Best-effort write of export metadata to ``user.ocmo.*`` xattrs."""
|
|
39
|
+
if not rows or not hasattr(os, "setxattr"):
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
for key, value in rows:
|
|
43
|
+
attr = f"{_XATTR_PREFIX}{key}"
|
|
44
|
+
try:
|
|
45
|
+
os.setxattr(path, attr, str(value).encode("utf-8"))
|
|
46
|
+
except OSError:
|
|
47
|
+
continue
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Lightweight registry for generated CLI action groups.
|
|
2
|
+
|
|
3
|
+
Keeps action names and help text available without importing generated.py.
|
|
4
|
+
Help strings here must stay import-light (no generated.py or SDK).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
_GENERATED_ACTION_HELP: dict[str, str] = {
|
|
10
|
+
"get": "Retrieve API resources.",
|
|
11
|
+
"create": "Create new resources.",
|
|
12
|
+
"update": "Update existing resources.",
|
|
13
|
+
"delete": "Delete resources.",
|
|
14
|
+
"tag": "Add a version tag to an item.",
|
|
15
|
+
"untag": "Remove a version tag from an item.",
|
|
16
|
+
"rotate": "Rotate resolver access tokens.",
|
|
17
|
+
"propagate": "Propagate an item to descendant paths.",
|
|
18
|
+
"search": "Search the item tree.",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def generated_action_names() -> list[str]:
|
|
23
|
+
"""Return sorted action names for generated command groups."""
|
|
24
|
+
from ocmo_cli._commands_map import OPERATIONS
|
|
25
|
+
|
|
26
|
+
actions: set[str] = set()
|
|
27
|
+
has_set_tag = False
|
|
28
|
+
for config in OPERATIONS.values():
|
|
29
|
+
if not isinstance(config, dict):
|
|
30
|
+
continue
|
|
31
|
+
if config.get("hand_written") or config.get("skip"):
|
|
32
|
+
continue
|
|
33
|
+
action = config.get("action")
|
|
34
|
+
if not action or action == "resolve":
|
|
35
|
+
continue
|
|
36
|
+
actions.add(action)
|
|
37
|
+
if action == "tag":
|
|
38
|
+
has_set_tag = True
|
|
39
|
+
if has_set_tag:
|
|
40
|
+
actions.add("untag")
|
|
41
|
+
return sorted(actions)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def action_help(action: str) -> str:
|
|
45
|
+
return _GENERATED_ACTION_HELP.get(action, f"{action.capitalize()} resources.")
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""List/show helpers for ``ocmo get cast``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
GET_CAST_LIST_OUTPUT_KEY = "get cast list"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CastFormatNotFoundError(ValueError):
|
|
11
|
+
"""Raised when a requested cast format name is not available."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def is_get_cast_list_mode(*, action: str, resource: str, address: str | None) -> bool:
|
|
15
|
+
return action == "get" and resource == "cast" and not address
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def is_get_cast_show_mode(*, action: str, resource: str, address: str | None) -> bool:
|
|
19
|
+
return action == "get" and resource == "cast" and bool(address)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _formats_from_payload(data: Any) -> list[dict[str, Any]]:
|
|
23
|
+
if isinstance(data, list):
|
|
24
|
+
return [row for row in data if isinstance(row, dict)]
|
|
25
|
+
if isinstance(data, dict):
|
|
26
|
+
formats = data.get("formats")
|
|
27
|
+
if isinstance(formats, list):
|
|
28
|
+
return [row for row in formats if isinstance(row, dict)]
|
|
29
|
+
return []
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def cast_list_rows(data: Any) -> list[dict[str, str]]:
|
|
33
|
+
"""Return one row per cast format name for list output."""
|
|
34
|
+
rows: list[dict[str, str]] = []
|
|
35
|
+
for entry in _formats_from_payload(data):
|
|
36
|
+
name = entry.get("format")
|
|
37
|
+
if isinstance(name, str) and name:
|
|
38
|
+
rows.append({"format": name})
|
|
39
|
+
return rows
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def cast_show_payload(data: Any, format_name: str) -> dict[str, Any]:
|
|
43
|
+
"""Return one cast format entry (format + options_schema) for show output."""
|
|
44
|
+
target = format_name.casefold()
|
|
45
|
+
for entry in _formats_from_payload(data):
|
|
46
|
+
name = entry.get("format")
|
|
47
|
+
if isinstance(name, str) and name.casefold() == target:
|
|
48
|
+
return {
|
|
49
|
+
"format": name,
|
|
50
|
+
"options_schema": entry.get("options_schema") or {},
|
|
51
|
+
}
|
|
52
|
+
raise CastFormatNotFoundError(f"Unknown cast format {format_name!r}.")
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""List-mode helpers for ``ocmo get item`` (namespace-wide item listing)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
ITEM_NODE_TYPES: tuple[str, ...] = ("config", "template", "secret", "resolver")
|
|
8
|
+
GET_ITEM_TYPE_CHOICES: tuple[str, ...] = ITEM_NODE_TYPES
|
|
9
|
+
GET_ITEM_LIST_OUTPUT_KEY = "get item list"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def is_get_item_list_mode(*, action: str, resource: str, address: str | None) -> bool:
|
|
13
|
+
return action == "get" and resource == "item" and not address
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def prepare_get_item_list_extra(
|
|
17
|
+
extra: dict[str, Any],
|
|
18
|
+
*,
|
|
19
|
+
item_types: tuple[str, ...],
|
|
20
|
+
) -> dict[str, Any]:
|
|
21
|
+
merged = dict(extra)
|
|
22
|
+
merged["types"] = list(item_types) if item_types else list(ITEM_NODE_TYPES)
|
|
23
|
+
return merged
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Output helpers for global permission rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ._output import as_dict, sanitize_for_output
|
|
8
|
+
|
|
9
|
+
_PERMISSION_SECTIONS = ("read", "write", "delete", "audit")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _format_actor_claims(claims: dict[str, Any]) -> str:
|
|
13
|
+
"""Format one actor block: claim constraints are combined with AND."""
|
|
14
|
+
parts = [f"{key}={claims[key]}" for key in sorted(claims) if claims[key] is not None]
|
|
15
|
+
if not parts:
|
|
16
|
+
return ""
|
|
17
|
+
if len(parts) == 1:
|
|
18
|
+
return parts[0]
|
|
19
|
+
return " AND ".join(parts)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def format_section_claims(section: Any) -> str:
|
|
23
|
+
"""Format one permission section with AND inside actors and OR between actors."""
|
|
24
|
+
if section is None:
|
|
25
|
+
return ""
|
|
26
|
+
payload = as_dict(section, fallback_vars=False)
|
|
27
|
+
if not isinstance(payload, dict):
|
|
28
|
+
return ""
|
|
29
|
+
actors = payload.get("actors")
|
|
30
|
+
if not isinstance(actors, list):
|
|
31
|
+
return ""
|
|
32
|
+
|
|
33
|
+
blocks: list[str] = []
|
|
34
|
+
for actor in actors:
|
|
35
|
+
if not isinstance(actor, dict):
|
|
36
|
+
continue
|
|
37
|
+
claims = actor.get("claims")
|
|
38
|
+
if not isinstance(claims, dict):
|
|
39
|
+
continue
|
|
40
|
+
block = _format_actor_claims(claims)
|
|
41
|
+
if block:
|
|
42
|
+
blocks.append(block)
|
|
43
|
+
|
|
44
|
+
if not blocks:
|
|
45
|
+
return ""
|
|
46
|
+
if len(blocks) == 1:
|
|
47
|
+
return blocks[0]
|
|
48
|
+
|
|
49
|
+
formatted_blocks: list[str] = []
|
|
50
|
+
for block in blocks:
|
|
51
|
+
if " AND " in block:
|
|
52
|
+
formatted_blocks.append(f"({block})")
|
|
53
|
+
else:
|
|
54
|
+
formatted_blocks.append(block)
|
|
55
|
+
return " OR ".join(formatted_blocks)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def global_permission_table_row(row: dict[str, Any], *, wide: bool = False) -> dict[str, Any]:
|
|
59
|
+
"""Flatten rule payload fields used by ``ocmo get gp`` output."""
|
|
60
|
+
rule = as_dict(row.get("rule"), fallback_vars=False)
|
|
61
|
+
flat = dict(row)
|
|
62
|
+
flat["namespace"] = rule.get("namespace", "")
|
|
63
|
+
rule_id = rule.get("id")
|
|
64
|
+
if rule_id not in (None, ""):
|
|
65
|
+
flat["id"] = str(rule_id)
|
|
66
|
+
elif flat.get("id") is not None:
|
|
67
|
+
flat["id"] = str(flat["id"])
|
|
68
|
+
if wide:
|
|
69
|
+
for section in _PERMISSION_SECTIONS:
|
|
70
|
+
flat[section] = format_section_claims(rule.get(section))
|
|
71
|
+
flat.pop("rule", None)
|
|
72
|
+
return flat
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def global_permission_table_rows(
|
|
76
|
+
rows: list[dict[str, Any]],
|
|
77
|
+
*,
|
|
78
|
+
wide: bool = False,
|
|
79
|
+
) -> list[dict[str, Any]]:
|
|
80
|
+
return [global_permission_table_row(row, wide=wide) for row in rows]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def emit_get_globalpermission_output(
|
|
84
|
+
data: Any,
|
|
85
|
+
*,
|
|
86
|
+
output_fmt: str | None = None,
|
|
87
|
+
ctx_fmt: str | None = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Emit one rule like ``ocmo get globalpermission ADDRESS``."""
|
|
90
|
+
from ._output_manifest import emit_command_output
|
|
91
|
+
|
|
92
|
+
payload = sanitize_for_output(as_dict(data, fallback_vars=False) or data)
|
|
93
|
+
emit_command_output(
|
|
94
|
+
"get globalpermission",
|
|
95
|
+
payload,
|
|
96
|
+
output_fmt,
|
|
97
|
+
ctx_fmt=ctx_fmt,
|
|
98
|
+
)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Read OCMO export metadata from files (xattrs and comment headers)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ._export_metadata import _XATTR_PREFIX
|
|
10
|
+
|
|
11
|
+
_YAML_META_RE = re.compile(r"^#\s*([^:]+):\s*(.*)$")
|
|
12
|
+
_JINJA_META_RE = re.compile(r"^\{#\s*([^:]+):\s*(.*?)\s*#\}$")
|
|
13
|
+
|
|
14
|
+
# Keys written by ``ocmo export --metadata`` (see ``item_metadata_rows``).
|
|
15
|
+
EXPORT_METADATA_KEYS = frozenset(
|
|
16
|
+
{
|
|
17
|
+
"namespace",
|
|
18
|
+
"path",
|
|
19
|
+
"name",
|
|
20
|
+
"node_type",
|
|
21
|
+
"author",
|
|
22
|
+
"version",
|
|
23
|
+
"updater",
|
|
24
|
+
"updated_at",
|
|
25
|
+
"description",
|
|
26
|
+
"deleted_at",
|
|
27
|
+
"deleted_by",
|
|
28
|
+
}
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def read_file_metadata(path: Path) -> dict[str, str]:
|
|
33
|
+
"""Read ``user.ocmo.*`` xattrs and leading comment metadata from *path*."""
|
|
34
|
+
meta = _read_xattr_metadata(path)
|
|
35
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
36
|
+
for key, value in _parse_leading_comment_metadata(text).items():
|
|
37
|
+
meta.setdefault(key, value)
|
|
38
|
+
return meta
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def import_file_text(content_bytes: bytes) -> str:
|
|
42
|
+
"""Decode file bytes and strip leading export metadata comments."""
|
|
43
|
+
text = content_bytes.decode("utf-8", errors="replace")
|
|
44
|
+
return strip_export_metadata_comments(text)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def strip_export_metadata_comments(content: str) -> str:
|
|
48
|
+
"""Remove leading export metadata comment lines from *content*."""
|
|
49
|
+
if not content:
|
|
50
|
+
return content
|
|
51
|
+
|
|
52
|
+
lines = content.splitlines(keepends=True)
|
|
53
|
+
index = 0
|
|
54
|
+
while index < len(lines):
|
|
55
|
+
line = lines[index].rstrip("\n")
|
|
56
|
+
if _is_export_metadata_comment_line(line):
|
|
57
|
+
index += 1
|
|
58
|
+
continue
|
|
59
|
+
break
|
|
60
|
+
return "".join(lines[index:])
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def strip_leading_metadata_comments(content: str, *, node_type: str) -> str:
|
|
64
|
+
"""Backward-compatible alias for :func:`strip_export_metadata_comments`."""
|
|
65
|
+
return strip_export_metadata_comments(content)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def metadata_tree_path(metadata_path: str, target_prefix: str) -> str:
|
|
69
|
+
"""Join optional ``--to`` prefix with the path from metadata."""
|
|
70
|
+
item_path = metadata_path.strip("/")
|
|
71
|
+
prefix = target_prefix.strip("/")
|
|
72
|
+
if prefix and item_path:
|
|
73
|
+
return f"{prefix}/{item_path}"
|
|
74
|
+
if prefix:
|
|
75
|
+
return prefix
|
|
76
|
+
return item_path
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _read_xattr_metadata(path: Path) -> dict[str, str]:
|
|
80
|
+
if not hasattr(os, "listxattr"):
|
|
81
|
+
return {}
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
names = os.listxattr(path)
|
|
85
|
+
except OSError:
|
|
86
|
+
return {}
|
|
87
|
+
|
|
88
|
+
meta: dict[str, str] = {}
|
|
89
|
+
for name in names:
|
|
90
|
+
if not isinstance(name, str) or not name.startswith(_XATTR_PREFIX):
|
|
91
|
+
continue
|
|
92
|
+
key = name[len(_XATTR_PREFIX) :]
|
|
93
|
+
try:
|
|
94
|
+
value = os.getxattr(path, name)
|
|
95
|
+
except OSError:
|
|
96
|
+
continue
|
|
97
|
+
if isinstance(value, bytes):
|
|
98
|
+
meta[key] = value.decode("utf-8", errors="replace")
|
|
99
|
+
else:
|
|
100
|
+
meta[key] = str(value)
|
|
101
|
+
return meta
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _parse_metadata_comment_key(line: str) -> str | None:
|
|
105
|
+
stripped = line.strip()
|
|
106
|
+
if not stripped:
|
|
107
|
+
return None
|
|
108
|
+
parsed_yaml = _YAML_META_RE.match(stripped)
|
|
109
|
+
if parsed_yaml:
|
|
110
|
+
return parsed_yaml.group(1).strip()
|
|
111
|
+
parsed_jinja = _JINJA_META_RE.match(stripped)
|
|
112
|
+
if parsed_jinja:
|
|
113
|
+
return parsed_jinja.group(1).strip()
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _is_export_metadata_comment_line(line: str) -> bool:
|
|
118
|
+
key = _parse_metadata_comment_key(line)
|
|
119
|
+
return key is not None and key in EXPORT_METADATA_KEYS
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _parse_leading_comment_metadata(text: str) -> dict[str, str]:
|
|
123
|
+
meta: dict[str, str] = {}
|
|
124
|
+
for line in text.splitlines():
|
|
125
|
+
stripped = line.strip()
|
|
126
|
+
if not stripped:
|
|
127
|
+
continue
|
|
128
|
+
key = _parse_metadata_comment_key(stripped)
|
|
129
|
+
if key is None or key not in EXPORT_METADATA_KEYS:
|
|
130
|
+
break
|
|
131
|
+
parsed_yaml = _YAML_META_RE.match(stripped)
|
|
132
|
+
if parsed_yaml:
|
|
133
|
+
meta[key] = parsed_yaml.group(2).strip()
|
|
134
|
+
continue
|
|
135
|
+
parsed_jinja = _JINJA_META_RE.match(stripped)
|
|
136
|
+
if parsed_jinja:
|
|
137
|
+
meta[key] = parsed_jinja.group(2).strip()
|
|
138
|
+
continue
|
|
139
|
+
break
|
|
140
|
+
return meta
|