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,108 @@
1
+ """Hand-written ``ocmo move globalpermission`` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from .._address import parse_simple_address_or_exit
8
+ from .._client import OcmoCtx
9
+ from .._dry_run import format_generated_dry_run
10
+ from .._errors import sdk_command
11
+ from .._globalpermission_output import emit_get_globalpermission_output
12
+ from .._mutating import run_mutating
13
+ from .._options import dry_run_option, yes_option
14
+
15
+ _MOVE_GP_HELP = """\
16
+ Reorder a global permission rule.
17
+
18
+ \b
19
+ Examples:
20
+ ocmo move globalpermission dev-read --position 1.5
21
+ ocmo move globalpermission dev-read --position 2 --yes
22
+ """
23
+
24
+
25
+ def _move_gp_confirm_message(rule_id: str, position: float) -> str:
26
+ return f"Move global permission rule {rule_id!r} to position {position}. Continue?"
27
+
28
+
29
+ def _run_move_globalpermission(
30
+ ctx: OcmoCtx,
31
+ *,
32
+ address: str,
33
+ position: float,
34
+ dry_run: bool,
35
+ yes: bool,
36
+ ) -> None:
37
+ rule_id, _version = parse_simple_address_or_exit(address)
38
+ sdk_kwargs = {"position": position}
39
+
40
+ def _action() -> None:
41
+ client = ctx.client()
42
+ client.move_global_permission(rule_id, position=position)
43
+ result = client.get_global_permission(rule_id)
44
+ emit_get_globalpermission_output(result, ctx_fmt=ctx.output)
45
+
46
+ run_mutating(
47
+ ctx,
48
+ dry_run=dry_run,
49
+ yes=yes,
50
+ plan_lines=format_generated_dry_run(
51
+ op_id="move_global_permission",
52
+ action="move",
53
+ resource="globalpermission",
54
+ path=rule_id,
55
+ version=None,
56
+ namespace=None,
57
+ args=[],
58
+ kwargs=sdk_kwargs,
59
+ client_scope=True,
60
+ ),
61
+ confirm_message=_move_gp_confirm_message(rule_id, position),
62
+ action=_action,
63
+ )
64
+
65
+
66
+ @click.command("globalpermission", help=_MOVE_GP_HELP)
67
+ @click.argument("address")
68
+ @click.option(
69
+ "--position",
70
+ required=True,
71
+ type=float,
72
+ help="New sort position for the rule (fractional values allowed).",
73
+ )
74
+ @yes_option()
75
+ @dry_run_option()
76
+ @click.pass_obj
77
+ @sdk_command
78
+ def move_globalpermission_cmd(
79
+ ctx: OcmoCtx,
80
+ address: str,
81
+ position: float,
82
+ dry_run: bool,
83
+ yes: bool,
84
+ ) -> None:
85
+ _run_move_globalpermission(
86
+ ctx,
87
+ address=address,
88
+ position=position,
89
+ dry_run=dry_run,
90
+ yes=yes,
91
+ )
92
+
93
+
94
+ def register_gp_move_command(root: click.Group) -> None:
95
+ """Register hand-written ``move globalpermission`` on the generated move group."""
96
+ from .._click_groups import attach_resource_command, ensure_resource_alias_group
97
+
98
+ move_group = ensure_resource_alias_group(
99
+ root,
100
+ "move",
101
+ help="Move items within the tree.",
102
+ )
103
+ attach_resource_command(
104
+ move_group,
105
+ move_globalpermission_cmd,
106
+ canonical="globalpermission",
107
+ aliases=["gp", "globalpermissions"],
108
+ )
@@ -0,0 +1,595 @@
1
+ """ocmo import — import a directory tree into OCMO.
2
+
3
+ Classification:
4
+ parseable YAML/JSON object → Config
5
+ everything else → Template
6
+
7
+ With ``--from-metadata``, each file's export metadata (xattrs or comment headers)
8
+ selects the target namespace path and item type.
9
+
10
+ Slug rule: NFC-normalise, replace forbidden chars with '-', trim '-', preserve case.
11
+ The .ocmoignore file (gitignore syntax) and --exclude control what is walked.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import fnmatch
17
+ import json
18
+ import os
19
+ import sys
20
+ from pathlib import Path
21
+ from typing import TYPE_CHECKING, Any
22
+
23
+ import click
24
+
25
+ from .._address import slug
26
+ from .._client import OcmoCtx
27
+ from .._errors import sdk_command
28
+ from .._exit import USAGE_ERROR, VERIFY_FAILURE
29
+ from .._import_metadata import import_file_text, read_file_metadata
30
+ from .._import_plan import (
31
+ annotate_plan_conflicts,
32
+ build_metadata_entry,
33
+ load_type_map,
34
+ parse_type_override,
35
+ resolve_import_type,
36
+ try_parse_config_document,
37
+ )
38
+ from .._options import dry_run_option, yes_option
39
+ from .._output import as_dict, confirm, emit_table, err, warn
40
+
41
+ if TYPE_CHECKING:
42
+ from ocmo import NamespaceView
43
+
44
+
45
+ @click.command("import")
46
+ @click.argument("source_dir")
47
+ @click.option("--to", "target_path", default=None, metavar="PATH", help="Target tree path prefix to import into.")
48
+ @click.option("-n", "--namespace", default=None)
49
+ @click.option(
50
+ "--from-metadata",
51
+ is_flag=True,
52
+ default=False,
53
+ help="Import items using export metadata (namespace/path) from files.",
54
+ )
55
+ @dry_run_option()
56
+ @click.option("--verify", is_flag=True, default=False, help="After import, resolve and compare against source.")
57
+ @click.option(
58
+ "--update",
59
+ is_flag=True,
60
+ default=False,
61
+ help="Update existing items (create a new version). Otherwise, conflicts fail.",
62
+ )
63
+ @click.option(
64
+ "--exclude", "excludes", multiple=True, metavar="GLOB", help="Glob patterns to exclude (may be repeated)."
65
+ )
66
+ @click.option(
67
+ "--type-override",
68
+ "type_override_specs",
69
+ multiple=True,
70
+ metavar="GLOB=TYPE",
71
+ help="Force item type for matching paths (config|template|secret|resolver).",
72
+ )
73
+ @click.option(
74
+ "--type-map",
75
+ "type_map_path",
76
+ default=None,
77
+ type=click.Path(exists=True),
78
+ help="YAML file mapping glob patterns to item types.",
79
+ )
80
+ @click.option(
81
+ "--on-conflict", type=click.Choice(["fail", "suffix"]), default="fail", help="How to handle slug collisions."
82
+ )
83
+ @click.option(
84
+ "--merge-metadata", is_flag=True, default=False, help="Allow merging into files that already have the metadata key."
85
+ )
86
+ @click.option("--follow-symlinks", is_flag=True, default=False)
87
+ @yes_option()
88
+ @click.pass_obj
89
+ @sdk_command
90
+ def import_cmd(
91
+ ctx: OcmoCtx,
92
+ source_dir: str,
93
+ target_path: str | None,
94
+ namespace: str | None,
95
+ from_metadata: bool,
96
+ dry_run: bool,
97
+ verify: bool,
98
+ update: bool,
99
+ excludes: tuple[str, ...],
100
+ type_override_specs: tuple[str, ...],
101
+ type_map_path: str | None,
102
+ on_conflict: str,
103
+ merge_metadata: bool,
104
+ follow_symlinks: bool,
105
+ yes: bool,
106
+ ) -> None:
107
+ """Import a directory tree into OCMO.
108
+
109
+ \b
110
+ Examples:
111
+ ocmo -n prod import ./configs/ --to app/ --dry-run
112
+ ocmo -n prod import ./configs/ --to app/
113
+ ocmo -n prod import ./backup/ --from-metadata --update
114
+ """
115
+ source = Path(source_dir).resolve()
116
+ if not source.is_dir():
117
+ err(f"Source directory not found: {source}")
118
+ raise SystemExit(1)
119
+
120
+ if not from_metadata and not target_path:
121
+ err("--to is required unless --from-metadata is set.")
122
+ raise SystemExit(USAGE_ERROR)
123
+
124
+ ns = ctx.require_namespace(namespace)
125
+ view = ctx.namespace_view(namespace)
126
+ metadata_key = _get_metadata_key(ctx)
127
+ ignore_patterns = _load_ocmoignore(source) + list(excludes)
128
+ type_map, type_overrides = _load_type_rules(type_map_path, type_override_specs)
129
+
130
+ plan = _build_plan(
131
+ source=source,
132
+ target_base=target_path or "",
133
+ ignore_patterns=ignore_patterns,
134
+ from_metadata=from_metadata,
135
+ follow_symlinks=follow_symlinks,
136
+ metadata_key=metadata_key,
137
+ merge_metadata=merge_metadata,
138
+ on_conflict=on_conflict,
139
+ namespace=ns,
140
+ type_map=type_map,
141
+ type_overrides=type_overrides,
142
+ )
143
+
144
+ if not plan:
145
+ warn("No files to import.")
146
+ return
147
+
148
+ conflicts = annotate_plan_conflicts(
149
+ plan,
150
+ namespace=ns,
151
+ update=update,
152
+ view=view,
153
+ )
154
+
155
+ if dry_run or ctx.dry_run:
156
+ from .._dry_run import emit_dry_run_plan, format_import_dry_run_header # deferred
157
+
158
+ emit_dry_run_plan(format_import_dry_run_header(len(plan)))
159
+ emit_table(
160
+ [
161
+ {
162
+ "source": str(entry["source_rel"]),
163
+ "type": entry["kind"],
164
+ "path": entry["tree_path"],
165
+ "action": entry.get("action", "create"),
166
+ "status": entry.get("status", "ok"),
167
+ }
168
+ for entry in plan
169
+ ],
170
+ columns=["source", "type", "path", "action", "status"],
171
+ )
172
+ if conflicts:
173
+ err("Import blocked by conflicts:")
174
+ for message in conflicts:
175
+ err(f" {message}")
176
+ raise SystemExit(USAGE_ERROR)
177
+ return
178
+
179
+ created = updated = skipped = failed = 0
180
+
181
+ for entry in plan:
182
+ if entry.get("status") == "conflict":
183
+ warn(f"Skipping {entry['source_rel']}: {entry.get('conflict')}")
184
+ skipped += 1
185
+ continue
186
+
187
+ kind = entry["kind"]
188
+ tree_path = entry["tree_path"]
189
+ content: str = entry["content"]
190
+
191
+ if not (yes or ctx.yes) and kind == "secret":
192
+ if not confirm(f"Import secret {tree_path!r}?", yes=False):
193
+ skipped += 1
194
+ continue
195
+
196
+ try:
197
+ if kind == "config":
198
+ _import_item(view, "config", tree_path, content, update=update)
199
+ elif kind == "template":
200
+ _import_item(view, "template", tree_path, content, update=update)
201
+ elif kind == "secret":
202
+ _import_item(view, "secret", tree_path, content, update=update)
203
+ elif kind == "resolver":
204
+ _import_item(view, "resolver", tree_path, content, update=update)
205
+ else:
206
+ warn(f"Skipping unsupported type {kind!r} at {tree_path!r}.")
207
+ skipped += 1
208
+ continue
209
+
210
+ created += 1
211
+ except Exception as exc:
212
+ msg = str(exc)
213
+ if "already exists" in msg.lower() or "conflict" in msg.lower():
214
+ if update:
215
+ updated += 1
216
+ else:
217
+ warn(f"Conflict at {tree_path!r}: {exc} (use --update to overwrite)")
218
+ skipped += 1
219
+ else:
220
+ warn(f"Failed {tree_path!r}: {exc}")
221
+ failed += 1
222
+
223
+ print(
224
+ f"Import complete: created={created}, updated={updated}, " f"skipped={skipped}, failed={failed}.",
225
+ file=sys.stderr,
226
+ )
227
+
228
+ if verify and not failed:
229
+ _run_verify(
230
+ view,
231
+ source,
232
+ target_path or "",
233
+ plan,
234
+ metadata_key=metadata_key,
235
+ )
236
+
237
+
238
+ # ---------------------------------------------------------------------------
239
+ # Plan building
240
+ # ---------------------------------------------------------------------------
241
+
242
+
243
+ def _build_plan(
244
+ *,
245
+ source: Path,
246
+ target_base: str,
247
+ ignore_patterns: list[str],
248
+ from_metadata: bool,
249
+ follow_symlinks: bool,
250
+ metadata_key: str,
251
+ merge_metadata: bool,
252
+ on_conflict: str,
253
+ namespace: str,
254
+ type_map: list[tuple[str, str]],
255
+ type_overrides: list[tuple[str, str]],
256
+ ) -> list[dict[str, Any]]:
257
+ plan: list[dict[str, Any]] = []
258
+ slug_set: set[str] = set()
259
+
260
+ for file_path in _walk(source, ignore_patterns=ignore_patterns, follow_symlinks=follow_symlinks):
261
+ rel = file_path.relative_to(source)
262
+ rel_str = str(rel)
263
+ content_bytes = file_path.read_bytes()
264
+
265
+ if from_metadata:
266
+ if not read_file_metadata(file_path).get("path"):
267
+ continue
268
+ entry = build_metadata_entry(
269
+ file_path=file_path,
270
+ rel=rel,
271
+ rel_str=rel_str,
272
+ target_prefix=target_base,
273
+ content_bytes=content_bytes,
274
+ type_map=type_map,
275
+ type_overrides=type_overrides,
276
+ )
277
+ if entry is None:
278
+ continue
279
+ plan.append(entry)
280
+ continue
281
+
282
+ kind = resolve_import_type(
283
+ rel_str,
284
+ type_map=type_map,
285
+ type_overrides=type_overrides,
286
+ metadata_type=None,
287
+ content_bytes=content_bytes,
288
+ )
289
+ tree_path = _build_tree_path(rel, target_base)
290
+
291
+ slug_key = tree_path.lower()
292
+ if slug_key in slug_set:
293
+ if on_conflict == "fail":
294
+ err(f"Slug collision: {rel_str!r} → {tree_path!r} already mapped.")
295
+ raise SystemExit(USAGE_ERROR)
296
+ tree_path = _add_suffix(tree_path)
297
+ slug_set.add(slug_key)
298
+
299
+ if kind == "config":
300
+ content, conflict = _prepare_config_content(
301
+ content_bytes=content_bytes,
302
+ rel_str=rel_str,
303
+ tree_path=tree_path,
304
+ metadata_key=metadata_key,
305
+ merge_metadata=merge_metadata,
306
+ )
307
+ entry = {
308
+ "kind": "config",
309
+ "source_rel": rel,
310
+ "source_abs": file_path,
311
+ "tree_path": tree_path,
312
+ "content": content,
313
+ }
314
+ if conflict:
315
+ entry["conflict_reason"] = conflict
316
+ plan.append(entry)
317
+ elif kind in ("secret", "resolver"):
318
+ plan.append(
319
+ {
320
+ "kind": kind,
321
+ "source_rel": rel,
322
+ "source_abs": file_path,
323
+ "tree_path": tree_path,
324
+ "content": import_file_text(content_bytes),
325
+ }
326
+ )
327
+ elif kind == "template":
328
+ plan.append(
329
+ {
330
+ "kind": "template",
331
+ "source_rel": rel,
332
+ "source_abs": file_path,
333
+ "tree_path": tree_path,
334
+ "content": import_file_text(content_bytes),
335
+ }
336
+ )
337
+
338
+ return plan
339
+
340
+
341
+ def _load_type_rules(
342
+ type_map_path: str | None,
343
+ type_override_specs: tuple[str, ...],
344
+ ) -> tuple[list[tuple[str, str]], list[tuple[str, str]]]:
345
+ type_map: list[tuple[str, str]] = []
346
+ if type_map_path:
347
+ try:
348
+ type_map = load_type_map(Path(type_map_path))
349
+ except ValueError as exc:
350
+ err(str(exc))
351
+ raise SystemExit(USAGE_ERROR)
352
+
353
+ type_overrides: list[tuple[str, str]] = []
354
+ for spec in type_override_specs:
355
+ try:
356
+ type_overrides.append(parse_type_override(spec))
357
+ except ValueError as exc:
358
+ err(str(exc))
359
+ raise SystemExit(USAGE_ERROR)
360
+
361
+ return type_map, type_overrides
362
+
363
+
364
+ def _build_tree_path(rel: Path, target_base: str) -> str:
365
+ base = target_base.rstrip("/")
366
+ parts = [slug(p) for p in rel.parts]
367
+ return base + "/" + "/".join(parts)
368
+
369
+
370
+ def _prepare_config_content(
371
+ *,
372
+ content_bytes: bytes,
373
+ rel_str: str,
374
+ tree_path: str,
375
+ metadata_key: str,
376
+ merge_metadata: bool,
377
+ ) -> tuple[str, str | None]:
378
+ import yaml # deferred
379
+
380
+ text = import_file_text(content_bytes)
381
+ doc, is_json = try_parse_config_document(text.encode("utf-8"))
382
+ if doc is None:
383
+ return text, None
384
+
385
+ if metadata_key in doc and not merge_metadata:
386
+ return "", (f"file already contains key {metadata_key!r} " "(use --merge-metadata to override)")
387
+
388
+ meta: dict[str, Any] = {}
389
+ slugged = tree_path.split("/")[-1]
390
+ if slugged != rel_str:
391
+ meta["name"] = rel_str
392
+ if is_json:
393
+ meta["cast"] = {"format": "json"}
394
+
395
+ if meta:
396
+ existing = doc.get(metadata_key, {})
397
+ if not isinstance(existing, dict):
398
+ existing = {}
399
+ doc[metadata_key] = {**existing, **meta}
400
+
401
+ if is_json:
402
+ return json.dumps(doc, indent=2), None
403
+ return yaml.safe_dump(doc, default_flow_style=False), None
404
+
405
+
406
+ def _add_suffix(path: str) -> str:
407
+ parts = path.rsplit("/", 1)
408
+ return parts[0] + "/" + parts[1] + "_1" if len(parts) == 2 else path + "_1"
409
+
410
+
411
+ # ---------------------------------------------------------------------------
412
+ # Import helpers
413
+ # ---------------------------------------------------------------------------
414
+
415
+
416
+ def _get_metadata_key(ctx: OcmoCtx) -> str:
417
+ try:
418
+ info = ctx.client().version() # type: ignore[no-untyped-call]
419
+ data = as_dict(info)
420
+ return data.get("config_metadata_key") or "_ocmo"
421
+ except Exception:
422
+ return "_ocmo"
423
+
424
+
425
+ def _import_item(
426
+ view: NamespaceView,
427
+ kind: str,
428
+ path: str,
429
+ content: str,
430
+ *,
431
+ update: bool,
432
+ ) -> None:
433
+ methods = {
434
+ "config": (view.update_config, view.create_config),
435
+ "template": (view.update_template, view.create_template),
436
+ "secret": (view.update_secret, view.create_secret),
437
+ "resolver": (view.update_resolver, view.create_resolver),
438
+ }
439
+ update_fn, create_fn = methods[kind]
440
+ try:
441
+ update_fn(path=path, content=content)
442
+ except Exception:
443
+ create_fn(path=path, content=content)
444
+
445
+
446
+ # ---------------------------------------------------------------------------
447
+ # Filesystem walk
448
+ # ---------------------------------------------------------------------------
449
+
450
+
451
+ def _walk(
452
+ root: Path,
453
+ *,
454
+ ignore_patterns: list[str],
455
+ follow_symlinks: bool,
456
+ ) -> list[Path]:
457
+ results: list[Path] = []
458
+ for dirpath, dirnames, filenames in os.walk(root, followlinks=follow_symlinks):
459
+ dp = Path(dirpath)
460
+ rel_dir = dp.relative_to(root)
461
+
462
+ dirnames[:] = [d for d in dirnames if d != ".git"]
463
+
464
+ for filename in sorted(filenames):
465
+ rel_file = rel_dir / filename
466
+ rel_str = str(rel_file)
467
+
468
+ full = dp / filename
469
+ if full.is_symlink() and not follow_symlinks:
470
+ warn(f"Skipping symlink: {rel_str}")
471
+ continue
472
+
473
+ if _is_ignored(rel_str, ignore_patterns):
474
+ continue
475
+
476
+ results.append(full)
477
+
478
+ return results
479
+
480
+
481
+ def _is_ignored(rel_str: str, patterns: list[str]) -> bool:
482
+ for pat in patterns:
483
+ if fnmatch.fnmatch(rel_str, pat) or fnmatch.fnmatch(Path(rel_str).name, pat):
484
+ return True
485
+ return False
486
+
487
+
488
+ def _load_ocmoignore(root: Path) -> list[str]:
489
+ ignore_file = root / ".ocmoignore"
490
+ if not ignore_file.exists():
491
+ return []
492
+ patterns: list[str] = []
493
+ for line in ignore_file.read_text().splitlines():
494
+ line = line.strip()
495
+ if line and not line.startswith("#"):
496
+ patterns.append(line)
497
+ return patterns
498
+
499
+
500
+ # ---------------------------------------------------------------------------
501
+ # Verification (§12.3)
502
+ # ---------------------------------------------------------------------------
503
+
504
+
505
+ def _run_verify(
506
+ view: NamespaceView,
507
+ source: Path,
508
+ target_path: str,
509
+ plan: list[dict[str, Any]],
510
+ *,
511
+ metadata_key: str,
512
+ ) -> None:
513
+ """Resolve the imported folder and compare against the source directory."""
514
+ import tempfile
515
+
516
+ from .._resolve_write import write_resolve_items
517
+
518
+ with tempfile.TemporaryDirectory() as tmp_dir:
519
+ tmp = Path(tmp_dir)
520
+
521
+ try:
522
+ result = view.resolve(target_path.rstrip("/") + "/")
523
+ items = list(result)
524
+ outcomes = write_resolve_items(
525
+ items, # type: ignore[arg-type] # SDK ResolvedItem satisfies ResolvedArtifact
526
+ output_dir=str(tmp),
527
+ rewrite=True,
528
+ )
529
+ except Exception as exc:
530
+ err(f"Verify: resolve failed: {exc}")
531
+ raise SystemExit(VERIFY_FAILURE)
532
+
533
+ write_failures = [o for o in outcomes if o.result == "failed"]
534
+ if write_failures:
535
+ err("Verify: failed to write resolved output:")
536
+ for outcome in write_failures:
537
+ detail = outcome.detail or "write failed"
538
+ err(f" {outcome.path}: {detail}")
539
+ raise SystemExit(VERIFY_FAILURE)
540
+
541
+ mismatches: list[str] = []
542
+ for entry in plan:
543
+ if entry["kind"] != "config":
544
+ continue
545
+ rel = _expected_verify_rel(entry, target_path)
546
+ src_text = import_file_text(entry["source_abs"].read_bytes())
547
+ resolved_file = tmp / rel
548
+ if not resolved_file.exists():
549
+ mismatches.append(f"MISSING: {rel}")
550
+ continue
551
+ try:
552
+ src_doc, _ = try_parse_config_document(src_text.encode("utf-8"))
553
+ res_doc, _ = try_parse_config_document(
554
+ resolved_file.read_bytes(),
555
+ )
556
+ if src_doc is None or res_doc is None:
557
+ if resolved_file.read_text(encoding="utf-8") != src_text:
558
+ mismatches.append(f"BYTE MISMATCH: {rel}")
559
+ continue
560
+ src_body = _config_document_body(src_doc, metadata_key)
561
+ res_body = _config_document_body(res_doc, metadata_key)
562
+ if src_body != res_body:
563
+ mismatches.append(f"SEMANTIC MISMATCH: {rel}")
564
+ except Exception as e:
565
+ mismatches.append(f"PARSE ERROR: {rel}: {e}")
566
+
567
+ if mismatches:
568
+ err("Verification failed:")
569
+ for m in mismatches:
570
+ err(f" {m}")
571
+ raise SystemExit(VERIFY_FAILURE)
572
+ else:
573
+ print("Verification passed.", file=sys.stderr)
574
+
575
+
576
+ def _expected_verify_rel(entry: dict[str, Any], target_path: str) -> str:
577
+ """Path of a resolved artifact relative to the verify output directory."""
578
+ tree_path = str(entry["tree_path"]).strip("/")
579
+ base = target_path.strip("/")
580
+ if base and (tree_path == base or tree_path.startswith(f"{base}/")):
581
+ if tree_path == base:
582
+ return Path(tree_path).name
583
+ return str(tree_path[len(base) + 1 :])
584
+ return str(entry["source_rel"])
585
+
586
+
587
+ def _config_document_body(
588
+ doc: dict[str, Any] | None,
589
+ metadata_key: str,
590
+ ) -> dict[str, Any] | None:
591
+ if doc is None:
592
+ return None
593
+ body = dict(doc)
594
+ body.pop(metadata_key, None)
595
+ return body