deepsieve-cli 0.3.0__tar.gz → 0.4.0__tar.gz
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.
- {deepsieve_cli-0.3.0 → deepsieve_cli-0.4.0}/PKG-INFO +13 -1
- {deepsieve_cli-0.3.0 → deepsieve_cli-0.4.0}/README.md +12 -0
- {deepsieve_cli-0.3.0 → deepsieve_cli-0.4.0}/pyproject.toml +1 -1
- {deepsieve_cli-0.3.0 → deepsieve_cli-0.4.0}/src/deepsieve_cli/main.py +439 -2
- {deepsieve_cli-0.3.0 → deepsieve_cli-0.4.0}/tests/test_cli.py +385 -0
- {deepsieve_cli-0.3.0 → deepsieve_cli-0.4.0}/.gitignore +0 -0
- {deepsieve_cli-0.3.0 → deepsieve_cli-0.4.0}/src/deepsieve_cli/__init__.py +0 -0
- {deepsieve_cli-0.3.0 → deepsieve_cli-0.4.0}/src/deepsieve_cli/__main__.py +0 -0
- {deepsieve_cli-0.3.0 → deepsieve_cli-0.4.0}/src/deepsieve_cli/client.py +0 -0
- {deepsieve_cli-0.3.0 → deepsieve_cli-0.4.0}/src/deepsieve_cli/config.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: deepsieve-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: DeepSieve CLI — run cited deep research from your terminal
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Requires-Dist: httpx>=0.27
|
|
@@ -20,6 +20,10 @@ scoped API key. Nothing to copy, nothing pasted into your shell history. The
|
|
|
20
20
|
credential appears in **Settings → API keys** on your deployment and can be
|
|
21
21
|
revoked there at any time.
|
|
22
22
|
|
|
23
|
+
The key covers the CLI's own commands and cannot create a Blueprint. If an agent
|
|
24
|
+
will use it to create Blueprints, run `deepsieve login --preset agent`; the
|
|
25
|
+
approval screen lists the extra scopes.
|
|
26
|
+
|
|
23
27
|
## Commands
|
|
24
28
|
|
|
25
29
|
```bash
|
|
@@ -31,8 +35,16 @@ deepsieve runs list
|
|
|
31
35
|
deepsieve runs get <id> # exit 4 while still running
|
|
32
36
|
deepsieve runs cancel <id>
|
|
33
37
|
deepsieve members list # who is in the workspace + seat usage
|
|
38
|
+
deepsieve changes list --live # merges, renames, successor links
|
|
39
|
+
deepsieve changes undo <id> # asks first; --yes in scripts
|
|
40
|
+
deepsieve merge preview <entity> --survivor <id> --loser <id> # writes nothing
|
|
41
|
+
deepsieve merge apply <entity> --survivor <id> --loser <id> [--choice KEY=loser]
|
|
34
42
|
deepsieve data catalog # entities + columns (never guess)
|
|
35
43
|
deepsieve data get companies --receipts # rows with per-cell citations
|
|
44
|
+
deepsieve chat thread <run-id> # changes, with status + warnings
|
|
45
|
+
deepsieve chat confirm <action-id> # apply a change that waits for confirmation
|
|
46
|
+
deepsieve chat dismiss <action-id> # decline it; nothing is written
|
|
47
|
+
deepsieve chat revert <action-id> # undo a change a chat answer applied
|
|
36
48
|
deepsieve setup mcp # MCP registration for this origin
|
|
37
49
|
```
|
|
38
50
|
|
|
@@ -12,6 +12,10 @@ scoped API key. Nothing to copy, nothing pasted into your shell history. The
|
|
|
12
12
|
credential appears in **Settings → API keys** on your deployment and can be
|
|
13
13
|
revoked there at any time.
|
|
14
14
|
|
|
15
|
+
The key covers the CLI's own commands and cannot create a Blueprint. If an agent
|
|
16
|
+
will use it to create Blueprints, run `deepsieve login --preset agent`; the
|
|
17
|
+
approval screen lists the extra scopes.
|
|
18
|
+
|
|
15
19
|
## Commands
|
|
16
20
|
|
|
17
21
|
```bash
|
|
@@ -23,8 +27,16 @@ deepsieve runs list
|
|
|
23
27
|
deepsieve runs get <id> # exit 4 while still running
|
|
24
28
|
deepsieve runs cancel <id>
|
|
25
29
|
deepsieve members list # who is in the workspace + seat usage
|
|
30
|
+
deepsieve changes list --live # merges, renames, successor links
|
|
31
|
+
deepsieve changes undo <id> # asks first; --yes in scripts
|
|
32
|
+
deepsieve merge preview <entity> --survivor <id> --loser <id> # writes nothing
|
|
33
|
+
deepsieve merge apply <entity> --survivor <id> --loser <id> [--choice KEY=loser]
|
|
26
34
|
deepsieve data catalog # entities + columns (never guess)
|
|
27
35
|
deepsieve data get companies --receipts # rows with per-cell citations
|
|
36
|
+
deepsieve chat thread <run-id> # changes, with status + warnings
|
|
37
|
+
deepsieve chat confirm <action-id> # apply a change that waits for confirmation
|
|
38
|
+
deepsieve chat dismiss <action-id> # decline it; nothing is written
|
|
39
|
+
deepsieve chat revert <action-id> # undo a change a chat answer applied
|
|
28
40
|
deepsieve setup mcp # MCP registration for this origin
|
|
29
41
|
```
|
|
30
42
|
|
|
@@ -110,7 +110,7 @@ def cmd_login(args) -> int:
|
|
|
110
110
|
return EXIT_OK
|
|
111
111
|
|
|
112
112
|
try:
|
|
113
|
-
auth = client.start_device_authorization(CLIENT_NAME)
|
|
113
|
+
auth = client.start_device_authorization(CLIENT_NAME, preset=args.preset)
|
|
114
114
|
except ApiError as exc:
|
|
115
115
|
err(str(exc))
|
|
116
116
|
return EXIT_FAIL
|
|
@@ -172,6 +172,10 @@ def cmd_whoami(args) -> int:
|
|
|
172
172
|
print(f"profile {prof.name}")
|
|
173
173
|
print(f"origin {prof.origin}")
|
|
174
174
|
print(f"org {me.get('org_id')}")
|
|
175
|
+
# gh #1282: the Blueprint un-addressed calls act on, by name and by the id every
|
|
176
|
+
# `--blueprint` / `blueprint_id` accepts — not the internal schema name.
|
|
177
|
+
bp = me.get("blueprint") or {}
|
|
178
|
+
print(f"blueprint {bp.get('name')} ({bp.get('id')})" if bp else "blueprint (none)")
|
|
175
179
|
print(f"workspace {me.get('workspace_schema')}")
|
|
176
180
|
print(f"role {me.get('role')}")
|
|
177
181
|
print(f"scopes {', '.join(me.get('scopes') or []) or '(full)'}")
|
|
@@ -200,6 +204,8 @@ def cmd_runs_list(args) -> int:
|
|
|
200
204
|
params: dict[str, Any] = {"limit": args.limit}
|
|
201
205
|
if args.status:
|
|
202
206
|
params["status"] = args.status
|
|
207
|
+
if args.blueprint:
|
|
208
|
+
params["blueprint_id"] = args.blueprint
|
|
203
209
|
try:
|
|
204
210
|
body = client.request("GET", "/v1/research/runs", params=params)
|
|
205
211
|
except ApiError as exc:
|
|
@@ -213,6 +219,9 @@ def cmd_runs_list(args) -> int:
|
|
|
213
219
|
(r.get("title") or r.get("query") or "")[:48]]
|
|
214
220
|
for r in body.get("data", [])
|
|
215
221
|
]
|
|
222
|
+
bp = body.get("blueprint") or {}
|
|
223
|
+
if bp:
|
|
224
|
+
print(f"Blueprint: {bp.get('name')} ({bp.get('id')})")
|
|
216
225
|
print(_table(rows, ["id", "status", "created", "title"]) if rows else "No runs yet.")
|
|
217
226
|
return EXIT_OK
|
|
218
227
|
|
|
@@ -229,6 +238,9 @@ def cmd_runs_get(args) -> int:
|
|
|
229
238
|
else:
|
|
230
239
|
print(f"id {body.get('id')}")
|
|
231
240
|
print(f"status {body.get('status')} (done={body.get('done')})")
|
|
241
|
+
bp = body.get("blueprint") or {}
|
|
242
|
+
if bp:
|
|
243
|
+
print(f"blueprint {bp.get('name')} ({bp.get('id')})")
|
|
232
244
|
if body.get("error"):
|
|
233
245
|
print(f"error {body['error'].get('message')}")
|
|
234
246
|
if not body.get("done"):
|
|
@@ -255,6 +267,8 @@ def cmd_runs_create(args) -> int:
|
|
|
255
267
|
# #1802 — repeatable; the server resolves and refuses, the CLI only carries.
|
|
256
268
|
# Only blanks given → no list at all, never an empty one (the server 400s on []).
|
|
257
269
|
body["allowed_sources"] = allowed
|
|
270
|
+
if getattr(args, "blueprint", None):
|
|
271
|
+
body["blueprint_id"] = args.blueprint
|
|
258
272
|
|
|
259
273
|
# A real run spends money. Confirm interactively unless told not to — and
|
|
260
274
|
# never silently in a pipe, where nobody is watching.
|
|
@@ -264,6 +278,10 @@ def cmd_runs_create(args) -> int:
|
|
|
264
278
|
return EXIT_USAGE
|
|
265
279
|
print(f"This starts a REAL research run on {prof.origin} — it spends credits "
|
|
266
280
|
f"and takes 15-60 minutes.")
|
|
281
|
+
# gh #1282: say which Blueprint BEFORE it spends; the 202 is too late.
|
|
282
|
+
target = (f"Blueprint {args.blueprint}" if getattr(args, "blueprint", None)
|
|
283
|
+
else "your active Blueprint (see `deepsieve whoami`; choose with --blueprint)")
|
|
284
|
+
print(f"It runs on {target}.")
|
|
267
285
|
if input("Continue? [y/N] ").strip().lower() not in ("y", "yes"):
|
|
268
286
|
print("Cancelled.")
|
|
269
287
|
return EXIT_OK
|
|
@@ -284,6 +302,9 @@ def cmd_runs_create(args) -> int:
|
|
|
284
302
|
return EXIT_OK
|
|
285
303
|
run_id = created.get("id")
|
|
286
304
|
print(f"started run {run_id} ({'dry run' if args.dry_run else 'billable'})")
|
|
305
|
+
bp = created.get("blueprint") or {}
|
|
306
|
+
if bp:
|
|
307
|
+
print(f"Blueprint: {bp.get('name')} ({bp.get('id')})")
|
|
287
308
|
if not args.wait:
|
|
288
309
|
print(f"poll with: deepsieve runs get {run_id}")
|
|
289
310
|
return EXIT_OK
|
|
@@ -330,6 +351,80 @@ def cmd_runs_cancel(args) -> int:
|
|
|
330
351
|
return EXIT_OK
|
|
331
352
|
|
|
332
353
|
|
|
354
|
+
def _uuid_arg(value: str, what: str) -> str | None:
|
|
355
|
+
"""An id interpolated into a URL path must be a UUID, never a path fragment."""
|
|
356
|
+
import uuid as _uuid # noqa: PLC0415
|
|
357
|
+
|
|
358
|
+
try:
|
|
359
|
+
return str(_uuid.UUID(value))
|
|
360
|
+
except ValueError:
|
|
361
|
+
err(f"{what} must be a UUID")
|
|
362
|
+
return None
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def cmd_chat_thread(args) -> int:
|
|
366
|
+
"""A run's chat thread: questions, answers, and each change with its status. A change
|
|
367
|
+
`pending_confirmation` waits for `chat confirm` or `chat dismiss` (#2187)."""
|
|
368
|
+
rid = _uuid_arg(args.run_id, "run_id")
|
|
369
|
+
if rid is None:
|
|
370
|
+
return EXIT_FAIL
|
|
371
|
+
client, _ = _require_auth(args)
|
|
372
|
+
try:
|
|
373
|
+
body = client.request("GET", f"/v1/research/runs/{rid}/chat")
|
|
374
|
+
except ApiError as exc:
|
|
375
|
+
err(str(exc))
|
|
376
|
+
return EXIT_FAIL
|
|
377
|
+
if args.json:
|
|
378
|
+
out(body, True)
|
|
379
|
+
return EXIT_OK
|
|
380
|
+
rows = []
|
|
381
|
+
for message in body.get("messages") or []:
|
|
382
|
+
for action in message.get("actions") or []:
|
|
383
|
+
warnings = "; ".join(w.get("message", "") for w in action.get("warnings") or [])
|
|
384
|
+
rows.append(
|
|
385
|
+
[
|
|
386
|
+
action.get("id", ""),
|
|
387
|
+
action.get("status", ""),
|
|
388
|
+
action.get("action_type", ""),
|
|
389
|
+
f"{action.get('record_name') or ''} {action.get('column_name') or ''}".strip(),
|
|
390
|
+
warnings or action.get("note") or "",
|
|
391
|
+
]
|
|
392
|
+
)
|
|
393
|
+
print(f"{len(body.get('messages') or [])} message(s)")
|
|
394
|
+
if rows:
|
|
395
|
+
print(_table(rows, ["action", "status", "type", "cell", "warning / note"]))
|
|
396
|
+
return EXIT_OK
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _chat_action(args, op: str, done: str) -> int:
|
|
400
|
+
aid = _uuid_arg(args.action_id, "action_id")
|
|
401
|
+
if aid is None:
|
|
402
|
+
return EXIT_FAIL
|
|
403
|
+
client, _ = _require_auth(args)
|
|
404
|
+
try:
|
|
405
|
+
body = client.request("POST", f"/v1/chat/actions/{aid}/{op}")
|
|
406
|
+
except ApiError as exc:
|
|
407
|
+
err(str(exc))
|
|
408
|
+
return EXIT_FAIL
|
|
409
|
+
out(body, args.json, f"{done} {aid}")
|
|
410
|
+
return EXIT_OK
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def cmd_chat_confirm(args) -> int:
|
|
414
|
+
"""Apply a waiting change anyway, having read its warnings (`chat thread`)."""
|
|
415
|
+
return _chat_action(args, "confirm", "applied")
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def cmd_chat_dismiss(args) -> int:
|
|
419
|
+
"""Decline a waiting change: nothing is written."""
|
|
420
|
+
return _chat_action(args, "dismiss", "dismissed")
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def cmd_chat_revert(args) -> int:
|
|
424
|
+
"""Undo a change a chat answer applied."""
|
|
425
|
+
return _chat_action(args, "revert", "reverted")
|
|
426
|
+
|
|
427
|
+
|
|
333
428
|
def cmd_members_list(args) -> int:
|
|
334
429
|
"""Who is in this workspace and how many editor seats are in use (#985).
|
|
335
430
|
Read-only; inviting is a human-admin action in the UI."""
|
|
@@ -351,6 +446,262 @@ def cmd_members_list(args) -> int:
|
|
|
351
446
|
return EXIT_OK
|
|
352
447
|
|
|
353
448
|
|
|
449
|
+
def _describe_change(c: dict) -> str:
|
|
450
|
+
"""One line a person can read: what the change did, in its own words."""
|
|
451
|
+
kind = c.get("kind")
|
|
452
|
+
if kind == "merge":
|
|
453
|
+
folded = ", ".join(c.get("merged_names") or [])
|
|
454
|
+
return f"merged {folded} into {c.get('survivor_name')}"
|
|
455
|
+
if kind == "renamed":
|
|
456
|
+
return f"renamed {c.get('old_name')} to {c.get('new_name')}"
|
|
457
|
+
if kind == "succeeded_by":
|
|
458
|
+
return f"{c.get('old_name')} succeeded by {c.get('new_name')}"
|
|
459
|
+
return str(kind)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def cmd_changes_list(args) -> int:
|
|
463
|
+
"""Merges, renames and successor links, newest first (#1577)."""
|
|
464
|
+
client, _ = _require_auth(args)
|
|
465
|
+
params: dict[str, Any] = {"limit": args.limit}
|
|
466
|
+
for flag, key in (("entity", "entity"), ("kind", "kind"), ("by", "by"), ("cursor", "cursor"),
|
|
467
|
+
("blueprint", "blueprint_id")):
|
|
468
|
+
if getattr(args, flag, None):
|
|
469
|
+
params[key] = getattr(args, flag)
|
|
470
|
+
if args.live:
|
|
471
|
+
params["live"] = "true"
|
|
472
|
+
try:
|
|
473
|
+
body = client.request("GET", "/v1/changes", params=params)
|
|
474
|
+
except ApiError as exc:
|
|
475
|
+
err(str(exc))
|
|
476
|
+
return EXIT_FAIL
|
|
477
|
+
if args.json:
|
|
478
|
+
out(body, True)
|
|
479
|
+
return EXIT_OK
|
|
480
|
+
bp = body.get("blueprint") or {}
|
|
481
|
+
if bp:
|
|
482
|
+
print(f"Blueprint: {bp.get('name')} ({bp.get('id')})")
|
|
483
|
+
rows = []
|
|
484
|
+
for c in body.get("data", []):
|
|
485
|
+
if c.get("undone_at"):
|
|
486
|
+
state = "undone"
|
|
487
|
+
elif c.get("undoable"):
|
|
488
|
+
state = "undoable"
|
|
489
|
+
else:
|
|
490
|
+
state = "cannot undo"
|
|
491
|
+
rows.append([c.get("id", ""), str(c.get("created_at", ""))[:19], c.get("entity", ""),
|
|
492
|
+
_describe_change(c)[:60], state])
|
|
493
|
+
print(_table(rows, ["id", "when", "entity", "change", "state"]) if rows else "No changes.")
|
|
494
|
+
if body.get("has_more"):
|
|
495
|
+
print(f"\nMore: --cursor {body.get('next_cursor')}")
|
|
496
|
+
return EXIT_OK
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def cmd_changes_undo(args) -> int:
|
|
500
|
+
"""Undo one change. It changes the dataset, so it is confirmed like a billable
|
|
501
|
+
run: interactively, or with --yes, and never silently in a pipe."""
|
|
502
|
+
client, _ = _require_auth(args)
|
|
503
|
+
try:
|
|
504
|
+
# The id goes into the URL path; a non-UUID could reach another endpoint.
|
|
505
|
+
change_id = str(uuid.UUID(args.change_id))
|
|
506
|
+
except ValueError:
|
|
507
|
+
err("change id must be a UUID (from `deepsieve changes list`)")
|
|
508
|
+
return EXIT_USAGE
|
|
509
|
+
if not args.yes:
|
|
510
|
+
if not _tty():
|
|
511
|
+
err("refusing to change data non-interactively; pass --yes")
|
|
512
|
+
return EXIT_USAGE
|
|
513
|
+
if input(f"Undo change {change_id}? This changes your dataset. [y/N] ").strip().lower() not in ("y", "yes"):
|
|
514
|
+
print("Cancelled.")
|
|
515
|
+
return EXIT_OK
|
|
516
|
+
headers = {"Idempotency-Key": args.idempotency_key} if args.idempotency_key else None
|
|
517
|
+
payload = {"overwrite_changes": True} if getattr(args, "overwrite_changes", False) else None
|
|
518
|
+
try:
|
|
519
|
+
body = client.request(
|
|
520
|
+
"POST", f"/v1/changes/{change_id}/undo", json_body=payload, headers=headers
|
|
521
|
+
)
|
|
522
|
+
except ApiError as exc:
|
|
523
|
+
# The API names its body field; a CLI user has a flag for it (second review, n3).
|
|
524
|
+
err(str(exc).replace("Send `overwrite_changes: true`", "Run again with --overwrite-changes"))
|
|
525
|
+
return EXIT_FAIL
|
|
526
|
+
out(body, args.json, f"undone: {_describe_change(body)}")
|
|
527
|
+
return EXIT_OK
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
#: A cell id as /v1 issues them. It goes into a URL path; anything else is refused here.
|
|
532
|
+
_CELL_ID = re.compile(r"cel_[A-Za-z0-9_-]{8,1024}")
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def cmd_holds_list(args) -> int:
|
|
536
|
+
"""Cells you set on purpose, and what research found since (#2196)."""
|
|
537
|
+
client, _ = _require_auth(args)
|
|
538
|
+
params: dict[str, Any] = {"limit": args.limit}
|
|
539
|
+
for flag, key in (("entity", "entity"), ("cursor", "cursor"), ("blueprint", "blueprint_id")):
|
|
540
|
+
if getattr(args, flag, None):
|
|
541
|
+
params[key] = getattr(args, flag)
|
|
542
|
+
if args.pending:
|
|
543
|
+
params["pending"] = "true"
|
|
544
|
+
try:
|
|
545
|
+
body = client.request("GET", "/v1/holds", params=params)
|
|
546
|
+
except ApiError as exc:
|
|
547
|
+
err(str(exc))
|
|
548
|
+
return EXIT_FAIL
|
|
549
|
+
if args.json:
|
|
550
|
+
out(body, True)
|
|
551
|
+
return EXIT_OK
|
|
552
|
+
bp = body.get("blueprint") or {}
|
|
553
|
+
if bp:
|
|
554
|
+
print(f"Blueprint: {bp.get('name')} ({bp.get('id')})")
|
|
555
|
+
rows = []
|
|
556
|
+
for h in body.get("data", []):
|
|
557
|
+
pf = h.get("pending_finding") or {}
|
|
558
|
+
rows.append([
|
|
559
|
+
h.get("cell_id", ""),
|
|
560
|
+
f"{h.get('record_name') or h.get('record_id')}.{h.get('column')}"[:40],
|
|
561
|
+
str(h.get("value"))[:30],
|
|
562
|
+
str(pf.get("value"))[:30] if pf else "-",
|
|
563
|
+
# Printed so `holds apply --finding` can name what you saw (a newer one is refused).
|
|
564
|
+
pf.get("evidence_id") or "-",
|
|
565
|
+
])
|
|
566
|
+
print(
|
|
567
|
+
_table(rows, ["cell", "cell of", "your value", "research found", "finding"])
|
|
568
|
+
if rows
|
|
569
|
+
else "No held cells."
|
|
570
|
+
)
|
|
571
|
+
if body.get("has_more"):
|
|
572
|
+
print(f"\nMore: --cursor {body.get('next_cursor')}")
|
|
573
|
+
return EXIT_OK
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _hold_verb(args, verb: str) -> int:
|
|
577
|
+
"""Apply or release one held cell. Both change what the dataset protects, so they are
|
|
578
|
+
confirmed like an undo: interactively, or with --yes, never silently in a pipe."""
|
|
579
|
+
client, _ = _require_auth(args)
|
|
580
|
+
if not _CELL_ID.fullmatch(args.cell_id or ""):
|
|
581
|
+
err("cell id must be one `deepsieve holds list` printed (it starts with cel_)")
|
|
582
|
+
return EXIT_USAGE
|
|
583
|
+
body: dict[str, Any] = {}
|
|
584
|
+
if verb == "apply" and args.finding:
|
|
585
|
+
try:
|
|
586
|
+
body["finding_evidence_id"] = str(uuid.UUID(args.finding))
|
|
587
|
+
except ValueError:
|
|
588
|
+
err("--finding must be the finding's evidence id (a UUID)")
|
|
589
|
+
return EXIT_USAGE
|
|
590
|
+
if not args.yes:
|
|
591
|
+
if not _tty():
|
|
592
|
+
err("refusing to change data non-interactively; pass --yes")
|
|
593
|
+
return EXIT_USAGE
|
|
594
|
+
what = "Take research's newer value for" if verb == "apply" else "Stop protecting"
|
|
595
|
+
if input(f"{what} this cell? [y/N] ").strip().lower() not in ("y", "yes"):
|
|
596
|
+
print("Cancelled.")
|
|
597
|
+
return EXIT_OK
|
|
598
|
+
headers = {"Idempotency-Key": args.idempotency_key} if args.idempotency_key else None
|
|
599
|
+
try:
|
|
600
|
+
result = client.request(
|
|
601
|
+
"POST", f"/v1/cells/{args.cell_id}/{verb}", json_body=body or None, headers=headers
|
|
602
|
+
)
|
|
603
|
+
except ApiError as exc:
|
|
604
|
+
err(str(exc))
|
|
605
|
+
return EXIT_FAIL
|
|
606
|
+
done = "applied; the cell stays protected" if verb == "apply" else "released; research may update it"
|
|
607
|
+
out(result, args.json, f"{done}: {result.get('column')} = {result.get('value')!r}")
|
|
608
|
+
return EXIT_OK
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def cmd_holds_apply(args) -> int:
|
|
612
|
+
return _hold_verb(args, "apply")
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def cmd_holds_release(args) -> int:
|
|
616
|
+
return _hold_verb(args, "release")
|
|
617
|
+
|
|
618
|
+
def _merge_body(args) -> dict[str, Any] | None:
|
|
619
|
+
choices: dict[str, str] = {}
|
|
620
|
+
for c in args.choice or []:
|
|
621
|
+
key, sep, side = c.rpartition("=")
|
|
622
|
+
if not sep or side not in ("survivor", "loser"):
|
|
623
|
+
err(f"--choice takes KEY=survivor or KEY=loser, not {c!r}")
|
|
624
|
+
return None
|
|
625
|
+
choices[key] = side
|
|
626
|
+
body: dict[str, Any] = {
|
|
627
|
+
"entity": args.entity,
|
|
628
|
+
"survivor_id": args.survivor,
|
|
629
|
+
"loser_ids": list(args.loser),
|
|
630
|
+
"choices": choices,
|
|
631
|
+
}
|
|
632
|
+
if getattr(args, "blueprint", None):
|
|
633
|
+
body["blueprint_id"] = args.blueprint
|
|
634
|
+
return body
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _print_merge(body: dict) -> None:
|
|
638
|
+
print(f"survivor: {body.get('survivor_name')} ({body.get('survivor_id')})")
|
|
639
|
+
print(f"merges: {', '.join(n or '?' for n in body.get('merged_names') or [])}")
|
|
640
|
+
for f in body.get("fills") or []:
|
|
641
|
+
print(f" fill {f.get('column')}: {f.get('value')} (from {f.get('from')})")
|
|
642
|
+
for c in body.get("conflicts") or []:
|
|
643
|
+
how = {"tie": "TIE", "held": "set on purpose"}.get(c.get("basis"), "evidence")
|
|
644
|
+
mark = " (your choice)" if c.get("chosen") else ""
|
|
645
|
+
print(f" conflict {c.get('column')}: keeps {c.get('resolved_to')} [{how}]{mark} "
|
|
646
|
+
f"survivor={c.get('survivor_value')!r} {c.get('loser_name')}={c.get('loser_value')!r} "
|
|
647
|
+
f"key={c.get('choice_key')}")
|
|
648
|
+
ties = body.get("unresolved_ties") or []
|
|
649
|
+
if ties:
|
|
650
|
+
print(f"\nopen ties ({len(ties)}): settle each with --choice KEY=survivor|loser, "
|
|
651
|
+
"or pass --accept-survivor-on-ties")
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def cmd_merge_preview(args) -> int:
|
|
655
|
+
"""What a merge would do; writes nothing (#1577 2c-3)."""
|
|
656
|
+
client, _ = _require_auth(args)
|
|
657
|
+
body = _merge_body(args)
|
|
658
|
+
if body is None:
|
|
659
|
+
return EXIT_USAGE
|
|
660
|
+
try:
|
|
661
|
+
out_ = client.request("POST", "/v1/merges/preview", json_body=body)
|
|
662
|
+
except ApiError as exc:
|
|
663
|
+
err(str(exc))
|
|
664
|
+
return EXIT_FAIL
|
|
665
|
+
if args.json:
|
|
666
|
+
out(out_, True)
|
|
667
|
+
else:
|
|
668
|
+
_print_merge(out_)
|
|
669
|
+
return EXIT_OK
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def cmd_merge_apply(args) -> int:
|
|
673
|
+
"""Merge rows into a survivor. It changes the dataset, so it is confirmed like a
|
|
674
|
+
billable run: interactively, or with --yes, never silently in a pipe."""
|
|
675
|
+
client, _ = _require_auth(args)
|
|
676
|
+
body = _merge_body(args)
|
|
677
|
+
if body is None:
|
|
678
|
+
return EXIT_USAGE
|
|
679
|
+
body["accept_survivor_on_ties"] = bool(args.accept_survivor_on_ties)
|
|
680
|
+
if not args.yes:
|
|
681
|
+
if not _tty():
|
|
682
|
+
err("refusing to change data non-interactively; pass --yes")
|
|
683
|
+
return EXIT_USAGE
|
|
684
|
+
prompt = f"Merge {len(body['loser_ids'])} row(s) into {args.survivor}? This changes your dataset. [y/N] "
|
|
685
|
+
if input(prompt).strip().lower() not in ("y", "yes"):
|
|
686
|
+
print("Cancelled.")
|
|
687
|
+
return EXIT_OK
|
|
688
|
+
headers = {"Idempotency-Key": args.idempotency_key} if args.idempotency_key else None
|
|
689
|
+
try:
|
|
690
|
+
out_ = client.request("POST", "/v1/merges", json_body=body, headers=headers)
|
|
691
|
+
except ApiError as exc:
|
|
692
|
+
err(str(exc))
|
|
693
|
+
# An open tie is fixed by changing the command (--choice, or
|
|
694
|
+
# --accept-survivor-on-ties), so it exits as a usage error: a script can tell it
|
|
695
|
+
# apart from a failure without --json.
|
|
696
|
+
return EXIT_USAGE if exc.code == "ties_unresolved" else EXIT_FAIL
|
|
697
|
+
if args.json:
|
|
698
|
+
out(out_, True)
|
|
699
|
+
else:
|
|
700
|
+
_print_merge(out_)
|
|
701
|
+
print(f"\nmerged. Undo: deepsieve changes undo {out_.get('change_id')}")
|
|
702
|
+
return EXIT_OK
|
|
703
|
+
|
|
704
|
+
|
|
354
705
|
def _blueprint_id(client, args) -> str | None:
|
|
355
706
|
"""The Blueprint these commands address: ``--blueprint``, else the active one.
|
|
356
707
|
|
|
@@ -610,12 +961,22 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
610
961
|
lg.add_argument("--origin", help="deployment URL (e.g. https://staging.deepsieve.ai)")
|
|
611
962
|
lg.add_argument("--api-key", help="headless: use a key instead of the browser flow")
|
|
612
963
|
lg.add_argument("--no-browser", action="store_true", help="print the URL, don't open it")
|
|
964
|
+
# #1799: the default `cli` token carries no `blueprint:create`; it has no
|
|
965
|
+
# Blueprint command to spend that consent on. `--preset agent` is the explicit
|
|
966
|
+
# widening for a developer driving an agent from this credential, and the
|
|
967
|
+
# consent screen lists the wider set. The server clamps to `agent` either way.
|
|
968
|
+
lg.add_argument(
|
|
969
|
+
"--preset",
|
|
970
|
+
choices=("cli", "agent"),
|
|
971
|
+
default="cli",
|
|
972
|
+
help="scope preset to request: cli (default) or agent (adds Blueprint creation)",
|
|
973
|
+
)
|
|
613
974
|
lg.set_defaults(func=cmd_login)
|
|
614
975
|
|
|
615
976
|
lo = sub.add_parser("logout", help="forget this profile's stored credential", parents=[common])
|
|
616
977
|
lo.set_defaults(func=cmd_logout)
|
|
617
978
|
|
|
618
|
-
wa = sub.add_parser("whoami", help="show the resolved identity,
|
|
979
|
+
wa = sub.add_parser("whoami", help="show the resolved identity, active Blueprint and scopes", parents=[common])
|
|
619
980
|
wa.set_defaults(func=cmd_whoami)
|
|
620
981
|
|
|
621
982
|
pr = sub.add_parser("profiles", help="list configured profiles", parents=[common])
|
|
@@ -626,6 +987,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
626
987
|
rl = runs.add_parser("list", help="recent runs", parents=[common])
|
|
627
988
|
rl.add_argument("--status")
|
|
628
989
|
rl.add_argument("--limit", type=int, default=20)
|
|
990
|
+
rl.add_argument(
|
|
991
|
+
"--blueprint",
|
|
992
|
+
help="list one Blueprint's runs, by id (`deepsieve whoami` shows your active "
|
|
993
|
+
"one's); omitted: your active Blueprint",
|
|
994
|
+
)
|
|
629
995
|
rl.set_defaults(func=cmd_runs_list)
|
|
630
996
|
|
|
631
997
|
rg = runs.add_parser("get", help="one run's status", parents=[common])
|
|
@@ -639,6 +1005,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
639
1005
|
help="restrict evidence to this source (repeatable): a host, *.gov, "
|
|
640
1006
|
"https://host/path/*, or blueprint:primary; a cell cited only "
|
|
641
1007
|
"elsewhere is not written")
|
|
1008
|
+
rc.add_argument("--blueprint", help="Blueprint id to research (default: your active one, "
|
|
1009
|
+
"which `deepsieve whoami` shows)")
|
|
642
1010
|
rc.add_argument("--depth", default="standard", choices=["standard", "max"])
|
|
643
1011
|
rc.add_argument("--dry-run", action="store_true", help="free simulated run (~15s)")
|
|
644
1012
|
rc.add_argument("--no-monitor", dest="monitored", action="store_false",
|
|
@@ -660,10 +1028,79 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
660
1028
|
rx.add_argument("run_id")
|
|
661
1029
|
rx.set_defaults(func=cmd_runs_cancel)
|
|
662
1030
|
|
|
1031
|
+
chat = sub.add_parser("chat", help="a run's chat and the changes it proposed", parents=[common]).add_subparsers(dest="sub", required=True)
|
|
1032
|
+
ct = chat.add_parser("thread", help="the thread, with each change's status and warnings", parents=[common])
|
|
1033
|
+
ct.add_argument("run_id")
|
|
1034
|
+
ct.set_defaults(func=cmd_chat_thread)
|
|
1035
|
+
for name, fn, text in (
|
|
1036
|
+
("confirm", cmd_chat_confirm, "apply a change that is waiting for confirmation"),
|
|
1037
|
+
("dismiss", cmd_chat_dismiss, "decline a change that is waiting for confirmation"),
|
|
1038
|
+
("revert", cmd_chat_revert, "undo a change a chat answer applied"),
|
|
1039
|
+
):
|
|
1040
|
+
cp = chat.add_parser(name, help=text, parents=[common])
|
|
1041
|
+
cp.add_argument("action_id")
|
|
1042
|
+
cp.set_defaults(func=fn)
|
|
1043
|
+
|
|
663
1044
|
members = sub.add_parser("members", help="your workspace's team", parents=[common]).add_subparsers(dest="sub", required=True)
|
|
664
1045
|
ml = members.add_parser("list", help="who is in the workspace, and seat usage", parents=[common])
|
|
665
1046
|
ml.set_defaults(func=cmd_members_list)
|
|
666
1047
|
|
|
1048
|
+
changes = sub.add_parser("changes", help="merges, renames and successor links", parents=[common]).add_subparsers(dest="sub", required=True)
|
|
1049
|
+
cl = changes.add_parser("list", help="newest first; undone ones included unless --live", parents=[common])
|
|
1050
|
+
cl.add_argument("--entity", help="only this entity's records (an entity key)")
|
|
1051
|
+
cl.add_argument("--kind", choices=["merge", "renamed", "succeeded_by"])
|
|
1052
|
+
cl.add_argument("--by", choices=["user", "automatic", "operator"], help="who made the change")
|
|
1053
|
+
cl.add_argument("--live", action="store_true", help="leave out undone changes")
|
|
1054
|
+
cl.add_argument("--limit", type=int, default=25)
|
|
1055
|
+
cl.add_argument("--cursor")
|
|
1056
|
+
cl.add_argument("--blueprint", help="Blueprint id (default: the active one)")
|
|
1057
|
+
cl.set_defaults(func=cmd_changes_list)
|
|
1058
|
+
cu = changes.add_parser("undo", help="undo one change (needs data:write)", parents=[common])
|
|
1059
|
+
cu.add_argument("change_id")
|
|
1060
|
+
cu.add_argument("--yes", action="store_true", help="do not ask for confirmation")
|
|
1061
|
+
cu.add_argument("--idempotency-key", help="make a retry safe")
|
|
1062
|
+
cu.add_argument("--overwrite-changes", action="store_true",
|
|
1063
|
+
help="undo a merge even though cells it filled changed since")
|
|
1064
|
+
cu.set_defaults(func=cmd_changes_undo)
|
|
1065
|
+
|
|
1066
|
+
holds = sub.add_parser("holds", help="cells you set on purpose, and newer research", parents=[common]).add_subparsers(dest="sub", required=True)
|
|
1067
|
+
hl = holds.add_parser("list", help="newest first; --pending for those with newer research", parents=[common])
|
|
1068
|
+
hl.add_argument("--entity", help="only this entity's cells (an entity key)")
|
|
1069
|
+
hl.add_argument("--pending", action="store_true", help="only cells where research found something newer")
|
|
1070
|
+
hl.add_argument("--limit", type=int, default=25)
|
|
1071
|
+
hl.add_argument("--cursor")
|
|
1072
|
+
hl.add_argument("--blueprint", help="Blueprint id (default: the active one)")
|
|
1073
|
+
hl.set_defaults(func=cmd_holds_list)
|
|
1074
|
+
ha = holds.add_parser("apply", help="take research's newer value (needs data:write)", parents=[common])
|
|
1075
|
+
ha.add_argument("cell_id")
|
|
1076
|
+
ha.add_argument("--finding", help="the `finding` id `holds list` printed for the value you reviewed (a newer one since is refused)")
|
|
1077
|
+
ha.add_argument("--yes", action="store_true", help="do not ask for confirmation")
|
|
1078
|
+
ha.add_argument("--idempotency-key", help="make a retry safe")
|
|
1079
|
+
ha.set_defaults(func=cmd_holds_apply)
|
|
1080
|
+
hr = holds.add_parser("release", help="stop protecting a cell (needs data:write)", parents=[common])
|
|
1081
|
+
hr.add_argument("cell_id")
|
|
1082
|
+
hr.add_argument("--yes", action="store_true", help="do not ask for confirmation")
|
|
1083
|
+
hr.add_argument("--idempotency-key", help="make a retry safe")
|
|
1084
|
+
hr.set_defaults(func=cmd_holds_release)
|
|
1085
|
+
|
|
1086
|
+
merge = sub.add_parser("merge", help="mark rows as duplicates of one another", parents=[common]).add_subparsers(dest="sub", required=True)
|
|
1087
|
+
for name, helptext, func in (
|
|
1088
|
+
("preview", "what a merge would do (writes nothing)", cmd_merge_preview),
|
|
1089
|
+
("apply", "merge rows into a survivor (needs data:write)", cmd_merge_apply),
|
|
1090
|
+
):
|
|
1091
|
+
mp = merge.add_parser(name, help=helptext, parents=[common])
|
|
1092
|
+
mp.add_argument("entity", help="entity key (see `deepsieve data catalog`)")
|
|
1093
|
+
mp.add_argument("--survivor", required=True, help="the row that stays")
|
|
1094
|
+
mp.add_argument("--loser", required=True, action="append", help="a row merged into it (repeatable)")
|
|
1095
|
+
mp.add_argument("--choice", action="append", help="settle a tie: KEY=survivor|loser (repeatable)")
|
|
1096
|
+
mp.add_argument("--blueprint", help="Blueprint id (default: the active one)")
|
|
1097
|
+
if name == "apply":
|
|
1098
|
+
mp.add_argument("--accept-survivor-on-ties", action="store_true",
|
|
1099
|
+
help="keep the survivor's value on every tie you did not settle")
|
|
1100
|
+
mp.add_argument("--yes", action="store_true", help="do not ask for confirmation")
|
|
1101
|
+
mp.add_argument("--idempotency-key", help="make a retry safe")
|
|
1102
|
+
mp.set_defaults(func=func)
|
|
1103
|
+
|
|
667
1104
|
data = sub.add_parser("data", help="your cited dataset", parents=[common]).add_subparsers(dest="sub", required=True)
|
|
668
1105
|
|
|
669
1106
|
dc = data.add_parser("catalog", help="entities and columns (never guess these)", parents=[common])
|
|
@@ -177,6 +177,29 @@ def test_login_rejects_a_bad_key_without_storing_it(monkeypatch):
|
|
|
177
177
|
assert cfg.load_profile().api_key is None, "a rejected key must never be persisted"
|
|
178
178
|
|
|
179
179
|
|
|
180
|
+
@pytest.mark.parametrize(("argv", "expected"), [([], "cli"), (["--preset", "agent"], "agent")])
|
|
181
|
+
def test_device_login_requests_the_chosen_preset(monkeypatch, argv, expected):
|
|
182
|
+
"""#1799: the default `cli` token has no `blueprint:create`; `--preset agent`
|
|
183
|
+
is the explicit widening. Assert what goes on the wire, and the credential
|
|
184
|
+
the flow stores, so a flag parsed but never sent cannot pass."""
|
|
185
|
+
sent: list[dict] = []
|
|
186
|
+
|
|
187
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
188
|
+
if request.url.path == "/v1/auth/device/code":
|
|
189
|
+
sent.append(json.loads(request.content))
|
|
190
|
+
return httpx.Response(201, json={
|
|
191
|
+
"device_code": "dc", "user_code": "A", "verification_uri": "u",
|
|
192
|
+
"verification_uri_complete": "u", "expires_in": 600, "interval": 0,
|
|
193
|
+
})
|
|
194
|
+
return httpx.Response(200, json={"api_key": "ds_live_dev", "key_name": "CLI"})
|
|
195
|
+
|
|
196
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
197
|
+
rc = main(["login", "--origin", "https://x.test", "--no-browser", *argv])
|
|
198
|
+
assert rc == 0
|
|
199
|
+
assert [b["preset"] for b in sent] == [expected]
|
|
200
|
+
assert cfg.load_profile().api_key == "ds_live_dev"
|
|
201
|
+
|
|
202
|
+
|
|
180
203
|
def test_commands_refuse_without_a_credential(capsys):
|
|
181
204
|
assert main(["whoami"]) == 3
|
|
182
205
|
assert "deepsieve login" in capsys.readouterr().err
|
|
@@ -333,6 +356,10 @@ def test_unfinished_run_exits_pending(monkeypatch):
|
|
|
333
356
|
["runs", "list", "--profile", "staging"],
|
|
334
357
|
["whoami", "--json"],
|
|
335
358
|
["setup", "mcp", "--json"],
|
|
359
|
+
["changes", "list", "--live", "--json"],
|
|
360
|
+
["merge", "preview", "books", "--survivor", "s", "--loser", "l", "--json"],
|
|
361
|
+
["merge", "apply", "books", "--survivor", "s", "--loser", "l", "--loser", "m", "--yes"],
|
|
362
|
+
["changes", "undo", "00000000-0000-0000-0000-000000000000", "--yes"],
|
|
336
363
|
],
|
|
337
364
|
)
|
|
338
365
|
def test_documented_command_lines_parse(argv):
|
|
@@ -667,3 +694,361 @@ def test_run_create_forwards_each_allowed_source(monkeypatch):
|
|
|
667
694
|
# Only blanks → no key at all, never `[]` (the server refuses an empty list).
|
|
668
695
|
assert main(["runs", "create", "--seeds", "Acme", "--dry-run", "--allowed-source", " "]) == 0
|
|
669
696
|
assert "allowed_sources" not in seen["body"]
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def test_run_create_names_the_blueprint_it_sends_and_the_one_it_got(monkeypatch, capsys):
|
|
700
|
+
"""gh #1282: `--blueprint` reaches the body, and the Blueprint the run is on is
|
|
701
|
+
printed, so a run on the wrong one is visible at once."""
|
|
702
|
+
cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
|
|
703
|
+
seen = {}
|
|
704
|
+
|
|
705
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
706
|
+
seen["body"] = json.loads(request.content)
|
|
707
|
+
return httpx.Response(
|
|
708
|
+
202,
|
|
709
|
+
json={"id": "run_1", "status": "queued", "blueprint": {"id": "bp-a", "name": "Widgets"}},
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
713
|
+
assert main(["runs", "create", "--seeds", "Acme", "--dry-run", "--blueprint", "bp-a"]) == 0
|
|
714
|
+
assert seen["body"]["blueprint_id"] == "bp-a"
|
|
715
|
+
assert "Blueprint: Widgets (bp-a)" in capsys.readouterr().out
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def test_runs_get_and_whoami_name_the_blueprint(monkeypatch, capsys):
|
|
719
|
+
"""gh #1282: text mode shows the Blueprint a run is on, and whoami keeps its
|
|
720
|
+
workspace line beside the new blueprint line (additive: scripts may parse it)."""
|
|
721
|
+
cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
|
|
722
|
+
|
|
723
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
724
|
+
if request.url.path == "/v1/me":
|
|
725
|
+
return httpx.Response(
|
|
726
|
+
200,
|
|
727
|
+
json={"org_id": "o", "workspace_schema": "ws_x", "role": "owner",
|
|
728
|
+
"blueprint": {"id": "bp-a", "name": "Widgets"}},
|
|
729
|
+
)
|
|
730
|
+
return httpx.Response(
|
|
731
|
+
200,
|
|
732
|
+
json={"id": "run_1", "status": "completed", "done": True,
|
|
733
|
+
"blueprint": {"id": "bp-a", "name": "Widgets"}},
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
737
|
+
assert main(["runs", "get", "run_1"]) == 0
|
|
738
|
+
assert main(["whoami"]) == 0
|
|
739
|
+
printed = capsys.readouterr().out
|
|
740
|
+
assert "blueprint Widgets (bp-a)" in printed
|
|
741
|
+
assert "blueprint Widgets (bp-a)" in printed and "workspace ws_x" in printed
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
# ── chat changes (#2187) ────────────────────────────────────────────────────
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
_AID = "2d6c5d6e-7a57-4a53-9c1c-7b0e0f6a0b11"
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
@pytest.mark.parametrize("op", ["confirm", "dismiss", "revert"])
|
|
751
|
+
def test_chat_action_commands_post_to_their_endpoint(monkeypatch, op):
|
|
752
|
+
monkeypatch.setenv(cfg.ENV_KEY, "ds_live_k")
|
|
753
|
+
seen: list[tuple[str, str]] = []
|
|
754
|
+
|
|
755
|
+
def handler(request):
|
|
756
|
+
seen.append((request.method, request.url.path))
|
|
757
|
+
return httpx.Response(200, json={"action_id": _AID, "status": "ok"})
|
|
758
|
+
|
|
759
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
760
|
+
assert main(["chat", op, _AID, "--json"]) == 0
|
|
761
|
+
assert seen == [("POST", f"/v1/chat/actions/{_AID}/{op}")]
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def test_a_chat_action_id_must_be_a_uuid(monkeypatch, capsys):
|
|
765
|
+
monkeypatch.setenv(cfg.ENV_KEY, "ds_live_k")
|
|
766
|
+
monkeypatch.setattr(httpx, "Client", _mock(lambda r: pytest.fail("no request expected")))
|
|
767
|
+
assert main(["chat", "confirm", "../keys"]) != 0
|
|
768
|
+
assert "must be a UUID" in capsys.readouterr().err
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def test_chat_thread_lists_each_change_with_its_warning(monkeypatch, capsys):
|
|
772
|
+
monkeypatch.setenv(cfg.ENV_KEY, "ds_live_k")
|
|
773
|
+
body = {
|
|
774
|
+
"messages": [
|
|
775
|
+
{
|
|
776
|
+
"actions": [
|
|
777
|
+
{
|
|
778
|
+
"id": _AID,
|
|
779
|
+
"status": "pending_confirmation",
|
|
780
|
+
"action_type": "update_cell",
|
|
781
|
+
"record_name": "Acme",
|
|
782
|
+
"column_name": "fee",
|
|
783
|
+
"warnings": [{"code": "unit_conversion", "message": "a unit change"}],
|
|
784
|
+
}
|
|
785
|
+
]
|
|
786
|
+
}
|
|
787
|
+
]
|
|
788
|
+
}
|
|
789
|
+
monkeypatch.setattr(httpx, "Client", _mock(lambda r: httpx.Response(200, json=body)))
|
|
790
|
+
assert main(["chat", "thread", _AID]) == 0
|
|
791
|
+
printed = capsys.readouterr().out
|
|
792
|
+
assert "pending_confirmation" in printed and "a unit change" in printed and _AID in printed
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
# ── changes (#1577 2c-2) ─────────────────────────────────────────────────────
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
CID = "6f1d8b43-95d7-430b-b355-764435da6663"
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def _change(**over):
|
|
802
|
+
base = {"object": "change", "id": "c-1", "kind": "renamed", "entity": "books",
|
|
803
|
+
"created_at": "2026-09-25T10:00:00+00:00", "undone_at": None, "undoable": True,
|
|
804
|
+
"old_name": "Bronte Hall", "new_name": "Bronte House", "survivor_name": None,
|
|
805
|
+
"merged_names": None}
|
|
806
|
+
return base | over
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def test_changes_list_says_what_each_change_did(monkeypatch, capsys):
|
|
810
|
+
_signed_in(monkeypatch)
|
|
811
|
+
seen = {}
|
|
812
|
+
|
|
813
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
814
|
+
seen["path"], seen["params"] = request.url.path, dict(request.url.params)
|
|
815
|
+
return httpx.Response(200, json={
|
|
816
|
+
"data": [
|
|
817
|
+
_change(id="m-1", kind="merge", survivor_name="Keep Press",
|
|
818
|
+
merged_names=["Keep Press Ltd"], old_name=None, new_name=None),
|
|
819
|
+
_change(undone_at="2026-09-25T11:00:00+00:00", undoable=False),
|
|
820
|
+
],
|
|
821
|
+
"has_more": True, "next_cursor": "abc", "blueprint": {"id": "bp", "name": "Library"},
|
|
822
|
+
})
|
|
823
|
+
|
|
824
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
825
|
+
assert main(["changes", "list", "--kind", "merge", "--live", "--entity", "books"]) == 0
|
|
826
|
+
text = capsys.readouterr().out
|
|
827
|
+
assert seen["path"] == "/v1/changes"
|
|
828
|
+
assert seen["params"] == {"limit": "25", "kind": "merge", "live": "true", "entity": "books"}
|
|
829
|
+
assert "merged Keep Press Ltd into Keep Press" in text and "undoable" in text
|
|
830
|
+
assert "renamed Bronte Hall to Bronte House" in text and "undone" in text
|
|
831
|
+
assert "--cursor abc" in text
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def test_changes_undo_is_refused_in_a_pipe_without_yes_and_sends_nothing(monkeypatch, capsys):
|
|
835
|
+
_signed_in(monkeypatch)
|
|
836
|
+
monkeypatch.setattr("deepsieve_cli.main._tty", lambda: False)
|
|
837
|
+
calls = []
|
|
838
|
+
monkeypatch.setattr(httpx, "Client", _mock(lambda r: calls.append(r) or httpx.Response(200)))
|
|
839
|
+
assert main(["changes", "undo", CID]) == 2
|
|
840
|
+
assert "pass --yes" in capsys.readouterr().err
|
|
841
|
+
assert calls == []
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def test_changes_undo_with_yes_posts_and_forwards_the_idempotency_key(monkeypatch, capsys):
|
|
845
|
+
_signed_in(monkeypatch)
|
|
846
|
+
seen = {}
|
|
847
|
+
|
|
848
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
849
|
+
seen["method"], seen["path"] = request.method, request.url.path
|
|
850
|
+
seen["key"] = request.headers.get("idempotency-key")
|
|
851
|
+
return httpx.Response(200, json=_change(undone_at="2026-09-25T11:00:00+00:00"))
|
|
852
|
+
|
|
853
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
854
|
+
assert main(["changes", "undo", CID, "--yes", "--idempotency-key", "k-1"]) == 0
|
|
855
|
+
assert (seen["method"], seen["path"], seen["key"]) == ("POST", f"/v1/changes/{CID}/undo", "k-1")
|
|
856
|
+
assert "undone: renamed Bronte Hall to Bronte House" in capsys.readouterr().out
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def test_changes_undo_surfaces_the_role_floor_refusal(monkeypatch, capsys):
|
|
860
|
+
_signed_in(monkeypatch)
|
|
861
|
+
body = {"error": {"type": "permission_error", "code": "insufficient_role",
|
|
862
|
+
"message": "data:write needs the member role or above", "retriable": False}}
|
|
863
|
+
monkeypatch.setattr(httpx, "Client", _mock(lambda r: httpx.Response(403, json=body)))
|
|
864
|
+
assert main(["changes", "undo", CID, "--yes"]) == 1
|
|
865
|
+
assert "needs the member role" in capsys.readouterr().err
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def test_changes_undo_names_the_flag_when_cells_changed_since(monkeypatch, capsys):
|
|
869
|
+
"""#2199 review n3: the API names its body field; the CLI user is told the flag."""
|
|
870
|
+
_signed_in(monkeypatch)
|
|
871
|
+
body = {"error": {"type": "invalid_request_error", "code": "conflict", "param": "overwrite_changes",
|
|
872
|
+
"message": "Changed since the merge: Author. Undoing restores the values from "
|
|
873
|
+
"before the merge and overwrites the newer ones. Send "
|
|
874
|
+
"`overwrite_changes: true` to undo anyway.", "retriable": False}}
|
|
875
|
+
monkeypatch.setattr(httpx, "Client", _mock(lambda r: httpx.Response(409, json=body)))
|
|
876
|
+
assert main(["changes", "undo", CID, "--yes"]) == 1
|
|
877
|
+
err_text = capsys.readouterr().err
|
|
878
|
+
assert "Run again with --overwrite-changes to undo anyway." in err_text
|
|
879
|
+
assert "overwrite_changes: true" not in err_text
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def test_changes_undo_refuses_a_non_uuid_id_and_sends_nothing(monkeypatch, capsys):
|
|
883
|
+
"""2c-2 review F9: the id goes into the URL path, so a crafted one could reach
|
|
884
|
+
another endpoint. Refused before any request, even with --yes."""
|
|
885
|
+
_signed_in(monkeypatch)
|
|
886
|
+
calls = []
|
|
887
|
+
monkeypatch.setattr(httpx, "Client", _mock(lambda r: calls.append(r) or httpx.Response(200)))
|
|
888
|
+
assert main(["changes", "undo", "../research/runs/x/cancel#", "--yes"]) == 2
|
|
889
|
+
assert "must be a UUID" in capsys.readouterr().err
|
|
890
|
+
assert calls == []
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
# ── merge (#1577 2c-3) ───────────────────────────────────────────────────────
|
|
894
|
+
|
|
895
|
+
_S, _L = "11111111-1111-1111-1111-111111111111", "22222222-2222-2222-2222-222222222222"
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def _merge_result(**over):
|
|
899
|
+
base = {"object": "merge", "change_id": None, "applied": False, "survivor_id": _S,
|
|
900
|
+
"survivor_name": "Tie Press", "merged_names": ["Tie Press Ltd"], "fills": [],
|
|
901
|
+
"conflicts": [{"choice_key": f"author::{_L}", "column": "author", "loser_id": _L,
|
|
902
|
+
"loser_name": "Tie Press Ltd", "survivor_value": "Ann", "loser_value": "Bea",
|
|
903
|
+
"basis": "tie", "resolved_to": "survivor", "chosen": False}],
|
|
904
|
+
"unresolved_ties": [f"author::{_L}"], "blueprint": {"id": "bp", "name": "Library"}}
|
|
905
|
+
return base | over
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def test_merge_preview_sends_the_body_and_names_the_open_tie(monkeypatch, capsys):
|
|
909
|
+
_signed_in(monkeypatch)
|
|
910
|
+
seen = {}
|
|
911
|
+
|
|
912
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
913
|
+
seen["path"], seen["body"] = request.url.path, json.loads(request.content)
|
|
914
|
+
return httpx.Response(200, json=_merge_result())
|
|
915
|
+
|
|
916
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
917
|
+
assert main(["merge", "preview", "books", "--survivor", _S, "--loser", _L]) == 0
|
|
918
|
+
assert seen["path"] == "/v1/merges/preview"
|
|
919
|
+
assert seen["body"] == {"entity": "books", "survivor_id": _S, "loser_ids": [_L], "choices": {}}
|
|
920
|
+
text = capsys.readouterr().out
|
|
921
|
+
assert f"key=author::{_L}" in text and "open ties (1)" in text
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def test_merge_preview_labels_a_held_conflict_as_set_on_purpose(monkeypatch, capsys):
|
|
925
|
+
"""#2199 review m2: a held conflict is not an evidence decision."""
|
|
926
|
+
_signed_in(monkeypatch)
|
|
927
|
+
held = _merge_result(unresolved_ties=[])
|
|
928
|
+
held["conflicts"][0] |= {"basis": "held"}
|
|
929
|
+
monkeypatch.setattr(httpx, "Client", _mock(lambda r: httpx.Response(200, json=held)))
|
|
930
|
+
assert main(["merge", "preview", "books", "--survivor", _S, "--loser", _L]) == 0
|
|
931
|
+
text = capsys.readouterr().out
|
|
932
|
+
assert "[set on purpose]" in text and "[evidence]" not in text
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def test_merge_apply_is_refused_in_a_pipe_without_yes_and_sends_nothing(monkeypatch, capsys):
|
|
936
|
+
_signed_in(monkeypatch)
|
|
937
|
+
monkeypatch.setattr("deepsieve_cli.main._tty", lambda: False)
|
|
938
|
+
calls = []
|
|
939
|
+
monkeypatch.setattr(httpx, "Client", _mock(lambda r: calls.append(r) or httpx.Response(201)))
|
|
940
|
+
assert main(["merge", "apply", "books", "--survivor", _S, "--loser", _L]) == 2
|
|
941
|
+
assert "pass --yes" in capsys.readouterr().err and calls == []
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def test_merge_apply_forwards_choices_the_tie_flag_and_the_key(monkeypatch, capsys):
|
|
945
|
+
_signed_in(monkeypatch)
|
|
946
|
+
seen = {}
|
|
947
|
+
|
|
948
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
949
|
+
seen["body"], seen["key"] = json.loads(request.content), request.headers.get("idempotency-key")
|
|
950
|
+
return httpx.Response(201, json=_merge_result(applied=True, change_id="c-9", unresolved_ties=[]))
|
|
951
|
+
|
|
952
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
953
|
+
argv = ["merge", "apply", "books", "--survivor", _S, "--loser", _L,
|
|
954
|
+
"--choice", f"author::{_L}=loser", "--accept-survivor-on-ties", "--yes",
|
|
955
|
+
"--idempotency-key", "k-1"]
|
|
956
|
+
assert main(argv) == 0
|
|
957
|
+
assert seen["body"]["choices"] == {f"author::{_L}": "loser"}
|
|
958
|
+
assert seen["body"]["accept_survivor_on_ties"] is True and seen["key"] == "k-1"
|
|
959
|
+
assert "deepsieve changes undo c-9" in capsys.readouterr().out
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def test_a_malformed_choice_is_refused_before_any_request(monkeypatch, capsys):
|
|
963
|
+
_signed_in(monkeypatch)
|
|
964
|
+
calls = []
|
|
965
|
+
monkeypatch.setattr(httpx, "Client", _mock(lambda r: calls.append(r) or httpx.Response(200)))
|
|
966
|
+
assert main(["merge", "preview", "books", "--survivor", _S, "--loser", _L, "--choice", "author=maybe"]) == 2
|
|
967
|
+
assert "KEY=survivor or KEY=loser" in capsys.readouterr().err and calls == []
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def test_an_unsettled_tie_surfaces_the_servers_refusal(monkeypatch, capsys):
|
|
971
|
+
_signed_in(monkeypatch)
|
|
972
|
+
body = {"error": {"type": "conflict_error", "code": "ties_unresolved", "retriable": False,
|
|
973
|
+
"message": f"1 tie(s) have no choice: author::{_L}.", "param": "choices"}}
|
|
974
|
+
monkeypatch.setattr(httpx, "Client", _mock(lambda r: httpx.Response(409, json=body)))
|
|
975
|
+
assert main(["merge", "apply", "books", "--survivor", _S, "--loser", _L, "--yes"]) == 2
|
|
976
|
+
assert f"author::{_L}" in capsys.readouterr().err
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
# ---------------------------------------------------------------------------
|
|
980
|
+
# #2196 PR 2: holds list | apply | release
|
|
981
|
+
# ---------------------------------------------------------------------------
|
|
982
|
+
|
|
983
|
+
CELL = "cel_YmxhaGJsYWhibGFoYmxhaA"
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def _hold_item(**kw):
|
|
987
|
+
return {
|
|
988
|
+
"object": "cell_hold",
|
|
989
|
+
"cell_id": CELL,
|
|
990
|
+
"entity": "books",
|
|
991
|
+
"record_id": "00000000-0000-0000-0000-000000000001",
|
|
992
|
+
"record_name": "Emma",
|
|
993
|
+
"column": "author",
|
|
994
|
+
"value": "Austen",
|
|
995
|
+
"source": "chat_edit",
|
|
996
|
+
"set_at": "2026-09-25T10:00:00+00:00",
|
|
997
|
+
"pending_finding": {"evidence_id": "6f1c2d3e-0000-4000-8000-00000000f1d0", "value": "J. Austen", "source_urls": [],
|
|
998
|
+
"found_at": "2026-09-25T11:00:00+00:00", "stage": "extract"},
|
|
999
|
+
**kw,
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def test_holds_list_shows_the_users_value_beside_what_research_found(monkeypatch, capsys):
|
|
1004
|
+
_signed_in(monkeypatch)
|
|
1005
|
+
seen = {}
|
|
1006
|
+
|
|
1007
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
1008
|
+
seen["path"], seen["params"] = request.url.path, dict(request.url.params)
|
|
1009
|
+
return httpx.Response(200, json={"data": [_hold_item()], "has_more": False, "next_cursor": None})
|
|
1010
|
+
|
|
1011
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
1012
|
+
assert main(["holds", "list", "--pending"]) == 0
|
|
1013
|
+
assert seen["path"] == "/v1/holds" and seen["params"]["pending"] == "true"
|
|
1014
|
+
text = capsys.readouterr().out
|
|
1015
|
+
assert "Austen" in text and "J. Austen" in text
|
|
1016
|
+
# The finding id is printed, so `holds apply --finding` can name the value reviewed.
|
|
1017
|
+
assert _hold_item()["pending_finding"]["evidence_id"] in text
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def test_holds_apply_is_refused_in_a_pipe_without_yes_and_sends_nothing(monkeypatch, capsys):
|
|
1021
|
+
_signed_in(monkeypatch)
|
|
1022
|
+
monkeypatch.setattr("deepsieve_cli.main._tty", lambda: False)
|
|
1023
|
+
calls = []
|
|
1024
|
+
monkeypatch.setattr(httpx, "Client", _mock(lambda r: calls.append(r) or httpx.Response(200)))
|
|
1025
|
+
assert main(["holds", "apply", CELL]) == 2
|
|
1026
|
+
assert "pass --yes" in capsys.readouterr().err
|
|
1027
|
+
assert calls == []
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def test_holds_apply_posts_the_finding_and_the_idempotency_key(monkeypatch, capsys):
|
|
1031
|
+
_signed_in(monkeypatch)
|
|
1032
|
+
seen = {}
|
|
1033
|
+
|
|
1034
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
1035
|
+
seen["path"], seen["key"] = request.url.path, request.headers.get("idempotency-key")
|
|
1036
|
+
seen["body"] = json.loads(request.content or b"{}")
|
|
1037
|
+
return httpx.Response(200, json={"object": "cell", "column": "author", "value": "J. Austen",
|
|
1038
|
+
"held": True})
|
|
1039
|
+
|
|
1040
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
1041
|
+
rc = main(["holds", "apply", CELL, "--finding", CID, "--yes", "--idempotency-key", "k-2"])
|
|
1042
|
+
assert rc == 0
|
|
1043
|
+
assert (seen["path"], seen["key"]) == (f"/v1/cells/{CELL}/apply", "k-2")
|
|
1044
|
+
assert seen["body"] == {"finding_evidence_id": CID}
|
|
1045
|
+
assert "stays protected" in capsys.readouterr().out
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
def test_holds_release_refuses_a_malformed_cell_id_and_sends_nothing(monkeypatch, capsys):
|
|
1049
|
+
"""The id goes into the URL path, so a crafted one could reach another endpoint."""
|
|
1050
|
+
_signed_in(monkeypatch)
|
|
1051
|
+
calls = []
|
|
1052
|
+
monkeypatch.setattr(httpx, "Client", _mock(lambda r: calls.append(r) or httpx.Response(200)))
|
|
1053
|
+
assert main(["holds", "release", "../research/runs/x/cancel#", "--yes"]) == 2
|
|
1054
|
+
assert calls == []
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|