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
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