dna-cli 0.1.0__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.
dna_cli/source_cmd.py ADDED
@@ -0,0 +1,437 @@
1
+ """``dna source`` — declarative source replicas + introspection.
2
+
3
+ Operates on ``<repo>/.dna-replicas.yaml`` (auto-discovered via upward
4
+ walk from cwd, just like git locates ``.gitignore``). The CLI never
5
+ touches the kernel directly: it only edits the YAML file. The harness
6
+ reads the file at boot and registers the post_save/post_delete
7
+ subscribers (host platforms wire the replica engine at boot).
8
+
9
+ Subcommands::
10
+
11
+ dna source replica add <id> --replica fs://./examples \\
12
+ --scopes dna-development [--kinds Story,Feature]
13
+ dna source replica list
14
+ dna source replica show <id>
15
+ dna source replica drop <id>
16
+ dna source replica enable <id>
17
+ dna source replica disable <id>
18
+
19
+ Phase 1 only supports ``fs://`` / ``file://`` replica URLs. ``sqlite://``
20
+ and ``postgres://`` destinations are roadmapped (the engine already
21
+ accepts any WritableSourcePort; the CLI just gates the URL until the
22
+ async-init plumbing for non-FS schemes lands).
23
+ """
24
+ from __future__ import annotations
25
+
26
+ from pathlib import Path
27
+ from typing import Any
28
+ from urllib.parse import urlparse
29
+
30
+ import click
31
+
32
+ from dna_cli._ctx import fail, print_json, print_table
33
+
34
+ CONFIG_FILENAME = ".dna-replicas.yaml"
35
+
36
+
37
+ # ─── Config rw helpers ────────────────────────────────────────────────
38
+
39
+
40
+ def _find_config(start: Path | None = None) -> Path | None:
41
+ """Walk upward from cwd looking for ``.dna-replicas.yaml``."""
42
+ cur = (start or Path.cwd()).resolve()
43
+ for _ in range(20):
44
+ candidate = cur / CONFIG_FILENAME
45
+ if candidate.is_file():
46
+ return candidate
47
+ if cur.parent == cur:
48
+ return None
49
+ cur = cur.parent
50
+ return None
51
+
52
+
53
+ def _default_config_path() -> Path:
54
+ """Where ``add`` writes when no config exists yet — at cwd."""
55
+ return Path.cwd() / CONFIG_FILENAME
56
+
57
+
58
+ def _load_or_init(path: Path) -> dict[str, Any]:
59
+ import yaml
60
+ if not path.exists():
61
+ return {
62
+ "apiVersion": "dna.io/v1",
63
+ "kind": "ReplicaConfig",
64
+ "replicas": [],
65
+ }
66
+ raw = yaml.safe_load(path.read_text()) or {}
67
+ if not isinstance(raw, dict):
68
+ raise click.ClickException(f"{path}: top-level must be a mapping")
69
+ raw.setdefault("apiVersion", "dna.io/v1")
70
+ raw.setdefault("kind", "ReplicaConfig")
71
+ raw.setdefault("replicas", [])
72
+ if not isinstance(raw["replicas"], list):
73
+ raise click.ClickException(f"{path}: 'replicas' must be a list")
74
+ return raw
75
+
76
+
77
+ def _save(path: Path, cfg: dict[str, Any]) -> None:
78
+ import yaml
79
+ path.write_text(yaml.safe_dump(cfg, sort_keys=False, default_flow_style=False))
80
+
81
+
82
+ def _entry_index(cfg: dict[str, Any], replica_id: str) -> int:
83
+ for i, e in enumerate(cfg["replicas"]):
84
+ if isinstance(e, dict) and e.get("id") == replica_id:
85
+ return i
86
+ return -1
87
+
88
+
89
+ def _validate_url(url: str) -> None:
90
+ parsed = urlparse(url)
91
+ scheme = parsed.scheme or "file"
92
+ if scheme not in ("file", "fs"):
93
+ raise click.ClickException(
94
+ f"unsupported replica scheme '{scheme}://' — Phase 1 supports "
95
+ f"fs:// or file:// only. (sqlite:// / postgres:// are roadmapped.)"
96
+ )
97
+
98
+
99
+ def _resolve_url_to_path(url: str, cfg_dir: Path) -> Path:
100
+ parsed = urlparse(url)
101
+ raw = parsed.netloc + parsed.path if parsed.netloc else parsed.path
102
+ p = Path(raw)
103
+ return p if p.is_absolute() else (cfg_dir / p).resolve()
104
+
105
+
106
+ # ─── Click group ──────────────────────────────────────────────────────
107
+
108
+
109
+ @click.group(help="Source-level operations: declarative replicas, introspection.")
110
+ def source() -> None:
111
+ """Top-level ``dna source`` group."""
112
+
113
+
114
+ @source.command("diff")
115
+ @click.argument("other_url")
116
+ @click.option("--scope", required=True, help="Scope to compare.")
117
+ @click.option("--tenant", default=None, help="Tenant layer (default: base).")
118
+ @click.option(
119
+ "--authored-only", is_flag=True,
120
+ help="Skip runtime-generated Kinds (EvalRun, Narrative, …) — compare only "
121
+ "authored docs (the FS git source-of-truth class).",
122
+ )
123
+ @click.option("--json", "as_json", is_flag=True, help="Emit JSON.")
124
+ def diff(
125
+ other_url: str, scope: str, tenant: str | None,
126
+ authored_only: bool, as_json: bool,
127
+ ) -> None:
128
+ """s-sync-s4 — semantic diff of a scope between the CURRENT source
129
+ (DNA_SOURCE_URL) and OTHER_URL (e.g. file://./scopes or a postgres URL).
130
+
131
+ Compares Kind-aware content digests (not raw text), so formatting,
132
+ frontmatter re-serialization and volatile stamps never show as drift.
133
+ Reports added (in current, missing in other), removed (in other only),
134
+ and changed (digest drifted).
135
+ """
136
+ from dna_cli._ctx import dna_session
137
+ from dna.kernel import Kernel
138
+ from dna_cli._ctx import build_source_from_env
139
+
140
+ with dna_session(scope) as s:
141
+ kernel = s.kernel
142
+ # authored-only → drop docs whose Kind is a runtime artifact.
143
+ include = None
144
+ if authored_only:
145
+ artifact_kinds = {
146
+ kp.kind for kp in kernel._kinds.values()
147
+ if getattr(kp, "is_runtime_artifact", False)
148
+ }
149
+ include = lambda raw: raw.get("kind") not in artifact_kinds # noqa: E731
150
+
151
+ async def _run() -> dict:
152
+ current = await kernel.digest_manifest(
153
+ scope, tenant=tenant, include=include,
154
+ )
155
+ other_src = await build_source_from_env(
156
+ Kernel.auto(), _source_url=other_url,
157
+ )
158
+ other = await kernel.digest_manifest(
159
+ scope, tenant=tenant, include=include, source=other_src,
160
+ )
161
+ return Kernel.diff_manifests(current, other)
162
+
163
+ d = s.run(_run())
164
+
165
+ if as_json:
166
+ print_json({k: [f"{kind}/{name}" for kind, name in v] for k, v in d.items()})
167
+ return
168
+
169
+ total = sum(len(v) for v in d.values())
170
+ if total == 0:
171
+ click.secho(f"✔ in sync — {scope} matches {other_url} (0 diffs)", fg="green")
172
+ return
173
+ click.secho(
174
+ f"{scope}: {len(d['added'])} added · {len(d['changed'])} changed · "
175
+ f"{len(d['removed'])} removed (current ↔ {other_url})",
176
+ fg="yellow", bold=True,
177
+ )
178
+ for label, color in (("added", "green"), ("changed", "cyan"), ("removed", "red")):
179
+ for kind, name in d[label]:
180
+ click.secho(f" {label[0].upper()} {kind}/{name}", fg=color)
181
+
182
+
183
+ @source.command("push")
184
+ @click.argument("to_url")
185
+ @click.option("--scope", required=True, help="Scope to reconcile.")
186
+ @click.option("--tenant", default=None, help="Tenant layer (default: base).")
187
+ @click.option(
188
+ "--authored-only", is_flag=True,
189
+ help="Skip runtime-generated Kinds — push only authored docs.",
190
+ )
191
+ @click.option(
192
+ "--apply", "do_apply", is_flag=True,
193
+ help="Actually write to the target (default: dry-run preview only).",
194
+ )
195
+ @click.option(
196
+ "--prune", is_flag=True,
197
+ help="Delete docs that exist ONLY in the target (off by default).",
198
+ )
199
+ @click.option("--json", "as_json", is_flag=True, help="Emit JSON.")
200
+ def push(
201
+ to_url: str, scope: str, tenant: str | None,
202
+ authored_only: bool, do_apply: bool, prune: bool, as_json: bool,
203
+ ) -> None:
204
+ """s-sync-s5 — reconcile TO_URL to match the CURRENT source (DNA_SOURCE_URL,
205
+ the source-of-truth) for a scope. Writes added/changed docs atomically
206
+ (doc + bundle via the s-sync-s3 net). Dry-run by default — pass --apply to
207
+ write. --prune also removes docs that exist only in the target.
208
+ """
209
+ from dna_cli._ctx import dna_session
210
+ from dna.kernel import Kernel
211
+ from dna_cli._ctx import build_source_from_env
212
+
213
+ with dna_session(scope) as s:
214
+ kernel = s.kernel
215
+ include = None
216
+ if authored_only:
217
+ artifact_kinds = {
218
+ kp.kind for kp in kernel._kinds.values()
219
+ if getattr(kp, "is_runtime_artifact", False)
220
+ }
221
+ include = lambda raw: raw.get("kind") not in artifact_kinds # noqa: E731
222
+
223
+ async def _run() -> dict:
224
+ to_src = await build_source_from_env(
225
+ Kernel.auto(), _source_url=to_url,
226
+ )
227
+ return await kernel.push_scope(
228
+ scope, to_src, tenant=tenant, include=include,
229
+ dry_run=not do_apply, prune=prune,
230
+ )
231
+
232
+ out = s.run(_run())
233
+
234
+ if as_json:
235
+ print_json({
236
+ k: [f"{kind}/{name}" for kind, name in v] if k != "applied"
237
+ else [f"{op}:{kind}/{name}" for op, kind, name in v]
238
+ for k, v in out.items()
239
+ })
240
+ return
241
+
242
+ n_writes = len(out["added"]) + len(out["changed"])
243
+ verb = "would write" if not do_apply else "wrote"
244
+ if n_writes == 0 and (not prune or not out["removed"]):
245
+ click.secho(f"✔ {scope} already in sync with {to_url} (nothing to push)", fg="green")
246
+ return
247
+ head = "DRY-RUN" if not do_apply else "APPLIED"
248
+ click.secho(
249
+ f"[{head}] {scope} → {to_url}: {verb} {n_writes} "
250
+ f"({len(out['added'])} added, {len(out['changed'])} changed)"
251
+ + (f"; prune {len(out['removed'])} removed" if prune else ""),
252
+ fg="yellow", bold=True,
253
+ )
254
+ for kind, name in out["added"]:
255
+ click.secho(f" + {kind}/{name}", fg="green")
256
+ for kind, name in out["changed"]:
257
+ click.secho(f" ~ {kind}/{name}", fg="cyan")
258
+ if prune:
259
+ for kind, name in out["removed"]:
260
+ click.secho(f" - {kind}/{name}", fg="red")
261
+ if not do_apply:
262
+ click.secho(" (dry-run — re-run with --apply to write)", fg="white")
263
+
264
+
265
+ @source.group(help="Manage source replicas (.dna-replicas.yaml).")
266
+ def replica() -> None:
267
+ """Replica subgroup."""
268
+
269
+
270
+ # ─── add ──────────────────────────────────────────────────────────────
271
+
272
+
273
+ @replica.command("add")
274
+ @click.argument("replica_id")
275
+ @click.option("--replica", "replica_url", required=True,
276
+ help="Destination URL (fs://path or file://path).")
277
+ @click.option("--scopes", required=True,
278
+ help="Comma-separated scope allowlist (e.g. dna-development).")
279
+ @click.option("--kinds", default=None,
280
+ help="Comma-separated Kind allowlist. Omit = all kinds.")
281
+ @click.option("--config", "config_path", default=None, type=click.Path(),
282
+ help="Path to .dna-replicas.yaml (default: walk up from cwd, then cwd).")
283
+ def add(
284
+ replica_id: str,
285
+ replica_url: str,
286
+ scopes: str,
287
+ kinds: str | None,
288
+ config_path: str | None,
289
+ ) -> None:
290
+ """Add a new replica entry. Errors on duplicate id."""
291
+ _validate_url(replica_url)
292
+ scope_list = [s.strip() for s in scopes.split(",") if s.strip()]
293
+ if not scope_list:
294
+ raise click.ClickException("--scopes must contain at least one scope")
295
+ kind_list: list[str] | None
296
+ if kinds is None:
297
+ kind_list = None
298
+ else:
299
+ kind_list = [k.strip() for k in kinds.split(",") if k.strip()] or None
300
+
301
+ cfg_path = Path(config_path) if config_path else (_find_config() or _default_config_path())
302
+ cfg = _load_or_init(cfg_path)
303
+ if _entry_index(cfg, replica_id) >= 0:
304
+ raise click.ClickException(
305
+ f"replica id '{replica_id}' already exists in {cfg_path}; "
306
+ f"use 'drop' first or pick a different id"
307
+ )
308
+
309
+ # Sanity-check the resolved destination dir exists. This mirrors the
310
+ # harness check that warns + skips at boot — surfacing it earlier
311
+ # avoids declaring a replica that silently never fires.
312
+ dest = _resolve_url_to_path(replica_url, cfg_path.parent)
313
+ if not dest.exists():
314
+ click.echo(
315
+ f"warning: replica destination {dest} does not exist; "
316
+ f"create it before booting harness or it will be skipped",
317
+ err=True,
318
+ )
319
+
320
+ entry: dict[str, Any] = {
321
+ "id": replica_id,
322
+ "replica": replica_url,
323
+ "scopes": scope_list,
324
+ "kinds": kind_list,
325
+ "enabled": True,
326
+ }
327
+ cfg["replicas"].append(entry)
328
+ _save(cfg_path, cfg)
329
+ click.echo(f"ADDED replica/{replica_id} -> {cfg_path}")
330
+
331
+
332
+ # ─── list ─────────────────────────────────────────────────────────────
333
+
334
+
335
+ @replica.command("list")
336
+ @click.option("--config", "config_path", default=None, type=click.Path())
337
+ @click.option("--json", "as_json", is_flag=True)
338
+ def list_replicas(config_path: str | None, as_json: bool) -> None:
339
+ """List replicas declared in the config file."""
340
+ cfg_path = Path(config_path) if config_path else _find_config()
341
+ if cfg_path is None:
342
+ if as_json:
343
+ print_json([])
344
+ else:
345
+ click.echo("(no .dna-replicas.yaml found)")
346
+ return
347
+ cfg = _load_or_init(cfg_path)
348
+ rows = []
349
+ for e in cfg.get("replicas", []):
350
+ if not isinstance(e, dict):
351
+ continue
352
+ rows.append({
353
+ "id": e.get("id", ""),
354
+ "replica": e.get("replica", ""),
355
+ "scopes": ",".join(e.get("scopes", []) or []),
356
+ "kinds": "all" if not e.get("kinds") else ",".join(e.get("kinds") or []),
357
+ "enabled": "yes" if e.get("enabled", True) else "NO",
358
+ })
359
+ if as_json:
360
+ print_json(rows)
361
+ elif not rows:
362
+ click.echo(f"{cfg_path}: 0 replicas declared")
363
+ else:
364
+ print_table(rows, ["id", "replica", "scopes", "kinds", "enabled"])
365
+
366
+
367
+ # ─── show ─────────────────────────────────────────────────────────────
368
+
369
+
370
+ @replica.command("show")
371
+ @click.argument("replica_id")
372
+ @click.option("--config", "config_path", default=None, type=click.Path())
373
+ def show(replica_id: str, config_path: str | None) -> None:
374
+ """Show full entry for one replica id."""
375
+ cfg_path = Path(config_path) if config_path else _find_config()
376
+ if cfg_path is None:
377
+ raise fail("no .dna-replicas.yaml found in cwd or any parent")
378
+ cfg = _load_or_init(cfg_path)
379
+ idx = _entry_index(cfg, replica_id)
380
+ if idx < 0:
381
+ raise fail(f"replica id '{replica_id}' not found in {cfg_path}")
382
+ entry = cfg["replicas"][idx]
383
+ print_json({"config": str(cfg_path), "entry": entry})
384
+
385
+
386
+ # ─── drop ─────────────────────────────────────────────────────────────
387
+
388
+
389
+ @replica.command("drop")
390
+ @click.argument("replica_id")
391
+ @click.option("--config", "config_path", default=None, type=click.Path())
392
+ def drop(replica_id: str, config_path: str | None) -> None:
393
+ """Remove a replica entry."""
394
+ cfg_path = Path(config_path) if config_path else _find_config()
395
+ if cfg_path is None:
396
+ raise fail("no .dna-replicas.yaml found in cwd or any parent")
397
+ cfg = _load_or_init(cfg_path)
398
+ idx = _entry_index(cfg, replica_id)
399
+ if idx < 0:
400
+ raise fail(f"replica id '{replica_id}' not found in {cfg_path}")
401
+ cfg["replicas"].pop(idx)
402
+ _save(cfg_path, cfg)
403
+ click.echo(f"DROPPED replica/{replica_id} from {cfg_path}")
404
+
405
+
406
+ # ─── enable / disable ─────────────────────────────────────────────────
407
+
408
+
409
+ @replica.command("enable")
410
+ @click.argument("replica_id")
411
+ @click.option("--config", "config_path", default=None, type=click.Path())
412
+ def enable(replica_id: str, config_path: str | None) -> None:
413
+ """Set enabled=true for a replica."""
414
+ _toggle_enabled(replica_id, True, config_path)
415
+
416
+
417
+ @replica.command("disable")
418
+ @click.argument("replica_id")
419
+ @click.option("--config", "config_path", default=None, type=click.Path())
420
+ def disable(replica_id: str, config_path: str | None) -> None:
421
+ """Set enabled=false for a replica (soft-mute without losing config)."""
422
+ _toggle_enabled(replica_id, False, config_path)
423
+
424
+
425
+ def _toggle_enabled(replica_id: str, value: bool, config_path: str | None) -> None:
426
+ cfg_path = Path(config_path) if config_path else _find_config()
427
+ if cfg_path is None:
428
+ raise fail("no .dna-replicas.yaml found in cwd or any parent")
429
+ cfg = _load_or_init(cfg_path)
430
+ idx = _entry_index(cfg, replica_id)
431
+ if idx < 0:
432
+ raise fail(f"replica id '{replica_id}' not found in {cfg_path}")
433
+ cfg["replicas"][idx]["enabled"] = value
434
+ _save(cfg_path, cfg)
435
+ click.echo(
436
+ f"{'ENABLED' if value else 'DISABLED'} replica/{replica_id} in {cfg_path}"
437
+ )