kanibako-cli 1.5.0.dev14__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 (85) hide show
  1. kanibako/__init__.py +3 -0
  2. kanibako/__main__.py +6 -0
  3. kanibako/auth_browser.py +296 -0
  4. kanibako/auth_parser.py +51 -0
  5. kanibako/browser_sidecar.py +183 -0
  6. kanibako/browser_state.py +103 -0
  7. kanibako/bun_sea.py +144 -0
  8. kanibako/cli.py +344 -0
  9. kanibako/commands/__init__.py +0 -0
  10. kanibako/commands/archive.py +228 -0
  11. kanibako/commands/box/__init__.py +22 -0
  12. kanibako/commands/box/_duplicate.py +395 -0
  13. kanibako/commands/box/_migrate.py +574 -0
  14. kanibako/commands/box/_parser.py +1178 -0
  15. kanibako/commands/clean.py +166 -0
  16. kanibako/commands/crab_cmd.py +480 -0
  17. kanibako/commands/diagnose.py +239 -0
  18. kanibako/commands/fork_cmd.py +51 -0
  19. kanibako/commands/helper_cmd.py +669 -0
  20. kanibako/commands/image.py +1300 -0
  21. kanibako/commands/install.py +152 -0
  22. kanibako/commands/refresh_credentials.py +67 -0
  23. kanibako/commands/restore.py +298 -0
  24. kanibako/commands/setup_cmd.py +89 -0
  25. kanibako/commands/start.py +1600 -0
  26. kanibako/commands/stop.py +116 -0
  27. kanibako/commands/system_cmd.py +224 -0
  28. kanibako/commands/upgrade.py +161 -0
  29. kanibako/commands/vault_cmd.py +199 -0
  30. kanibako/commands/workset_cmd.py +552 -0
  31. kanibako/config.py +514 -0
  32. kanibako/config_interface.py +573 -0
  33. kanibako/config_io.py +36 -0
  34. kanibako/container.py +607 -0
  35. kanibako/containerfiles.py +58 -0
  36. kanibako/containers/Containerfile.kanibako +99 -0
  37. kanibako/containers/Containerfile.template-android +55 -0
  38. kanibako/containers/Containerfile.template-dotnet +29 -0
  39. kanibako/containers/Containerfile.template-js +43 -0
  40. kanibako/containers/Containerfile.template-jvm +27 -0
  41. kanibako/containers/Containerfile.template-systems +46 -0
  42. kanibako/containers/__init__.py +0 -0
  43. kanibako/crabs.py +89 -0
  44. kanibako/errors.py +33 -0
  45. kanibako/freshness.py +67 -0
  46. kanibako/git.py +114 -0
  47. kanibako/helper_client.py +132 -0
  48. kanibako/helper_listener.py +538 -0
  49. kanibako/helpers.py +339 -0
  50. kanibako/hygiene.py +296 -0
  51. kanibako/image_sharing.py +133 -0
  52. kanibako/instructions.py +160 -0
  53. kanibako/log.py +31 -0
  54. kanibako/names.py +248 -0
  55. kanibako/paths.py +1483 -0
  56. kanibako/plugins/__init__.py +10 -0
  57. kanibako/registry.py +71 -0
  58. kanibako/rig_bundle.py +121 -0
  59. kanibako/rig_meta.py +92 -0
  60. kanibako/rig_registry.py +132 -0
  61. kanibako/rig_resolve.py +182 -0
  62. kanibako/rig_source.py +245 -0
  63. kanibako/scripts/__init__.py +0 -0
  64. kanibako/scripts/helper-init.sh +45 -0
  65. kanibako/scripts/kanibako-entry +12 -0
  66. kanibako/settings_resolve.py +312 -0
  67. kanibako/settings_seeds.py +154 -0
  68. kanibako/settings_shares.py +154 -0
  69. kanibako/shellenv.py +75 -0
  70. kanibako/snapshots.py +281 -0
  71. kanibako/targets/__init__.py +173 -0
  72. kanibako/targets/base.py +243 -0
  73. kanibako/targets/no_agent.py +58 -0
  74. kanibako/templates.py +60 -0
  75. kanibako/templates_image.py +224 -0
  76. kanibako/tweakcc.py +140 -0
  77. kanibako/tweakcc_cache.py +171 -0
  78. kanibako/utils.py +136 -0
  79. kanibako/workset.py +347 -0
  80. kanibako_cli-1.5.0.dev14.dist-info/METADATA +15 -0
  81. kanibako_cli-1.5.0.dev14.dist-info/RECORD +85 -0
  82. kanibako_cli-1.5.0.dev14.dist-info/WHEEL +5 -0
  83. kanibako_cli-1.5.0.dev14.dist-info/entry_points.txt +5 -0
  84. kanibako_cli-1.5.0.dev14.dist-info/licenses/LICENSE.md +594 -0
  85. kanibako_cli-1.5.0.dev14.dist-info/top_level.txt +1 -0
@@ -0,0 +1,573 @@
1
+ """Unified config interface engine for all management commands.
2
+
3
+ Provides a reusable config subsystem that box/workset/agent/system commands
4
+ share. Handles get, set, show, and reset operations with a consistent
5
+ syntax:
6
+
7
+ - ``key=value`` → set
8
+ - ``key`` → get (if key is known)
9
+ - no args → show all overrides
10
+ - ``--effective`` → show resolved values
11
+ - ``--reset key`` → remove override
12
+ - ``--reset --all`` → remove all overrides (with confirmation)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import sys
18
+ from dataclasses import fields
19
+ from enum import Enum
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from kanibako.config import (
24
+ _DEFAULTS,
25
+ load_merged_config,
26
+ load_project_overrides,
27
+ read_crab_settings,
28
+ unset_project_config_key,
29
+ write_project_config_key,
30
+ )
31
+ from kanibako.config_io import dump_doc, load_doc
32
+ from kanibako.errors import UserCancelled
33
+ from kanibako.shellenv import (
34
+ merge_env,
35
+ read_env_file,
36
+ set_env_var,
37
+ unset_env_var,
38
+ write_env_file,
39
+ )
40
+ from kanibako.utils import confirm_prompt
41
+
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Key registry
45
+ # ---------------------------------------------------------------------------
46
+
47
+ class ConfigLevel(Enum):
48
+ """Which scope a config operation targets."""
49
+
50
+ box = "box"
51
+ workset = "workset"
52
+ crab = "crab"
53
+ system = "system"
54
+
55
+
56
+ # Keys recognized by the unified config interface.
57
+ # This set drives the "known-key heuristic": if a positional arg matches one
58
+ # of these, it's treated as a GET request rather than a project name.
59
+ KNOWN_CONFIG_KEYS: frozenset[str] = frozenset({
60
+ # Start mode / agent flags
61
+ "start_mode",
62
+ "autonomous",
63
+ "model",
64
+ "persistence",
65
+ # Box
66
+ "box.image",
67
+ "box.crab",
68
+ "box.share_images",
69
+ # Auth / project
70
+ "group_auth",
71
+ "layout",
72
+ "mode",
73
+ # Vault
74
+ "vault.enabled",
75
+ "vault.ro",
76
+ "vault.rw",
77
+ # System-level path settings (resolver-backed system.path.* tier)
78
+ "system.path.data",
79
+ "system.path.boxes",
80
+ "system.path.crabs",
81
+ "system.path.comms",
82
+ "system.path.templates",
83
+ "system.path.ws_hints",
84
+ # Box-level path settings (flat KanibakoConfig.paths_* fields)
85
+ "paths.shell",
86
+ "paths.vault",
87
+ "paths.shared",
88
+ # Helpers
89
+ "allow_helpers",
90
+ })
91
+
92
+ # Prefixes for dynamic keys (env vars, resources, shared caches).
93
+ DYNAMIC_PREFIXES: tuple[str, ...] = ("env.", "resource.", "shared.")
94
+
95
+ # Map friendly short names to canonical flat config keys.
96
+ _KEY_ALIASES: dict[str, str] = {
97
+ "image": "box.image",
98
+ "crab": "box.crab",
99
+ }
100
+
101
+
102
+ def is_known_key(arg: str) -> bool:
103
+ """Return True if *arg* looks like a config key (not a project name)."""
104
+ if arg in KNOWN_CONFIG_KEYS or arg in _KEY_ALIASES:
105
+ return True
106
+ return any(arg.startswith(p) for p in DYNAMIC_PREFIXES)
107
+
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # Config action parsing
111
+ # ---------------------------------------------------------------------------
112
+
113
+ class ConfigAction(Enum):
114
+ """What the user wants to do with config."""
115
+
116
+ get = "get"
117
+ set = "set"
118
+ show = "show"
119
+ reset = "reset"
120
+
121
+
122
+ def parse_config_arg(arg: str | None) -> tuple[ConfigAction, str, str]:
123
+ """Parse a single positional config argument.
124
+
125
+ Returns ``(action, key, value)``.
126
+
127
+ - ``"key=value"`` → ``(set, key, value)``
128
+ - ``"key"`` → ``(get, key, "")``
129
+ - ``None`` → ``(show, "", "")``
130
+ """
131
+ if arg is None:
132
+ return (ConfigAction.show, "", "")
133
+ if "=" in arg:
134
+ key, _, value = arg.partition("=")
135
+ return (ConfigAction.set, key.strip(), value.strip())
136
+ return (ConfigAction.get, arg.strip(), "")
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Canonical key resolution
141
+ # ---------------------------------------------------------------------------
142
+
143
+ def _resolve_key(raw: str) -> str:
144
+ """Map a user-supplied key name to the canonical form.
145
+
146
+ Accepts aliases (``image`` → ``box.image``), dot-notation
147
+ (``vault.enabled``), or the raw flat key. Returns the key unchanged
148
+ if no alias exists.
149
+ """
150
+ if raw in _KEY_ALIASES:
151
+ return _KEY_ALIASES[raw]
152
+ return raw
153
+
154
+
155
+ def _is_env_key(key: str) -> bool:
156
+ return key.startswith("env.")
157
+
158
+
159
+ def _is_resource_key(key: str) -> bool:
160
+ return key.startswith("resource.")
161
+
162
+
163
+ def _is_shared_key(key: str) -> bool:
164
+ return key.startswith("shared.")
165
+
166
+
167
+ def _is_crab_setting(key: str) -> bool:
168
+ """Keys that belong in the crab section of project.yaml."""
169
+ return key in {"model", "start_mode", "autonomous"}
170
+
171
+
172
+ def _is_system_path_key(key: str) -> bool:
173
+ """Keys that belong in the nested ``[system.path]`` table (system-only)."""
174
+ return key.startswith("system.path.")
175
+
176
+
177
+ def _dot_to_flat(key: str) -> str:
178
+ """Convert ``vault.enabled`` to ``enable_vault``, etc."""
179
+ # For paths.* keys, convert to the flat KanibakoConfig field name.
180
+ if key.startswith("paths."):
181
+ return "paths_" + key[6:]
182
+ return key.replace(".", "_")
183
+
184
+
185
+ # ---------------------------------------------------------------------------
186
+ # Get / set / reset operations
187
+ # ---------------------------------------------------------------------------
188
+
189
+ def get_config_value(
190
+ key: str,
191
+ *,
192
+ global_config_path: Path,
193
+ project_toml: Path | None = None,
194
+ env_global: Path | None = None,
195
+ env_project: Path | None = None,
196
+ ) -> str | None:
197
+ """Read a single config value from the appropriate store.
198
+
199
+ Returns the resolved (merged) value as a string, or None if the key
200
+ is not set.
201
+ """
202
+ canonical = _resolve_key(key)
203
+
204
+ # env.* keys — read from env files
205
+ if _is_env_key(canonical):
206
+ env_name = canonical[4:] # strip "env."
207
+ merged = merge_env(env_global, env_project)
208
+ return merged.get(env_name)
209
+
210
+ # resource.* keys — read from resource_overrides in project.yaml
211
+ if _is_resource_key(canonical):
212
+ resource_name = canonical[9:] # strip "resource."
213
+ if project_toml and project_toml.exists():
214
+ data = load_doc(project_toml)
215
+ overrides = data.get("resource_overrides", {})
216
+ return str(overrides.get(resource_name, "")) or None
217
+ return None
218
+
219
+ # shared.* keys — read from [shared] in global config or project
220
+ if _is_shared_key(canonical):
221
+ cache_name = canonical[7:] # strip "shared."
222
+ cfg = load_merged_config(global_config_path, project_toml)
223
+ return cfg.shared_caches.get(cache_name)
224
+
225
+ # target settings (model, start_mode, autonomous)
226
+ if _is_crab_setting(canonical):
227
+ if project_toml and project_toml.exists():
228
+ settings = read_crab_settings(project_toml)
229
+ if canonical in settings:
230
+ return settings[canonical]
231
+ return None
232
+
233
+ # system.path.* keys — read the raw set-value from the global config's
234
+ # [system.path] table (system-only tier; not a merged-config field).
235
+ if _is_system_path_key(canonical):
236
+ cfg = load_merged_config(global_config_path, project_toml)
237
+ return cfg.system_paths.get(canonical)
238
+
239
+ # Regular config keys — use merged config
240
+ flat = _dot_to_flat(canonical)
241
+ cfg = load_merged_config(global_config_path, project_toml)
242
+ valid = {fld.name for fld in fields(cfg)}
243
+ if flat in valid:
244
+ val = getattr(cfg, flat)
245
+ if isinstance(val, bool):
246
+ return str(val).lower()
247
+ return str(val) if val else None
248
+ return None
249
+
250
+
251
+ def set_config_value(
252
+ key: str,
253
+ value: str,
254
+ *,
255
+ config_path: Path,
256
+ env_path: Path | None = None,
257
+ is_system: bool = False,
258
+ ) -> str:
259
+ """Write a config value to the appropriate store.
260
+
261
+ *config_path* is the project.yaml (for box/workset) or kanibako.yaml
262
+ (for system). Returns a human-readable confirmation message.
263
+ """
264
+ canonical = _resolve_key(key)
265
+
266
+ # env.* keys
267
+ if _is_env_key(canonical):
268
+ env_name = canonical[4:]
269
+ if env_path is None:
270
+ return f"Error: no env file path for key {canonical}"
271
+ try:
272
+ set_env_var(env_path, env_name, value)
273
+ except ValueError as e:
274
+ return f"Error: {e}"
275
+ return f"Set {env_name}={value}"
276
+
277
+ # resource.* keys — write to [resource_overrides]
278
+ if _is_resource_key(canonical):
279
+ resource_name = canonical[9:]
280
+ _write_toml_key(config_path, "resource_overrides", resource_name, value)
281
+ return f"Set resource.{resource_name}={value}"
282
+
283
+ # shared.* keys — write to [shared]
284
+ if _is_shared_key(canonical):
285
+ cache_name = canonical[7:]
286
+ _write_toml_key(config_path, "shared", cache_name, value)
287
+ return f"Set shared.{cache_name}={value}"
288
+
289
+ # target settings
290
+ if _is_crab_setting(canonical):
291
+ _write_toml_key(config_path, "crab", canonical, value)
292
+ return f"Set {canonical}={value}"
293
+
294
+ # system.path.* keys — write to the nested [system.path] table.
295
+ if _is_system_path_key(canonical):
296
+ leaf = canonical[len("system.path."):]
297
+ _write_nested_toml_key(config_path, ("system", "path"), leaf, value)
298
+ return f"Set {canonical}={value}"
299
+
300
+ # Regular config keys
301
+ flat = _dot_to_flat(canonical)
302
+ write_project_config_key(config_path, flat, value)
303
+ return f"Set {flat}={value}"
304
+
305
+
306
+ def reset_config_value(
307
+ key: str,
308
+ *,
309
+ config_path: Path,
310
+ env_path: Path | None = None,
311
+ ) -> str:
312
+ """Remove an override for a single key. Returns confirmation message."""
313
+ canonical = _resolve_key(key)
314
+
315
+ # env.* keys
316
+ if _is_env_key(canonical):
317
+ env_name = canonical[4:]
318
+ if env_path and unset_env_var(env_path, env_name):
319
+ return f"Unset env.{env_name}"
320
+ return f"No override for env.{env_name}"
321
+
322
+ # resource.* keys
323
+ if _is_resource_key(canonical):
324
+ resource_name = canonical[9:]
325
+ if _remove_toml_key(config_path, "resource_overrides", resource_name):
326
+ return f"Reset resource.{resource_name}"
327
+ return f"No override for resource.{resource_name}"
328
+
329
+ # shared.* keys
330
+ if _is_shared_key(canonical):
331
+ cache_name = canonical[7:]
332
+ if _remove_toml_key(config_path, "shared", cache_name):
333
+ return f"Reset shared.{cache_name}"
334
+ return f"No override for shared.{cache_name}"
335
+
336
+ # target settings
337
+ if _is_crab_setting(canonical):
338
+ if _remove_toml_key(config_path, "crab", canonical):
339
+ return f"Reset {canonical}"
340
+ return f"No override for {canonical}"
341
+
342
+ # system.path.* keys — remove from the nested [system.path] table.
343
+ if _is_system_path_key(canonical):
344
+ leaf = canonical[len("system.path."):]
345
+ if _remove_nested_toml_key(config_path, ("system", "path"), leaf):
346
+ return f"Reset {canonical}"
347
+ return f"No override for {canonical}"
348
+
349
+ # Regular config keys
350
+ flat = _dot_to_flat(canonical)
351
+ if unset_project_config_key(config_path, flat):
352
+ default_val = _DEFAULTS.get(flat, "(none)")
353
+ return f"Reset {flat} (reverts to default: {default_val})"
354
+ return f"No override for {flat}"
355
+
356
+
357
+ def reset_all(
358
+ *,
359
+ config_path: Path,
360
+ env_path: Path | None = None,
361
+ force: bool = False,
362
+ ) -> str:
363
+ """Remove all overrides at this config level. Confirms unless *force*."""
364
+ if not force:
365
+ try:
366
+ confirm_prompt("Remove all config overrides? Type 'yes' to proceed: ")
367
+ except UserCancelled:
368
+ return "Aborted."
369
+
370
+ count = 0
371
+
372
+ # Clear project-level config overrides
373
+ overrides = load_project_overrides(config_path)
374
+ for key in overrides:
375
+ unset_project_config_key(config_path, key)
376
+ count += 1
377
+
378
+ # Clear target settings
379
+ if config_path.exists():
380
+ data = load_doc(config_path)
381
+ if data.get("crab"):
382
+ for k in list(data["crab"]):
383
+ _remove_toml_key(config_path, "crab", k)
384
+ count += 1
385
+ if data.get("resource_overrides"):
386
+ for k in list(data["resource_overrides"]):
387
+ _remove_toml_key(config_path, "resource_overrides", k)
388
+ count += 1
389
+
390
+ # Clear env file
391
+ if env_path and env_path.is_file():
392
+ env = read_env_file(env_path)
393
+ if env:
394
+ count += len(env)
395
+ write_env_file(env_path, {})
396
+
397
+ return f"Reset {count} override(s)." if count else "No overrides to reset."
398
+
399
+
400
+ def show_config(
401
+ *,
402
+ global_config_path: Path,
403
+ config_path: Path | None = None,
404
+ env_global: Path | None = None,
405
+ env_project: Path | None = None,
406
+ effective: bool = False,
407
+ file: Any = None,
408
+ workset_path: Path | None = None,
409
+ crab_state: dict[str, str] | None = None,
410
+ env_resolved: dict[str, str] | None = None,
411
+ ) -> int:
412
+ """Display config values. Returns exit code.
413
+
414
+ - *effective=False*: show only overrides at this level.
415
+ - *effective=True*: show all resolved values including inherited defaults.
416
+ """
417
+ out = file or sys.stdout
418
+
419
+ if effective:
420
+ # Show all resolved values
421
+ cfg = load_merged_config(
422
+ global_config_path, config_path, workset_path=workset_path,
423
+ )
424
+ overrides = load_project_overrides(config_path) if config_path else {}
425
+ for fld in fields(cfg):
426
+ val = getattr(cfg, fld.name)
427
+ marker = " (override)" if fld.name in overrides else ""
428
+ print(f" {fld.name} = {val}{marker}", file=out)
429
+
430
+ # Crab settings. When a fully-resolved crab_state is supplied (box
431
+ # view), render it; mark only the keys actually set at the box level.
432
+ # Otherwise fall back to the project-level overrides (today's behavior).
433
+ if crab_state is not None:
434
+ proj_crab = (
435
+ read_crab_settings(config_path)
436
+ if config_path and config_path.exists()
437
+ else {}
438
+ )
439
+ if crab_state:
440
+ print("", file=out)
441
+ for k, v in sorted(crab_state.items()):
442
+ marker = " (override)" if k in proj_crab else ""
443
+ print(f" {k} = {v}{marker}", file=out)
444
+ elif config_path and config_path.exists():
445
+ settings = read_crab_settings(config_path)
446
+ if settings:
447
+ print("", file=out)
448
+ for k, v in sorted(settings.items()):
449
+ print(f" {k} = {v} (override)", file=out)
450
+
451
+ # Env vars. Prefer the fully-resolved env (box view) when supplied.
452
+ merged = (
453
+ env_resolved
454
+ if env_resolved is not None
455
+ else merge_env(env_global, env_project)
456
+ )
457
+ if merged:
458
+ print("", file=out)
459
+ for k in sorted(merged):
460
+ print(f" env.{k} = {merged[k]}", file=out)
461
+
462
+ else:
463
+ # Show only overrides
464
+ has_output = False
465
+
466
+ overrides = load_project_overrides(config_path) if config_path else {}
467
+ for k, v in sorted(overrides.items()):
468
+ print(f" {k} = {v}", file=out)
469
+ has_output = True
470
+
471
+ if config_path and config_path.exists():
472
+ settings = read_crab_settings(config_path)
473
+ for k, v in sorted(settings.items()):
474
+ print(f" {k} = {v}", file=out)
475
+ has_output = True
476
+
477
+ # Env vars (project-level only)
478
+ if env_project:
479
+ env = read_env_file(env_project)
480
+ for k in sorted(env):
481
+ print(f" env.{k} = {env[k]}", file=out)
482
+ has_output = True
483
+
484
+ if not has_output:
485
+ print(" (no overrides)", file=out)
486
+
487
+ return 0
488
+
489
+
490
+ # ---------------------------------------------------------------------------
491
+ # Config section helpers (load → mutate → dump as YAML)
492
+ # ---------------------------------------------------------------------------
493
+
494
+ def _write_toml_key(path: Path, section: str, key: str, value: str | bool) -> None:
495
+ """Write a key to a specific config section, preserving other content."""
496
+ data = load_doc(path)
497
+ sec = data.get(section)
498
+ if not isinstance(sec, dict):
499
+ sec = {}
500
+ data[section] = sec
501
+ sec[key] = value
502
+ dump_doc(path, data)
503
+
504
+
505
+ def _remove_toml_key(path: Path, section: str, key: str) -> bool:
506
+ """Remove a key from a specific config section. Returns True if found."""
507
+ if not path.exists():
508
+ return False
509
+
510
+ data = load_doc(path)
511
+ sec = data.get(section, {})
512
+ if not isinstance(sec, dict) or key not in sec:
513
+ return False
514
+
515
+ del sec[key]
516
+ if not sec:
517
+ del data[section]
518
+ dump_doc(path, data)
519
+ return True
520
+
521
+
522
+ def _write_nested_toml_key(
523
+ path: Path, sections: tuple[str, ...], key: str, value: str | bool,
524
+ ) -> None:
525
+ """Write *key* into a nested table (e.g. ``("system", "path")``).
526
+
527
+ Preserves other content; creates intermediate tables as needed.
528
+ """
529
+ data = load_doc(path)
530
+ node = data
531
+ for sec in sections:
532
+ child = node.get(sec)
533
+ if not isinstance(child, dict):
534
+ child = {}
535
+ node[sec] = child
536
+ node = child
537
+ node[key] = value
538
+ dump_doc(path, data)
539
+
540
+
541
+ def _remove_nested_toml_key(
542
+ path: Path, sections: tuple[str, ...], key: str,
543
+ ) -> bool:
544
+ """Remove *key* from a nested table. Returns True if found.
545
+
546
+ Prunes now-empty intermediate tables.
547
+ """
548
+ if not path.exists():
549
+ return False
550
+
551
+ data = load_doc(path)
552
+
553
+ # Walk to the innermost table, recording the chain for pruning.
554
+ chain: list[dict] = [data]
555
+ node = data
556
+ for sec in sections:
557
+ if sec not in node or not isinstance(node[sec], dict):
558
+ return False
559
+ node = node[sec]
560
+ chain.append(node)
561
+
562
+ if key not in node:
563
+ return False
564
+ del node[key]
565
+
566
+ # Prune empty tables bottom-up.
567
+ for i in range(len(sections) - 1, -1, -1):
568
+ if not chain[i + 1]:
569
+ del chain[i][sections[i]]
570
+ else:
571
+ break
572
+ dump_doc(path, data)
573
+ return True
kanibako/config_io.py ADDED
@@ -0,0 +1,36 @@
1
+ """Centralized load/dump for kanibako config documents (YAML).
2
+
3
+ All kanibako-owned config files (kanibako.yaml, project.yaml, config.yaml,
4
+ workset.yaml, names.yaml, spawn.yaml, general.yaml, crab configs) are
5
+ serialized as YAML through these two helpers. There is no hand-rolled
6
+ serializer. (pyproject.toml is Python packaging and is NOT handled here.)
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ import yaml
13
+
14
+
15
+ def load_doc(path: Path | None) -> dict:
16
+ """Load a config document → dict. Missing/empty/non-mapping → {}."""
17
+ if path is None or not path.exists():
18
+ return {}
19
+ text = path.read_text()
20
+ # Defensive: only parse real text. A non-str (e.g. a MagicMock from an
21
+ # under-mocked test path) fed to yaml.safe_load can balloon memory
22
+ # catastrophically — guard the host instead of trusting the input.
23
+ if not isinstance(text, str):
24
+ return {}
25
+ data = yaml.safe_load(text)
26
+ return data if isinstance(data, dict) else {}
27
+
28
+
29
+ def dump_doc(path: Path, data: dict) -> None:
30
+ """Serialize *data* to *path* as YAML (creates parent dirs)."""
31
+ path.parent.mkdir(parents=True, exist_ok=True)
32
+ path.write_text(
33
+ yaml.safe_dump(
34
+ data, sort_keys=False, default_flow_style=False, allow_unicode=True,
35
+ )
36
+ )