deepsieve-cli 0.5.0__tar.gz → 0.6.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.5.0 → deepsieve_cli-0.6.0}/PKG-INFO +7 -2
- {deepsieve_cli-0.5.0 → deepsieve_cli-0.6.0}/README.md +6 -1
- {deepsieve_cli-0.5.0 → deepsieve_cli-0.6.0}/pyproject.toml +1 -1
- {deepsieve_cli-0.5.0 → deepsieve_cli-0.6.0}/src/deepsieve_cli/main.py +453 -3
- {deepsieve_cli-0.5.0 → deepsieve_cli-0.6.0}/tests/test_cli.py +614 -2
- {deepsieve_cli-0.5.0 → deepsieve_cli-0.6.0}/.gitignore +0 -0
- {deepsieve_cli-0.5.0 → deepsieve_cli-0.6.0}/src/deepsieve_cli/__init__.py +0 -0
- {deepsieve_cli-0.5.0 → deepsieve_cli-0.6.0}/src/deepsieve_cli/__main__.py +0 -0
- {deepsieve_cli-0.5.0 → deepsieve_cli-0.6.0}/src/deepsieve_cli/client.py +0 -0
- {deepsieve_cli-0.5.0 → deepsieve_cli-0.6.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.6.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
|
|
@@ -31,6 +31,7 @@ deepsieve whoami # identity, organization, active B
|
|
|
31
31
|
deepsieve runs create --query "..." --dry-run # free simulated run (~15s)
|
|
32
32
|
deepsieve runs create --blueprint <id> --query "..." --wait # real run: spends credits
|
|
33
33
|
deepsieve runs create --blueprint <id> --seeds "..." --allowed-source "*.gov" # only cite these
|
|
34
|
+
deepsieve runs create --blueprint <id> --query "..." --expected-total 1000 # refuse (charge nothing) if it now costs more than the estimate
|
|
34
35
|
deepsieve runs list
|
|
35
36
|
deepsieve runs get <id> # exit 4 while still running
|
|
36
37
|
deepsieve runs cancel <id>
|
|
@@ -38,7 +39,11 @@ deepsieve members list # who is in the organization + sea
|
|
|
38
39
|
deepsieve changes list --live # merges, renames, successor links
|
|
39
40
|
deepsieve changes undo <id> # asks first; --yes in scripts
|
|
40
41
|
deepsieve merge preview <entity> --survivor <id> --loser <id> # writes nothing
|
|
41
|
-
deepsieve merge apply <entity> --survivor <id> --loser <id> [--choice KEY=loser]
|
|
42
|
+
deepsieve merge apply <entity> --survivor <id> --loser <id> [--choice KEY=loser] --preview-digest <digest>
|
|
43
|
+
deepsieve runs retry <id> # a retry that charges shows its total and asks
|
|
44
|
+
deepsieve runs reruns <id> # a report's re-research waiting for approval
|
|
45
|
+
deepsieve runs monitoring <id> --off # stop a report's monitoring; asks first
|
|
46
|
+
deepsieve links remove <entity> <record-id> <linked-entity> <target-id> # asks first
|
|
42
47
|
deepsieve data catalog # entities + columns (never guess)
|
|
43
48
|
deepsieve data get companies --receipts # rows with per-cell citations
|
|
44
49
|
deepsieve chat thread <run-id> # changes, with status + warnings
|
|
@@ -23,6 +23,7 @@ deepsieve whoami # identity, organization, active B
|
|
|
23
23
|
deepsieve runs create --query "..." --dry-run # free simulated run (~15s)
|
|
24
24
|
deepsieve runs create --blueprint <id> --query "..." --wait # real run: spends credits
|
|
25
25
|
deepsieve runs create --blueprint <id> --seeds "..." --allowed-source "*.gov" # only cite these
|
|
26
|
+
deepsieve runs create --blueprint <id> --query "..." --expected-total 1000 # refuse (charge nothing) if it now costs more than the estimate
|
|
26
27
|
deepsieve runs list
|
|
27
28
|
deepsieve runs get <id> # exit 4 while still running
|
|
28
29
|
deepsieve runs cancel <id>
|
|
@@ -30,7 +31,11 @@ deepsieve members list # who is in the organization + sea
|
|
|
30
31
|
deepsieve changes list --live # merges, renames, successor links
|
|
31
32
|
deepsieve changes undo <id> # asks first; --yes in scripts
|
|
32
33
|
deepsieve merge preview <entity> --survivor <id> --loser <id> # writes nothing
|
|
33
|
-
deepsieve merge apply <entity> --survivor <id> --loser <id> [--choice KEY=loser]
|
|
34
|
+
deepsieve merge apply <entity> --survivor <id> --loser <id> [--choice KEY=loser] --preview-digest <digest>
|
|
35
|
+
deepsieve runs retry <id> # a retry that charges shows its total and asks
|
|
36
|
+
deepsieve runs reruns <id> # a report's re-research waiting for approval
|
|
37
|
+
deepsieve runs monitoring <id> --off # stop a report's monitoring; asks first
|
|
38
|
+
deepsieve links remove <entity> <record-id> <linked-entity> <target-id> # asks first
|
|
34
39
|
deepsieve data catalog # entities + columns (never guess)
|
|
35
40
|
deepsieve data get companies --receipts # rows with per-cell citations
|
|
36
41
|
deepsieve chat thread <run-id> # changes, with status + warnings
|
|
@@ -16,6 +16,7 @@ from __future__ import annotations
|
|
|
16
16
|
|
|
17
17
|
import argparse
|
|
18
18
|
import json
|
|
19
|
+
import math
|
|
19
20
|
import os
|
|
20
21
|
import re
|
|
21
22
|
import sys
|
|
@@ -266,6 +267,15 @@ def cmd_runs_get(args) -> int:
|
|
|
266
267
|
return EXIT_OK if body.get("status") == "completed" else EXIT_FAIL
|
|
267
268
|
|
|
268
269
|
|
|
270
|
+
def _folded_text(folds: list[dict]) -> str:
|
|
271
|
+
"""gh #2716 — the seeds a request counted once, in one line (the first 4, then a count)."""
|
|
272
|
+
if not folds:
|
|
273
|
+
return ""
|
|
274
|
+
shown = ", ".join(f'"{f.get("seed")}" as "{f.get("kept_as")}"' for f in folds[:4])
|
|
275
|
+
more = f" and {len(folds) - 4} more" if len(folds) > 4 else ""
|
|
276
|
+
return f"{len(folds)} counted once: {shown}{more}."
|
|
277
|
+
|
|
278
|
+
|
|
269
279
|
def cmd_runs_create(args) -> int:
|
|
270
280
|
client, prof = _require_auth(args)
|
|
271
281
|
# At most one. NEITHER is a paid broad sweep of the whole Blueprint schema
|
|
@@ -291,8 +301,15 @@ def cmd_runs_create(args) -> int:
|
|
|
291
301
|
# #1802 — repeatable; the server resolves and refuses, the CLI only carries.
|
|
292
302
|
# Only blanks given → no list at all, never an empty one (the server 400s on []).
|
|
293
303
|
body["allowed_sources"] = allowed
|
|
304
|
+
excluded = [s.strip() for s in (getattr(args, "excluded_source", None) or []) if s.strip()]
|
|
305
|
+
if excluded:
|
|
306
|
+
# gh #2641 — repeatable; the server resolves and refuses, the CLI only carries.
|
|
307
|
+
body["excluded_sources"] = excluded
|
|
294
308
|
if getattr(args, "blueprint", None):
|
|
295
309
|
body["blueprint_id"] = args.blueprint
|
|
310
|
+
if getattr(args, "expected_total", None) is not None:
|
|
311
|
+
# gh #2710 — bind the run to the estimate: a higher total is refused, never charged.
|
|
312
|
+
body["expected_total_cents"] = args.expected_total
|
|
296
313
|
|
|
297
314
|
# A real run spends money. Confirm interactively unless told not to — and
|
|
298
315
|
# never silently in a pipe, where nobody is watching.
|
|
@@ -317,6 +334,17 @@ def cmd_runs_create(args) -> int:
|
|
|
317
334
|
headers={"Idempotency-Key": args.idempotency_key or uuid.uuid4().hex},
|
|
318
335
|
)
|
|
319
336
|
except ApiError as exc:
|
|
337
|
+
if exc.code == "run_price_changed":
|
|
338
|
+
# gh #2710 — the run would now cost more than --expected-total. Nothing was charged.
|
|
339
|
+
total = exc.details.get("total_price_cents")
|
|
340
|
+
if isinstance(total, int) and not isinstance(total, bool):
|
|
341
|
+
ask = (f"The price for this run changed since it was estimated: it is now "
|
|
342
|
+
f"${total / 100:,.2f}. Nothing was charged.")
|
|
343
|
+
if args.json:
|
|
344
|
+
print(json.dumps({"error": {"code": exc.code, "message": ask,
|
|
345
|
+
"details": exc.details}}))
|
|
346
|
+
err(f"{ask} Pass --expected-total {total} to start at the new price.")
|
|
347
|
+
return EXIT_USAGE
|
|
320
348
|
err(str(exc))
|
|
321
349
|
# A --query is parsed by a model: the org's model allowance can refuse it (gh #2307).
|
|
322
350
|
return _api_exit(exc)
|
|
@@ -326,6 +354,9 @@ def cmd_runs_create(args) -> int:
|
|
|
326
354
|
return EXIT_OK
|
|
327
355
|
run_id = created.get("id")
|
|
328
356
|
print(f"started run {run_id} ({'dry run' if args.dry_run else 'billable'})")
|
|
357
|
+
folded = _folded_text(created.get("folded_duplicates") or [])
|
|
358
|
+
if folded:
|
|
359
|
+
print(folded)
|
|
329
360
|
bp = created.get("blueprint") or {}
|
|
330
361
|
if bp:
|
|
331
362
|
print(f"Blueprint: {bp.get('name')} ({bp.get('id')})")
|
|
@@ -472,6 +503,288 @@ def cmd_runs_monitoring(args) -> int:
|
|
|
472
503
|
return EXIT_OK
|
|
473
504
|
|
|
474
505
|
|
|
506
|
+
def _reruns_summary(rid: str, v: dict) -> str:
|
|
507
|
+
waiting = v.get("awaiting_count") or 0
|
|
508
|
+
lines = [
|
|
509
|
+
f"{waiting} waiting for re-research on {rid}"
|
|
510
|
+
+ (f", {_usd(v.get('awaiting_total_cents') or 0)} to approve now" if waiting else "")
|
|
511
|
+
]
|
|
512
|
+
for it in v.get("items") or []:
|
|
513
|
+
what = it.get("column_label") or it.get("column") or "a fact"
|
|
514
|
+
who = it.get("entity") or "an entity"
|
|
515
|
+
lines.append(f" - {who}" + (f", for {it['child']}" if it.get("child") else "") + f": {what}")
|
|
516
|
+
if waiting > len(v.get("items") or []):
|
|
517
|
+
lines.append(f" … and {waiting - len(v.get('items') or [])} more")
|
|
518
|
+
if v.get("quote_withheld"):
|
|
519
|
+
lines.append(v["quote_withheld"])
|
|
520
|
+
lines.append(
|
|
521
|
+
f"included this period: {v.get('included_used', 0)}/{v.get('included_per_period', 0)}; "
|
|
522
|
+
f"renews {v.get('next_period_start') or 'unknown'}"
|
|
523
|
+
)
|
|
524
|
+
if v.get("auto_approve"):
|
|
525
|
+
lines.append(
|
|
526
|
+
f"auto-approve ON, up to {_usd(v.get('auto_approve_cap_cents') or 0)}/month "
|
|
527
|
+
f"({_usd(v.get('auto_approve_spent_cents') or 0)} spent)"
|
|
528
|
+
)
|
|
529
|
+
return "\n".join(lines)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
#: The API's ceiling on a monthly auto-approve cap, in cents (`maximum` in the contract; the
|
|
533
|
+
#: column is a Postgres INTEGER). Checked here so the CLI refuses before it asks.
|
|
534
|
+
_AUTO_APPROVE_CAP_MAX_CENTS = 2_147_483_647
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def cmd_runs_reruns(args) -> int:
|
|
538
|
+
"""A report's re-research waiting for approval (gh #2351): show it, approve it, or turn
|
|
539
|
+
auto-approve on or off. Approving SPENDS money: it needs --expected-total and --quote-id (both
|
|
540
|
+
from what you were shown), or an interactive yes to what was fetched just now; never a silent
|
|
541
|
+
yes in a pipe. The quote pins the exact rows, so an approval never buys a rerun you were not
|
|
542
|
+
shown. There is no decline: a rerun left waiting costs nothing and is decided again at
|
|
543
|
+
renewal.
|
|
544
|
+
|
|
545
|
+
Turning auto-approve ON (gh #2506) is standing authority to spend on this report's future
|
|
546
|
+
re-research, up to a monthly limit you must give (--cap, in dollars). It is recorded against
|
|
547
|
+
the key or app you are signed in with: it switches off if that key is revoked or expires, or
|
|
548
|
+
you sign out everywhere, and pauses while the key is suspended. Confirmed like approving:
|
|
549
|
+
interactively, or with --yes, never silently in a pipe."""
|
|
550
|
+
rid = _uuid_arg(args.run_id, "run_id")
|
|
551
|
+
if rid is None:
|
|
552
|
+
return EXIT_USAGE
|
|
553
|
+
client, _ = _require_auth(args)
|
|
554
|
+
base = f"/v1/research/runs/{rid}/reruns"
|
|
555
|
+
if not args.auto_approve_on and (args.cap is not None or args.yes):
|
|
556
|
+
# seat-2506b F5: these belong to --auto-approve-on alone; silently ignoring them let a
|
|
557
|
+
# user believe --yes had confirmed something else.
|
|
558
|
+
err("--cap and --yes go with --auto-approve-on")
|
|
559
|
+
return EXIT_USAGE
|
|
560
|
+
try:
|
|
561
|
+
if args.auto_approve_on:
|
|
562
|
+
# seat-2506b F3/F4: a cap that is not a finite number, or that rounds to less than
|
|
563
|
+
# the API's 1 cent, is refused here (it used to traceback, or confirm "$0.00").
|
|
564
|
+
cap_ok = args.cap is not None and math.isfinite(args.cap) and args.cap > 0
|
|
565
|
+
cap_cents = int(round(args.cap * 100)) if cap_ok else 0
|
|
566
|
+
if cap_cents < 1 or cap_cents > _AUTO_APPROVE_CAP_MAX_CENTS:
|
|
567
|
+
err(
|
|
568
|
+
"turning auto-approve on needs --cap USD: the most it may spend a month, "
|
|
569
|
+
f"from $0.01 to {_usd(_AUTO_APPROVE_CAP_MAX_CENTS)}"
|
|
570
|
+
)
|
|
571
|
+
return EXIT_USAGE
|
|
572
|
+
what = (
|
|
573
|
+
f"Turn on auto-approve for {rid}? Its future re-research is approved and charged "
|
|
574
|
+
f"automatically, up to {_usd(cap_cents)} a month. It switches off if the key or "
|
|
575
|
+
"app you are signed in with is revoked."
|
|
576
|
+
)
|
|
577
|
+
if not args.yes:
|
|
578
|
+
if not _interactive():
|
|
579
|
+
err("refusing to turn on auto-approve non-interactively (it spends money); "
|
|
580
|
+
"pass --yes")
|
|
581
|
+
return EXIT_USAGE
|
|
582
|
+
if not _yes(f"{what} [y/N] "):
|
|
583
|
+
print("Cancelled. Auto-approve was not changed.")
|
|
584
|
+
return EXIT_OK
|
|
585
|
+
body = client.request(
|
|
586
|
+
"PATCH",
|
|
587
|
+
f"{base}/auto-approve",
|
|
588
|
+
json_body={"auto_approve": True, "auto_approve_cap_cents": cap_cents},
|
|
589
|
+
)
|
|
590
|
+
out(body, args.json, _reruns_summary(rid, body))
|
|
591
|
+
return EXIT_OK
|
|
592
|
+
if args.auto_approve_off:
|
|
593
|
+
body = client.request("PATCH", f"{base}/auto-approve", json_body={"auto_approve": False})
|
|
594
|
+
out(body, args.json, _reruns_summary(rid, body))
|
|
595
|
+
return EXIT_OK
|
|
596
|
+
if not args.approve:
|
|
597
|
+
body = client.request("GET", base)
|
|
598
|
+
out(body, args.json, _reruns_summary(rid, body))
|
|
599
|
+
return EXIT_OK
|
|
600
|
+
total, quote_id = args.expected_total, args.quote_id
|
|
601
|
+
if total is None or not quote_id:
|
|
602
|
+
if not _interactive():
|
|
603
|
+
err("approving spends money: pass --expected-total CENTS and --quote-id ID "
|
|
604
|
+
"(both from `deepsieve runs reruns RID --json`)")
|
|
605
|
+
return EXIT_USAGE
|
|
606
|
+
state = client.request("GET", base)
|
|
607
|
+
print(_reruns_summary(rid, state))
|
|
608
|
+
total = int(state.get("awaiting_total_cents") or 0)
|
|
609
|
+
quote_id = state.get("quote_id") or ""
|
|
610
|
+
if not state.get("awaiting_count"):
|
|
611
|
+
print("Nothing is waiting.")
|
|
612
|
+
return EXIT_OK
|
|
613
|
+
if not quote_id:
|
|
614
|
+
# gh #2351 delta review: past what the API will list there is no quote, and asking
|
|
615
|
+
# "Approve N?" over rows the list could not show is asking for a blind yes.
|
|
616
|
+
err("these cannot be approved here (the reason is above). Nothing was charged.")
|
|
617
|
+
return EXIT_USAGE
|
|
618
|
+
if not _yes(f"Approve {state['awaiting_count']} for {_usd(total)}? [y/N] "):
|
|
619
|
+
print("Cancelled. Nothing was charged.")
|
|
620
|
+
return EXIT_OK
|
|
621
|
+
body = client.request(
|
|
622
|
+
"POST",
|
|
623
|
+
f"{base}/approve",
|
|
624
|
+
json_body={"expected_total_cents": total, "quote_id": quote_id},
|
|
625
|
+
headers={"Idempotency-Key": uuid.uuid4().hex},
|
|
626
|
+
)
|
|
627
|
+
except ApiError as exc:
|
|
628
|
+
if exc.code == "rerun_price_changed":
|
|
629
|
+
if not (exc.details or {}).get("count"):
|
|
630
|
+
err("nothing is waiting any more (it was approved, or retired). Nothing was charged.")
|
|
631
|
+
else:
|
|
632
|
+
err(
|
|
633
|
+
f"what is waiting changed: {exc.details['count']} now, costing "
|
|
634
|
+
f"{_usd(exc.details.get('total_cents') or 0)}. "
|
|
635
|
+
"Nothing was charged; run it again to see the new list."
|
|
636
|
+
)
|
|
637
|
+
else:
|
|
638
|
+
err(str(exc))
|
|
639
|
+
return _api_exit(exc)
|
|
640
|
+
human = f"approved {body.get('approved', 0)} for {rid}; {body.get('still_waiting', 0)} still waiting"
|
|
641
|
+
if body.get("stopped"):
|
|
642
|
+
human += f"\nstopped part-way: {body['stopped']}"
|
|
643
|
+
out(body, args.json, human)
|
|
644
|
+
return EXIT_OK
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _usd(cents) -> str:
|
|
648
|
+
return f"${cents / 100:,.2f}"
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _refund_line(refund: dict | None) -> str:
|
|
652
|
+
"""What became of the earlier run's charge (gh #2389), in the app's words (site
|
|
653
|
+
`refundHeadline`), so the CLI and the Retry dialog never state it differently."""
|
|
654
|
+
refund = refund or {}
|
|
655
|
+
status, kind = refund.get("run_status"), refund.get("kind")
|
|
656
|
+
run = {"failed": "failed run", "cancelled": "stopped run"}.get(status, "earlier run")
|
|
657
|
+
amount, credits, units = refund.get("amount_cents"), refund.get("credits"), refund.get("units")
|
|
658
|
+
plural = lambda n, one, many: f"{n} {one if n == 1 else many}" # noqa: E731
|
|
659
|
+
if kind == "refunded_dollars":
|
|
660
|
+
return (f"Your {run} was refunded. {_usd(amount)} is back on your account."
|
|
661
|
+
if amount is not None else f"Your {run} was refunded in full.")
|
|
662
|
+
if kind == "refunded_free_run":
|
|
663
|
+
return f"Your {run} was refunded. Your free run is back."
|
|
664
|
+
if kind == "refunded_credits":
|
|
665
|
+
return (f"Your {run} was refunded. {plural(credits, 'run credit is', 'run credits are')} back."
|
|
666
|
+
if credits is not None else f"Your {run} was refunded. Its run credits are back.")
|
|
667
|
+
if kind == "allowance_restored":
|
|
668
|
+
return (f"Your {run} didn't fully count. {plural(units, 'included rerun is', 'included reruns are')} back."
|
|
669
|
+
if units is not None else f"Your {run} didn't count. Its included rerun is back.")
|
|
670
|
+
if kind == "not_charged":
|
|
671
|
+
return f"Your {run} wasn't charged."
|
|
672
|
+
if kind == "kept_free_run":
|
|
673
|
+
if status in ("completed", None):
|
|
674
|
+
return ("Your earlier run used your free run. It finished with no results, and a "
|
|
675
|
+
"finished run isn't refunded.")
|
|
676
|
+
return f"Your {run} used your free run. Part of it returned results, so your free run stays used."
|
|
677
|
+
if kind == "kept_credits":
|
|
678
|
+
used = plural(credits, "run credit", "run credits") if credits is not None else "run credits"
|
|
679
|
+
return f"Your earlier run used {used}. It finished with no results, and a finished run isn't refunded."
|
|
680
|
+
if kind == "refunded_to_ended_plan":
|
|
681
|
+
return f"Your plan has ended, so the credits for your {run} were returned to it and can't be used."
|
|
682
|
+
if kind == "refunded_to_ended_period":
|
|
683
|
+
# gh #2460: the plan is still running; an earlier billing period's allowance ended.
|
|
684
|
+
return (f"The credits for your {run} went back to the billing period they came from, "
|
|
685
|
+
"which has ended, so they can't be used again.")
|
|
686
|
+
if kind == "refunded_unknown":
|
|
687
|
+
return f"Your {run} was refunded in full."
|
|
688
|
+
if kind == "refund_pending":
|
|
689
|
+
return f"Your {run} is being refunded."
|
|
690
|
+
if kind == "partially_refunded":
|
|
691
|
+
lead = f"Part of your {run} returned nothing, and that part was refunded."
|
|
692
|
+
if amount is not None:
|
|
693
|
+
return f"{lead} {_usd(amount)} is back on your account."
|
|
694
|
+
if credits is not None:
|
|
695
|
+
return f"{lead} {plural(credits, 'run credit is', 'run credits are')} back."
|
|
696
|
+
return lead
|
|
697
|
+
return "We can't confirm yet whether your earlier run was refunded. We're checking it."
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _retry_cost(details: dict) -> str:
|
|
701
|
+
"""What would pay, from the quote's `funding` (seat-2389 M3): `total_price_cents` is the
|
|
702
|
+
LIST price even when the free run or plan credits cover the retry."""
|
|
703
|
+
total = details.get("total_price_cents")
|
|
704
|
+
funding = details.get("funding") or {}
|
|
705
|
+
if funding.get("covered_by") == "free_run":
|
|
706
|
+
return f"uses your free run (list price {_usd(total)})"
|
|
707
|
+
if funding.get("covered_by") == "plan_credits":
|
|
708
|
+
n = funding.get("plan_credits_needed")
|
|
709
|
+
return f"uses {n} plan run credit{'' if n == 1 else 's'} (list price {_usd(total)})"
|
|
710
|
+
cost = f"charges {_usd(total)}"
|
|
711
|
+
if (details.get("shortfall_cents") or 0) > 0:
|
|
712
|
+
cost += f", and your balance is {_usd(details['shortfall_cents'])} short"
|
|
713
|
+
return cost
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _retry_quote_text(details: dict, moved: bool) -> str:
|
|
717
|
+
parts = [
|
|
718
|
+
"The price changed since the quote." if moved else "",
|
|
719
|
+
_refund_line(details.get("refund")),
|
|
720
|
+
f"Retrying starts a new run and {_retry_cost(details)}.",
|
|
721
|
+
]
|
|
722
|
+
for key, what in (
|
|
723
|
+
("reuse_predicted", "carried over at no charge"),
|
|
724
|
+
("skipped_already_held", "already in the report, skipped"),
|
|
725
|
+
):
|
|
726
|
+
names = details.get(key) or []
|
|
727
|
+
if names:
|
|
728
|
+
parts.append(f"{len(names)} {what}: {', '.join(names[:4])}"
|
|
729
|
+
+ (f" and {len(names) - 4} more" if len(names) > 4 else "") + ".")
|
|
730
|
+
return " ".join(p for p in parts if p)
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def cmd_runs_retry(args) -> int:
|
|
734
|
+
"""Retry a failed, stopped or empty run, as the app's Retry does (gh #2389).
|
|
735
|
+
|
|
736
|
+
A retry that charges nothing starts at once. A retry that will CHARGE is refused by the API
|
|
737
|
+
(`retry_confirmation_required`) until it is sent with the total it was quoted: here, an
|
|
738
|
+
explicit question showing the refund and the total, or --expected-total CENTS. In a pipe it
|
|
739
|
+
never confirms by itself. A price that moved meanwhile (`retry_price_changed`) is asked again
|
|
740
|
+
with the new total; nothing is charged in between."""
|
|
741
|
+
rid = _uuid_arg(args.run_id, "run_id")
|
|
742
|
+
if rid is None:
|
|
743
|
+
return EXIT_USAGE
|
|
744
|
+
client, _ = _require_auth(args)
|
|
745
|
+
path = f"/v1/research/runs/{rid}/retry"
|
|
746
|
+
payload: dict = {}
|
|
747
|
+
if args.expected_total is not None:
|
|
748
|
+
payload["expected_total_cents"] = args.expected_total
|
|
749
|
+
# At most two refusals: the quote, and one re-quote if the price moved while the person was
|
|
750
|
+
# deciding. A third would be the API contradicting itself; stop rather than loop, and never ask
|
|
751
|
+
# a question whose yes would not be sent (seat-2389 L1).
|
|
752
|
+
attempts = 3
|
|
753
|
+
body = None
|
|
754
|
+
for attempt in range(attempts):
|
|
755
|
+
try:
|
|
756
|
+
body = client.request("POST", path, json_body=payload)
|
|
757
|
+
break
|
|
758
|
+
except ApiError as exc:
|
|
759
|
+
if exc.code not in ("retry_confirmation_required", "retry_price_changed"):
|
|
760
|
+
err(str(exc))
|
|
761
|
+
return _api_exit(exc)
|
|
762
|
+
total = exc.details.get("total_price_cents")
|
|
763
|
+
if not isinstance(total, int) or isinstance(total, bool):
|
|
764
|
+
# No quote to state: never prompt "$0.00" (seat-2389 L2).
|
|
765
|
+
err(f"{exc} (the API sent no total to confirm; nothing was charged)")
|
|
766
|
+
return EXIT_FAIL
|
|
767
|
+
ask = _retry_quote_text(exc.details, moved=exc.code == "retry_price_changed")
|
|
768
|
+
if not _interactive():
|
|
769
|
+
if args.json:
|
|
770
|
+
print(json.dumps({"error": {"code": exc.code, "message": ask, "details": exc.details}}))
|
|
771
|
+
err(f"{ask} Pass --expected-total {total} to confirm that charge.")
|
|
772
|
+
return EXIT_USAGE
|
|
773
|
+
if attempt == attempts - 1:
|
|
774
|
+
break # the last refusal: no request would follow, so ask nothing
|
|
775
|
+
if not _yes(f"{ask} Retry at {_usd(total)}? [y/N] "):
|
|
776
|
+
print("Cancelled. Nothing was charged.")
|
|
777
|
+
return EXIT_OK
|
|
778
|
+
payload = {"expected_total_cents": total}
|
|
779
|
+
if body is None:
|
|
780
|
+
err("the API kept refusing the confirmation; nothing was charged")
|
|
781
|
+
return EXIT_FAIL
|
|
782
|
+
retry = body.get("retry") or {}
|
|
783
|
+
how = "resumed" if retry.get("mode") == "resumed" else f"restarted as {body.get('id')}"
|
|
784
|
+
out(body, args.json, f"retry of {rid} {how}")
|
|
785
|
+
return EXIT_OK
|
|
786
|
+
|
|
787
|
+
|
|
475
788
|
def _uuid_arg(value: str, what: str) -> str | None:
|
|
476
789
|
"""An id interpolated into a URL path must be a UUID, never a path fragment."""
|
|
477
790
|
import uuid as _uuid # noqa: PLC0415
|
|
@@ -577,6 +890,9 @@ def _describe_change(c: dict) -> str:
|
|
|
577
890
|
return f"renamed {c.get('old_name')} to {c.get('new_name')}"
|
|
578
891
|
if kind == "succeeded_by":
|
|
579
892
|
return f"{c.get('old_name')} succeeded by {c.get('new_name')}"
|
|
893
|
+
if kind == "link_removed":
|
|
894
|
+
link = c.get("link") or {}
|
|
895
|
+
return f"removed {link.get('target_name') or link.get('target_id')} from {link.get('record_name')}"
|
|
580
896
|
return str(kind)
|
|
581
897
|
|
|
582
898
|
|
|
@@ -785,10 +1101,15 @@ def _print_merge(body: dict) -> None:
|
|
|
785
1101
|
if ties:
|
|
786
1102
|
print(f"\nopen ties ({len(ties)}): settle each with --choice KEY=survivor|loser, "
|
|
787
1103
|
"or pass --accept-survivor-on-ties")
|
|
1104
|
+
# #2409: the apply confirms THIS merge by its digest; a merge that changed since is refused.
|
|
1105
|
+
if body.get("preview_digest") and not body.get("applied"):
|
|
1106
|
+
print(f"\nto apply exactly this merge: --preview-digest {body['preview_digest']}")
|
|
788
1107
|
|
|
789
1108
|
|
|
790
1109
|
#: Merge refusals fixed by changing the command, not by retrying: exit 2, not 1.
|
|
791
|
-
_MERGE_USAGE_CODES = frozenset(
|
|
1110
|
+
_MERGE_USAGE_CODES = frozenset(
|
|
1111
|
+
{"ties_unresolved", "merge_across_parents", "merge_changed", "preview_required"}
|
|
1112
|
+
)
|
|
792
1113
|
|
|
793
1114
|
|
|
794
1115
|
def cmd_merge_preview(args) -> int:
|
|
@@ -817,6 +1138,15 @@ def cmd_merge_apply(args) -> int:
|
|
|
817
1138
|
if body is None:
|
|
818
1139
|
return EXIT_USAGE
|
|
819
1140
|
body["accept_survivor_on_ties"] = bool(args.accept_survivor_on_ties)
|
|
1141
|
+
# #2409: confirm the merge you previewed, or say you are merging blind. Neither is refused by
|
|
1142
|
+
# the API (preview_required); both is a contradiction, refused here before any call.
|
|
1143
|
+
if args.preview_digest and args.without_preview:
|
|
1144
|
+
err("pass --preview-digest or --without-preview, not both")
|
|
1145
|
+
return EXIT_USAGE
|
|
1146
|
+
if args.preview_digest:
|
|
1147
|
+
body["preview_digest"] = args.preview_digest
|
|
1148
|
+
if args.without_preview:
|
|
1149
|
+
body["apply_without_preview"] = True
|
|
820
1150
|
if not args.yes:
|
|
821
1151
|
if not _interactive():
|
|
822
1152
|
err("refusing to change data non-interactively; pass --yes")
|
|
@@ -830,6 +1160,14 @@ def cmd_merge_apply(args) -> int:
|
|
|
830
1160
|
out_ = client.request("POST", "/v1/merges", json_body=body, headers=headers)
|
|
831
1161
|
except ApiError as exc:
|
|
832
1162
|
err(str(exc))
|
|
1163
|
+
if exc.code == "merge_changed" and exc.details.get("preview"):
|
|
1164
|
+
# #2409: what the merge would do NOW, so the person sees what moved. Under --json it
|
|
1165
|
+
# stays JSON on stdout (seat-2409 5c): a script parses it, never human text.
|
|
1166
|
+
if args.json:
|
|
1167
|
+
out(exc.details["preview"], True)
|
|
1168
|
+
else:
|
|
1169
|
+
print("\nthe merge as it stands now:")
|
|
1170
|
+
_print_merge(exc.details["preview"])
|
|
833
1171
|
# An open tie is fixed by changing the command (--choice, or
|
|
834
1172
|
# --accept-survivor-on-ties), so it exits as a usage error: a script can tell it
|
|
835
1173
|
# apart from a failure without --json. So is a merge across parents (#2254): no retry
|
|
@@ -843,6 +1181,47 @@ def cmd_merge_apply(args) -> int:
|
|
|
843
1181
|
return EXIT_OK
|
|
844
1182
|
|
|
845
1183
|
|
|
1184
|
+
def cmd_links_remove(args) -> int:
|
|
1185
|
+
"""Remove one many-to-many link from a record (gh #2514). Research does not add it back.
|
|
1186
|
+
It changes the dataset, so it is confirmed like a merge: interactively, or with --yes,
|
|
1187
|
+
never silently in a pipe. Undo it with `deepsieve changes undo <change id>`."""
|
|
1188
|
+
client, _ = _require_auth(args)
|
|
1189
|
+
for value, name in ((args.record_id, "record id"), (args.target_id, "target id")):
|
|
1190
|
+
try:
|
|
1191
|
+
uuid.UUID(value)
|
|
1192
|
+
except ValueError:
|
|
1193
|
+
err(f"{name} must be a UUID")
|
|
1194
|
+
return EXIT_USAGE
|
|
1195
|
+
body = {
|
|
1196
|
+
"entity": args.entity,
|
|
1197
|
+
"record_id": args.record_id,
|
|
1198
|
+
"to_entity": args.to_entity,
|
|
1199
|
+
"target_id": args.target_id,
|
|
1200
|
+
}
|
|
1201
|
+
if args.blueprint:
|
|
1202
|
+
body["blueprint_id"] = args.blueprint
|
|
1203
|
+
if not args.yes:
|
|
1204
|
+
if not _interactive():
|
|
1205
|
+
err("refusing to change data non-interactively; pass --yes")
|
|
1206
|
+
return EXIT_USAGE
|
|
1207
|
+
prompt = (
|
|
1208
|
+
f"Remove the {args.to_entity} link {args.target_id} from {args.entity} "
|
|
1209
|
+
f"{args.record_id}? Research will not add it back. [y/N] "
|
|
1210
|
+
)
|
|
1211
|
+
if input(prompt).strip().lower() not in ("y", "yes"):
|
|
1212
|
+
print("Cancelled.")
|
|
1213
|
+
return EXIT_OK
|
|
1214
|
+
headers = {"Idempotency-Key": args.idempotency_key} if args.idempotency_key else None
|
|
1215
|
+
try:
|
|
1216
|
+
out_ = client.request("POST", "/v1/links/remove", json_body=body, headers=headers)
|
|
1217
|
+
except ApiError as exc:
|
|
1218
|
+
err(str(exc))
|
|
1219
|
+
return _api_exit(exc)
|
|
1220
|
+
human = f"{_describe_change(out_)}. Undo: deepsieve changes undo {out_.get('change_id')}"
|
|
1221
|
+
out(out_, args.json, human)
|
|
1222
|
+
return EXIT_OK
|
|
1223
|
+
|
|
1224
|
+
|
|
846
1225
|
def _blueprint_id(client, args) -> str | None:
|
|
847
1226
|
"""The Blueprint these commands address: ``--blueprint``, else the active one.
|
|
848
1227
|
|
|
@@ -1146,6 +1525,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1146
1525
|
help="restrict evidence to this source (repeatable): a host, *.gov, "
|
|
1147
1526
|
"https://host/path/*, or blueprint:primary; a cell cited only "
|
|
1148
1527
|
"elsewhere is not written")
|
|
1528
|
+
rc.add_argument("--excluded-source", action="append", metavar="PATTERN",
|
|
1529
|
+
help="never cite this source (repeatable): a host, *.tld or "
|
|
1530
|
+
"https://host/path/*; a citation on it is not written")
|
|
1149
1531
|
rc.add_argument("--blueprint", help="Blueprint id to research; REQUIRED unless --dry-run "
|
|
1150
1532
|
"(`deepsieve whoami` shows your active Blueprint's id)")
|
|
1151
1533
|
rc.add_argument("--depth", default="standard", choices=["standard", "max"])
|
|
@@ -1157,6 +1539,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1157
1539
|
rc.add_argument("--timeout", type=int, default=3600)
|
|
1158
1540
|
rc.add_argument("--idempotency-key")
|
|
1159
1541
|
rc.add_argument("-y", "--yes", action="store_true", help="skip the billable-run confirmation")
|
|
1542
|
+
rc.add_argument(
|
|
1543
|
+
"--expected-total",
|
|
1544
|
+
type=int,
|
|
1545
|
+
default=None,
|
|
1546
|
+
metavar="CENTS",
|
|
1547
|
+
help="the estimate's run_price_cents: refuse (and charge nothing) if the run now costs more",
|
|
1548
|
+
)
|
|
1160
1549
|
rc.set_defaults(func=cmd_runs_create)
|
|
1161
1550
|
|
|
1162
1551
|
rw = runs.add_parser("wait", help="poll an existing run to completion", parents=[common])
|
|
@@ -1169,6 +1558,21 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1169
1558
|
rx.add_argument("run_id")
|
|
1170
1559
|
rx.set_defaults(func=cmd_runs_cancel)
|
|
1171
1560
|
|
|
1561
|
+
rr = runs.add_parser(
|
|
1562
|
+
"retry",
|
|
1563
|
+
help="retry a failed, stopped or empty run (a retry that charges asks first)",
|
|
1564
|
+
parents=[common],
|
|
1565
|
+
)
|
|
1566
|
+
rr.add_argument("run_id")
|
|
1567
|
+
rr.add_argument(
|
|
1568
|
+
"--expected-total",
|
|
1569
|
+
type=int,
|
|
1570
|
+
default=None,
|
|
1571
|
+
metavar="CENTS",
|
|
1572
|
+
help="confirm a charging retry at the total you were quoted, in cents",
|
|
1573
|
+
)
|
|
1574
|
+
rr.set_defaults(func=cmd_runs_retry)
|
|
1575
|
+
|
|
1172
1576
|
rm = runs.add_parser(
|
|
1173
1577
|
"monitoring",
|
|
1174
1578
|
help="turn a report's monitoring on or off (ON bills daily past the free 30 days)",
|
|
@@ -1191,6 +1595,37 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1191
1595
|
)
|
|
1192
1596
|
rm.set_defaults(func=cmd_runs_monitoring)
|
|
1193
1597
|
|
|
1598
|
+
rq = runs.add_parser(
|
|
1599
|
+
"reruns",
|
|
1600
|
+
help="a report's re-research waiting for approval: show, --approve (spends), or "
|
|
1601
|
+
"--auto-approve-on/--auto-approve-off",
|
|
1602
|
+
parents=[common],
|
|
1603
|
+
)
|
|
1604
|
+
rq.add_argument("run_id")
|
|
1605
|
+
act = rq.add_mutually_exclusive_group()
|
|
1606
|
+
act.add_argument("--approve", action="store_true", help="approve everything waiting (spends money)")
|
|
1607
|
+
rq.add_argument(
|
|
1608
|
+
"--expected-total",
|
|
1609
|
+
type=int,
|
|
1610
|
+
default=None,
|
|
1611
|
+
metavar="CENTS",
|
|
1612
|
+
help="with --approve: the total you were shown, in cents; refused if the price moved. "
|
|
1613
|
+
"Without --quote-id it is ignored: in a terminal the list and total are fetched and you "
|
|
1614
|
+
"confirm those",
|
|
1615
|
+
)
|
|
1616
|
+
rq.add_argument("--quote-id", default="", metavar="ID",
|
|
1617
|
+
help="with --approve: the quote_id you were shown; refused if what is waiting changed")
|
|
1618
|
+
act.add_argument("--auto-approve-off", action="store_true",
|
|
1619
|
+
help="stop approving this report's future re-research automatically")
|
|
1620
|
+
act.add_argument("--auto-approve-on", action="store_true",
|
|
1621
|
+
help="approve this report's future re-research automatically, up to --cap a "
|
|
1622
|
+
"month (spends money; it switches off if your key is revoked)")
|
|
1623
|
+
rq.add_argument("--cap", type=float, default=None, metavar="USD",
|
|
1624
|
+
help="with --auto-approve-on: the most it may spend a month, in dollars (required)")
|
|
1625
|
+
rq.add_argument("--yes", action="store_true",
|
|
1626
|
+
help="with --auto-approve-on: do not ask for confirmation (required in a pipe)")
|
|
1627
|
+
rq.set_defaults(func=cmd_runs_reruns)
|
|
1628
|
+
|
|
1194
1629
|
chat = sub.add_parser("chat", help="a run's chat and the changes it proposed", parents=[common]).add_subparsers(dest="sub", required=True)
|
|
1195
1630
|
ct = chat.add_parser("thread", help="the thread, with each change's status and warnings", parents=[common])
|
|
1196
1631
|
ct.add_argument("run_id")
|
|
@@ -1208,10 +1643,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1208
1643
|
ml = members.add_parser("list", help="who is in the organization, and seat usage", parents=[common])
|
|
1209
1644
|
ml.set_defaults(func=cmd_members_list)
|
|
1210
1645
|
|
|
1211
|
-
changes = sub.add_parser("changes", help="merges, renames and
|
|
1646
|
+
changes = sub.add_parser("changes", help="merges, renames, successor links and removed links", parents=[common]).add_subparsers(dest="sub", required=True)
|
|
1212
1647
|
cl = changes.add_parser("list", help="newest first; undone ones included unless --live", parents=[common])
|
|
1213
1648
|
cl.add_argument("--entity", help="only this entity's records (an entity key)")
|
|
1214
|
-
cl.add_argument("--kind", choices=["merge", "renamed", "succeeded_by"])
|
|
1649
|
+
cl.add_argument("--kind", choices=["merge", "renamed", "succeeded_by", "link_removed"])
|
|
1215
1650
|
cl.add_argument("--by", choices=["user", "automatic", "operator"], help="who made the change")
|
|
1216
1651
|
cl.add_argument("--live", action="store_true", help="leave out undone changes")
|
|
1217
1652
|
cl.add_argument("--limit", type=int, default=25)
|
|
@@ -1226,6 +1661,17 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1226
1661
|
help="undo a merge even though cells it filled changed since")
|
|
1227
1662
|
cu.set_defaults(func=cmd_changes_undo)
|
|
1228
1663
|
|
|
1664
|
+
links = sub.add_parser("links", help="a record's many-to-many links", parents=[common]).add_subparsers(dest="sub", required=True)
|
|
1665
|
+
lr = links.add_parser("remove", help="remove one link; research will not add it back (needs data:write)", parents=[common])
|
|
1666
|
+
lr.add_argument("entity", help="the entity whose record lists the link (an entity key)")
|
|
1667
|
+
lr.add_argument("record_id")
|
|
1668
|
+
lr.add_argument("to_entity", help="the linked entity")
|
|
1669
|
+
lr.add_argument("target_id", help="the linked record to remove")
|
|
1670
|
+
lr.add_argument("--blueprint", help="Blueprint id (default: the active one)")
|
|
1671
|
+
lr.add_argument("--yes", action="store_true", help="do not ask for confirmation")
|
|
1672
|
+
lr.add_argument("--idempotency-key", help="make a retry safe")
|
|
1673
|
+
lr.set_defaults(func=cmd_links_remove)
|
|
1674
|
+
|
|
1229
1675
|
holds = sub.add_parser("holds", help="cells you set on purpose, and newer research", parents=[common]).add_subparsers(dest="sub", required=True)
|
|
1230
1676
|
hl = holds.add_parser("list", help="newest first; --pending for those with newer research", parents=[common])
|
|
1231
1677
|
hl.add_argument("--entity", help="only this entity's cells (an entity key)")
|
|
@@ -1268,6 +1714,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1268
1714
|
help="keep the survivor's value on every tie you did not settle")
|
|
1269
1715
|
mp.add_argument("--yes", action="store_true", help="do not ask for confirmation")
|
|
1270
1716
|
mp.add_argument("--idempotency-key", help="make a retry safe")
|
|
1717
|
+
mp.add_argument("--preview-digest", metavar="DIGEST",
|
|
1718
|
+
help="apply exactly the merge `merge preview` showed (its digest)")
|
|
1719
|
+
mp.add_argument("--without-preview", action="store_true",
|
|
1720
|
+
help="merge without confirming a preview (a blind merge, on purpose)")
|
|
1271
1721
|
mp.set_defaults(func=func)
|
|
1272
1722
|
|
|
1273
1723
|
data = sub.add_parser("data", help="your cited dataset", parents=[common]).add_subparsers(dest="sub", required=True)
|
|
@@ -253,6 +253,61 @@ def test_run_create_with_neither_sends_an_empty_broad_sweep(monkeypatch):
|
|
|
253
253
|
assert seen["body"]["dry_run"] is True
|
|
254
254
|
|
|
255
255
|
|
|
256
|
+
def test_run_create_sends_the_expected_total_only_when_given(monkeypatch):
|
|
257
|
+
"""gh #2710: --expected-total binds the create to the estimate; without it, no field."""
|
|
258
|
+
cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
|
|
259
|
+
bodies = []
|
|
260
|
+
|
|
261
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
262
|
+
bodies.append(json.loads(request.content))
|
|
263
|
+
return httpx.Response(202, json={"id": "run_1", "status": "queued"})
|
|
264
|
+
|
|
265
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
266
|
+
base = ["runs", "create", "--seeds", "Acme", "--blueprint", "bp-a", "--yes"]
|
|
267
|
+
assert main(base) == 0
|
|
268
|
+
assert main([*base, "--expected-total", "1000"]) == 0
|
|
269
|
+
assert "expected_total_cents" not in bodies[0]
|
|
270
|
+
assert bodies[1]["expected_total_cents"] == 1000
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def test_run_create_that_now_costs_more_says_the_new_total_and_charges_nothing(
|
|
274
|
+
monkeypatch, capsys
|
|
275
|
+
):
|
|
276
|
+
"""gh #2710: a 409 run_price_changed prints the new total and the flag to resend with, and
|
|
277
|
+
exits as a usage refusal (the human decides), never as a started run."""
|
|
278
|
+
cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
|
|
279
|
+
monkeypatch.setattr("deepsieve_cli.main._tty", lambda: False)
|
|
280
|
+
refusal = {
|
|
281
|
+
"error": {
|
|
282
|
+
"type": "conflict_error",
|
|
283
|
+
"code": "run_price_changed",
|
|
284
|
+
"message": "The price for this run changed since it was estimated.",
|
|
285
|
+
"param": "expected_total_cents",
|
|
286
|
+
"retriable": False,
|
|
287
|
+
"details": {
|
|
288
|
+
"expected_total_cents": 1000,
|
|
289
|
+
"total_price_cents": 2000,
|
|
290
|
+
"interactions": 2,
|
|
291
|
+
"unit_price_cents": 1000,
|
|
292
|
+
},
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
297
|
+
return httpx.Response(409, json=refusal)
|
|
298
|
+
|
|
299
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
300
|
+
code = main(
|
|
301
|
+
["runs", "create", "--seeds", "Acme", "--blueprint", "bp-a", "--yes",
|
|
302
|
+
"--expected-total", "1000"]
|
|
303
|
+
)
|
|
304
|
+
err = capsys.readouterr().err
|
|
305
|
+
print("exit", code, "err", err)
|
|
306
|
+
assert code == 2
|
|
307
|
+
assert "it is now $20.00. Nothing was charged." in err
|
|
308
|
+
assert "Pass --expected-total 2000 to start at the new price." in err
|
|
309
|
+
|
|
310
|
+
|
|
256
311
|
# A workspace with one Blueprint, active. `data` commands resolve through this
|
|
257
312
|
# listing since #1282 removed the ambient /v1/data — so a handler that does not
|
|
258
313
|
# serve it is a handler the data commands cannot get past.
|
|
@@ -360,6 +415,12 @@ def test_unfinished_run_exits_pending(monkeypatch):
|
|
|
360
415
|
["merge", "preview", "books", "--survivor", "s", "--loser", "l", "--json"],
|
|
361
416
|
["merge", "apply", "books", "--survivor", "s", "--loser", "l", "--loser", "m", "--yes"],
|
|
362
417
|
["changes", "undo", "00000000-0000-0000-0000-000000000000", "--yes"],
|
|
418
|
+
# #2615: the re-research and link-removal lines cli.md now shows.
|
|
419
|
+
["runs", "reruns", "r"],
|
|
420
|
+
["runs", "reruns", "r", "--approve", "--expected-total", "900", "--quote-id", "q"],
|
|
421
|
+
["runs", "reruns", "r", "--auto-approve-on", "--cap", "50"],
|
|
422
|
+
["runs", "reruns", "r", "--auto-approve-off"],
|
|
423
|
+
["links", "remove", "funds", "rid", "individuals", "tid"],
|
|
363
424
|
],
|
|
364
425
|
)
|
|
365
426
|
def test_documented_command_lines_parse(argv):
|
|
@@ -696,6 +757,24 @@ def test_run_create_forwards_each_allowed_source(monkeypatch):
|
|
|
696
757
|
assert "allowed_sources" not in seen["body"]
|
|
697
758
|
|
|
698
759
|
|
|
760
|
+
def test_run_create_forwards_each_excluded_source(monkeypatch):
|
|
761
|
+
"""gh #2641 — `--excluded-source` mirrors `--allowed-source`: repeatable, a list, absent
|
|
762
|
+
when not given (agent parity: every surface that takes one takes the other)."""
|
|
763
|
+
cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
|
|
764
|
+
seen = {}
|
|
765
|
+
|
|
766
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
767
|
+
seen["body"] = json.loads(request.content)
|
|
768
|
+
return httpx.Response(202, json={"id": "run_1", "status": "queued"})
|
|
769
|
+
|
|
770
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
771
|
+
assert main(["runs", "create", "--seeds", "Acme", "--dry-run",
|
|
772
|
+
"--excluded-source", "wikipedia.org", "--excluded-source", " *.example "]) == 0
|
|
773
|
+
assert seen["body"]["excluded_sources"] == ["wikipedia.org", "*.example"]
|
|
774
|
+
assert main(["runs", "create", "--seeds", "Acme", "--dry-run"]) == 0
|
|
775
|
+
assert "excluded_sources" not in seen["body"]
|
|
776
|
+
|
|
777
|
+
|
|
699
778
|
def test_run_create_names_the_blueprint_it_sends_and_the_one_it_got(monkeypatch, capsys):
|
|
700
779
|
"""gh #1282: `--blueprint` reaches the body, and the Blueprint the run is on is
|
|
701
780
|
printed, so a run on the wrong one is visible at once."""
|
|
@@ -1094,7 +1173,8 @@ def test_holds_release_refuses_a_malformed_cell_id_and_sends_nothing(monkeypatch
|
|
|
1094
1173
|
|
|
1095
1174
|
def test_a_billable_run_without_a_blueprint_is_a_usage_error_before_any_request(monkeypatch, capsys):
|
|
1096
1175
|
"""gh #1282 B1: the API now requires `blueprint_id` on run create; the CLI says so first, with
|
|
1097
|
-
where the id comes from, and sends nothing. A dry run may omit it (the API's own exception).
|
|
1176
|
+
where the id comes from, and sends nothing. A dry run may omit it (the API's own exception).
|
|
1177
|
+
"""
|
|
1098
1178
|
cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
|
|
1099
1179
|
monkeypatch.setattr("deepsieve_cli.main._tty", lambda: False)
|
|
1100
1180
|
sent: list[str] = []
|
|
@@ -1280,7 +1360,8 @@ def test_declining_the_lock_sends_nothing_more(monkeypatch, capsys):
|
|
|
1280
1360
|
|
|
1281
1361
|
def test_a_piped_yes_from_a_terminal_never_commits_30_days(monkeypatch, capsys):
|
|
1282
1362
|
"""gh #2345 review, the seat's exact case: stdout is a terminal, stdin is `yes |`. The gate
|
|
1283
|
-
looked at stdout only, so both questions were answered from the pipe and 30 days committed.
|
|
1363
|
+
looked at stdout only, so both questions were answered from the pipe and 30 days committed.
|
|
1364
|
+
"""
|
|
1284
1365
|
import io
|
|
1285
1366
|
|
|
1286
1367
|
_signed_in(monkeypatch)
|
|
@@ -1358,3 +1439,534 @@ def test_end_of_input_at_the_lock_question_is_a_no_not_a_traceback(monkeypatch,
|
|
|
1358
1439
|
monkeypatch.setattr(httpx, "Client", _mock(_lock_handler(seen)))
|
|
1359
1440
|
assert main(["runs", "monitoring", RUN, "--on"]) == 0
|
|
1360
1441
|
assert seen == [{"active": True}]
|
|
1442
|
+
|
|
1443
|
+
|
|
1444
|
+
# ── runs retry: a charging retry is confirmed by its total (gh #2389) ────────
|
|
1445
|
+
|
|
1446
|
+
_RETRY_QUOTE = {
|
|
1447
|
+
"total_price_cents": 1200,
|
|
1448
|
+
"interactions": 2,
|
|
1449
|
+
"unit_price_cents": 600,
|
|
1450
|
+
"shortfall_cents": 0,
|
|
1451
|
+
"entities": ["Acme Corp", "Initech"],
|
|
1452
|
+
"refund": {"kind": "refunded_dollars", "amount_cents": 1200},
|
|
1453
|
+
"max_depth": False,
|
|
1454
|
+
"reuse_predicted": ["Held Co"],
|
|
1455
|
+
"skipped_already_held": [],
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
|
|
1459
|
+
def _retry_handler(seen: list, *, price: int = 1200):
|
|
1460
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
1461
|
+
body = json.loads(request.content) if request.content else {}
|
|
1462
|
+
seen.append(body)
|
|
1463
|
+
if body.get("expected_total_cents") != price:
|
|
1464
|
+
code = "retry_price_changed" if "expected_total_cents" in body else (
|
|
1465
|
+
"retry_confirmation_required"
|
|
1466
|
+
)
|
|
1467
|
+
return httpx.Response(409, json={"error": {
|
|
1468
|
+
"code": code, "message": "confirm the total", "param": "expected_total_cents",
|
|
1469
|
+
"retriable": False, "details": {**_RETRY_QUOTE, "total_price_cents": price},
|
|
1470
|
+
}})
|
|
1471
|
+
return httpx.Response(202, json={
|
|
1472
|
+
"object": "run", "id": "new-run", "status": "queued",
|
|
1473
|
+
"retry": {"source_run_id": RUN, "mode": "restarted"},
|
|
1474
|
+
})
|
|
1475
|
+
|
|
1476
|
+
return handler
|
|
1477
|
+
|
|
1478
|
+
|
|
1479
|
+
def test_a_charging_retry_in_a_pipe_names_the_total_and_charges_nothing(monkeypatch, capsys):
|
|
1480
|
+
_signed_in(monkeypatch)
|
|
1481
|
+
monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: False)
|
|
1482
|
+
seen: list = []
|
|
1483
|
+
monkeypatch.setattr(httpx, "Client", _mock(_retry_handler(seen)))
|
|
1484
|
+
assert main(["runs", "retry", RUN]) == 2
|
|
1485
|
+
err_text = capsys.readouterr().err
|
|
1486
|
+
print("refusal:", err_text)
|
|
1487
|
+
assert "$12.00 is back on your account" in err_text and "charges $12.00" in err_text
|
|
1488
|
+
assert "1 carried over at no charge: Held Co" in err_text
|
|
1489
|
+
assert "--expected-total 1200" in err_text
|
|
1490
|
+
assert seen == [{}], "only the unconfirmed call, which charges nothing"
|
|
1491
|
+
|
|
1492
|
+
|
|
1493
|
+
def test_expected_total_confirms_in_one_call(monkeypatch, capsys):
|
|
1494
|
+
_signed_in(monkeypatch)
|
|
1495
|
+
seen: list = []
|
|
1496
|
+
monkeypatch.setattr(httpx, "Client", _mock(_retry_handler(seen)))
|
|
1497
|
+
assert main(["runs", "retry", RUN, "--expected-total", "1200"]) == 0
|
|
1498
|
+
assert seen == [{"expected_total_cents": 1200}]
|
|
1499
|
+
assert "restarted as new-run" in capsys.readouterr().out
|
|
1500
|
+
|
|
1501
|
+
|
|
1502
|
+
def test_interactively_the_yes_is_bound_to_the_total_shown(monkeypatch, capsys):
|
|
1503
|
+
_signed_in(monkeypatch)
|
|
1504
|
+
monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: True)
|
|
1505
|
+
prompts: list = []
|
|
1506
|
+
monkeypatch.setattr("builtins.input", lambda prompt="": prompts.append(prompt) or "y")
|
|
1507
|
+
seen: list = []
|
|
1508
|
+
monkeypatch.setattr(httpx, "Client", _mock(_retry_handler(seen)))
|
|
1509
|
+
assert main(["runs", "retry", RUN]) == 0
|
|
1510
|
+
assert seen == [{}, {"expected_total_cents": 1200}]
|
|
1511
|
+
assert "Retry at $12.00?" in prompts[0]
|
|
1512
|
+
|
|
1513
|
+
|
|
1514
|
+
def test_declining_a_charging_retry_sends_nothing_more(monkeypatch, capsys):
|
|
1515
|
+
_signed_in(monkeypatch)
|
|
1516
|
+
monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: True)
|
|
1517
|
+
monkeypatch.setattr("builtins.input", lambda prompt="": "n")
|
|
1518
|
+
seen: list = []
|
|
1519
|
+
monkeypatch.setattr(httpx, "Client", _mock(_retry_handler(seen)))
|
|
1520
|
+
assert main(["runs", "retry", RUN]) == 0
|
|
1521
|
+
assert seen == [{}]
|
|
1522
|
+
assert "Nothing was charged" in capsys.readouterr().out
|
|
1523
|
+
|
|
1524
|
+
|
|
1525
|
+
def test_a_moved_price_is_asked_again_with_the_new_total(monkeypatch, capsys):
|
|
1526
|
+
"""Quoted at one price, confirmed at an old one: the API answers `retry_price_changed` with
|
|
1527
|
+
the new total; the person is asked again and the new yes is bound to the new total."""
|
|
1528
|
+
_signed_in(monkeypatch)
|
|
1529
|
+
monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: True)
|
|
1530
|
+
prompts: list = []
|
|
1531
|
+
monkeypatch.setattr("builtins.input", lambda prompt="": prompts.append(prompt) or "y")
|
|
1532
|
+
seen: list = []
|
|
1533
|
+
monkeypatch.setattr(httpx, "Client", _mock(_retry_handler(seen, price=1500)))
|
|
1534
|
+
assert main(["runs", "retry", RUN, "--expected-total", "1200"]) == 0
|
|
1535
|
+
assert seen == [{"expected_total_cents": 1200}, {"expected_total_cents": 1500}]
|
|
1536
|
+
assert "price changed" in prompts[0] and "$15.00" in prompts[0]
|
|
1537
|
+
|
|
1538
|
+
|
|
1539
|
+
def test_a_retry_refusal_under_json_is_machine_readable(monkeypatch, capsys):
|
|
1540
|
+
_signed_in(monkeypatch)
|
|
1541
|
+
monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: False)
|
|
1542
|
+
seen: list = []
|
|
1543
|
+
monkeypatch.setattr(httpx, "Client", _mock(_retry_handler(seen)))
|
|
1544
|
+
assert main(["runs", "retry", RUN, "--json"]) == 2
|
|
1545
|
+
out_json = json.loads(capsys.readouterr().out)
|
|
1546
|
+
assert out_json["error"]["code"] == "retry_confirmation_required"
|
|
1547
|
+
assert out_json["error"]["details"]["total_price_cents"] == 1200
|
|
1548
|
+
|
|
1549
|
+
|
|
1550
|
+
def test_a_retry_that_charges_nothing_starts_without_asking(monkeypatch, capsys):
|
|
1551
|
+
_signed_in(monkeypatch)
|
|
1552
|
+
monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: False)
|
|
1553
|
+
seen: list = []
|
|
1554
|
+
|
|
1555
|
+
def handler(request):
|
|
1556
|
+
seen.append(json.loads(request.content) if request.content else {})
|
|
1557
|
+
return httpx.Response(202, json={"object": "run", "id": RUN, "status": "running",
|
|
1558
|
+
"retry": {"source_run_id": RUN, "mode": "resumed"}})
|
|
1559
|
+
|
|
1560
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
1561
|
+
assert main(["runs", "retry", RUN]) == 0
|
|
1562
|
+
assert seen == [{}]
|
|
1563
|
+
assert f"retry of {RUN} resumed" in capsys.readouterr().out
|
|
1564
|
+
|
|
1565
|
+
|
|
1566
|
+
def test_retry_refuses_a_run_id_that_is_not_a_uuid(monkeypatch, capsys):
|
|
1567
|
+
_signed_in(monkeypatch)
|
|
1568
|
+
seen: list = []
|
|
1569
|
+
monkeypatch.setattr(httpx, "Client", _mock(lambda r: seen.append(r) or httpx.Response(500)))
|
|
1570
|
+
assert main(["runs", "retry", "../billing"]) == 2
|
|
1571
|
+
assert seen == []
|
|
1572
|
+
|
|
1573
|
+
|
|
1574
|
+
def test_a_price_that_keeps_moving_is_asked_twice_and_never_a_third_time(monkeypatch, capsys):
|
|
1575
|
+
"""seat-2389 L1: the third refusal ends the loop without a question whose yes is not sent."""
|
|
1576
|
+
_signed_in(monkeypatch)
|
|
1577
|
+
monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: True)
|
|
1578
|
+
prompts: list = []
|
|
1579
|
+
monkeypatch.setattr("builtins.input", lambda prompt="": prompts.append(prompt) or "y")
|
|
1580
|
+
seen: list = []
|
|
1581
|
+
price = iter([1200, 1300, 1400])
|
|
1582
|
+
|
|
1583
|
+
def handler(request):
|
|
1584
|
+
body = json.loads(request.content) if request.content else {}
|
|
1585
|
+
seen.append(body)
|
|
1586
|
+
now = next(price)
|
|
1587
|
+
return httpx.Response(409, json={"error": {
|
|
1588
|
+
"code": "retry_price_changed" if body else "retry_confirmation_required",
|
|
1589
|
+
"message": "moved", "param": "expected_total_cents", "retriable": False,
|
|
1590
|
+
"details": {**_RETRY_QUOTE, "total_price_cents": now},
|
|
1591
|
+
}})
|
|
1592
|
+
|
|
1593
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
1594
|
+
assert main(["runs", "retry", RUN]) == 1
|
|
1595
|
+
print("sent:", seen, "prompts:", len(prompts))
|
|
1596
|
+
assert seen == [{}, {"expected_total_cents": 1200}, {"expected_total_cents": 1300}]
|
|
1597
|
+
assert len(prompts) == 2
|
|
1598
|
+
assert "kept refusing" in capsys.readouterr().err
|
|
1599
|
+
|
|
1600
|
+
|
|
1601
|
+
def test_a_quote_without_a_total_is_never_asked_as_zero(monkeypatch, capsys):
|
|
1602
|
+
"""seat-2389 L2: no `total_price_cents`, no prompt; never "$0.00"."""
|
|
1603
|
+
_signed_in(monkeypatch)
|
|
1604
|
+
monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: True)
|
|
1605
|
+
prompts: list = []
|
|
1606
|
+
monkeypatch.setattr("builtins.input", lambda prompt="": prompts.append(prompt) or "y")
|
|
1607
|
+
monkeypatch.setattr(httpx, "Client", _mock(lambda r: httpx.Response(409, json={"error": {
|
|
1608
|
+
"code": "retry_confirmation_required", "message": "confirm", "retriable": False}})))
|
|
1609
|
+
assert main(["runs", "retry", RUN]) == 1
|
|
1610
|
+
assert prompts == []
|
|
1611
|
+
assert "$0.00" not in capsys.readouterr().err
|
|
1612
|
+
|
|
1613
|
+
|
|
1614
|
+
@pytest.mark.parametrize(
|
|
1615
|
+
"refund, words",
|
|
1616
|
+
[
|
|
1617
|
+
({"kind": "kept_credits", "credits": 2, "run_status": "completed"},
|
|
1618
|
+
"Your earlier run used 2 run credits. It finished with no results"),
|
|
1619
|
+
({"kind": "refund_pending", "run_status": "failed"}, "Your failed run is being refunded."),
|
|
1620
|
+
({"kind": "refunded_to_ended_plan", "run_status": "cancelled"},
|
|
1621
|
+
"Your plan has ended, so the credits for your stopped run were returned to it"),
|
|
1622
|
+
({"kind": "refunded_to_ended_period", "run_status": "failed"},
|
|
1623
|
+
"The credits for your failed run went back to the billing period they came from"),
|
|
1624
|
+
({"kind": "unconfirmed"}, "We can't confirm yet whether your earlier run was refunded."),
|
|
1625
|
+
],
|
|
1626
|
+
ids=["kept-credits", "pending", "ended-plan", "ended-period", "unconfirmed"],
|
|
1627
|
+
)
|
|
1628
|
+
def test_every_refund_kind_reads_in_words_never_its_code(monkeypatch, capsys, refund, words):
|
|
1629
|
+
"""seat-2389 L3: the app's sentences (site `refundHeadline`), never a raw enum name."""
|
|
1630
|
+
_signed_in(monkeypatch)
|
|
1631
|
+
monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: False)
|
|
1632
|
+
monkeypatch.setattr(httpx, "Client", _mock(lambda r: httpx.Response(409, json={"error": {
|
|
1633
|
+
"code": "retry_confirmation_required", "message": "confirm", "retriable": False,
|
|
1634
|
+
"details": {**_RETRY_QUOTE, "refund": refund}}})))
|
|
1635
|
+
main(["runs", "retry", RUN])
|
|
1636
|
+
err_text = capsys.readouterr().err
|
|
1637
|
+
assert words in err_text
|
|
1638
|
+
assert refund["kind"] not in err_text
|
|
1639
|
+
|
|
1640
|
+
|
|
1641
|
+
@pytest.mark.parametrize(
|
|
1642
|
+
"funding, cost",
|
|
1643
|
+
[
|
|
1644
|
+
({"covered_by": "plan_credits", "plan_credits_needed": 2}, "uses 2 plan run credits (list price $12.00)"),
|
|
1645
|
+
({"covered_by": "free_run"}, "uses your free run (list price $12.00)"),
|
|
1646
|
+
({"covered_by": "balance"}, "charges $12.00"),
|
|
1647
|
+
],
|
|
1648
|
+
ids=["plan-credits", "free-run", "balance"],
|
|
1649
|
+
)
|
|
1650
|
+
def test_the_quote_says_what_pays_not_only_the_list_price(monkeypatch, capsys, funding, cost):
|
|
1651
|
+
"""seat-2389 M3: `total_price_cents` is the list price even when credits or the free run
|
|
1652
|
+
cover the retry; the human is told what will actually be spent."""
|
|
1653
|
+
_signed_in(monkeypatch)
|
|
1654
|
+
monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: False)
|
|
1655
|
+
monkeypatch.setattr(httpx, "Client", _mock(lambda r: httpx.Response(409, json={"error": {
|
|
1656
|
+
"code": "retry_confirmation_required", "message": "confirm", "retriable": False,
|
|
1657
|
+
"details": {**_RETRY_QUOTE, "funding": funding}}})))
|
|
1658
|
+
main(["runs", "retry", RUN])
|
|
1659
|
+
assert f"Retrying starts a new run and {cost}." in capsys.readouterr().err
|
|
1660
|
+
|
|
1661
|
+
|
|
1662
|
+
|
|
1663
|
+
# --- gh #2351: runs reruns ---------------------------------------------------------------------
|
|
1664
|
+
|
|
1665
|
+
_RERUNS = {
|
|
1666
|
+
"object": "reruns",
|
|
1667
|
+
"run_id": RUN,
|
|
1668
|
+
"awaiting_count": 2,
|
|
1669
|
+
"awaiting_total_cents": 2000,
|
|
1670
|
+
"items": [{"entity": "Acme", "column_label": "HQ city"}, {"entity": "Beta", "column": "ceo"}],
|
|
1671
|
+
"included_per_period": 1,
|
|
1672
|
+
"included_used": 1,
|
|
1673
|
+
"next_period_start": "2026-10-06",
|
|
1674
|
+
"auto_approve": False,
|
|
1675
|
+
"quote_id": "rq_abc",
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
|
|
1679
|
+
def test_runs_reruns_shows_what_is_waiting(monkeypatch, capsys):
|
|
1680
|
+
_signed_in(monkeypatch)
|
|
1681
|
+
seen = []
|
|
1682
|
+
|
|
1683
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
1684
|
+
seen.append((request.method, request.url.path))
|
|
1685
|
+
return httpx.Response(200, json=_RERUNS)
|
|
1686
|
+
|
|
1687
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
1688
|
+
assert main(["runs", "reruns", RUN]) == 0
|
|
1689
|
+
assert seen == [("GET", f"/v1/research/runs/{RUN}/reruns")]
|
|
1690
|
+
out = capsys.readouterr().out
|
|
1691
|
+
assert "2 waiting" in out and "$20.00" in out and "Acme" in out
|
|
1692
|
+
|
|
1693
|
+
|
|
1694
|
+
def test_runs_reruns_approve_sends_the_total_and_a_key(monkeypatch, capsys):
|
|
1695
|
+
_signed_in(monkeypatch)
|
|
1696
|
+
seen = {}
|
|
1697
|
+
|
|
1698
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
1699
|
+
seen["method"], seen["path"] = request.method, request.url.path
|
|
1700
|
+
seen["body"] = json.loads(request.content)
|
|
1701
|
+
seen["key"] = request.headers.get("Idempotency-Key")
|
|
1702
|
+
return httpx.Response(200, json={"approved": 1, "still_waiting": 0, "run_ids": []})
|
|
1703
|
+
|
|
1704
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
1705
|
+
assert main(["runs", "reruns", RUN, "--approve", "--expected-total", "2000", "--quote-id", "rq_abc"]) == 0
|
|
1706
|
+
assert (seen["method"], seen["path"]) == ("POST", f"/v1/research/runs/{RUN}/reruns/approve")
|
|
1707
|
+
assert seen["body"] == {"expected_total_cents": 2000, "quote_id": "rq_abc"}
|
|
1708
|
+
assert seen["key"], "approving spends money: an Idempotency-Key must be sent"
|
|
1709
|
+
|
|
1710
|
+
|
|
1711
|
+
def test_runs_reruns_approve_in_a_pipe_needs_the_total_and_sends_nothing(monkeypatch, capsys):
|
|
1712
|
+
_signed_in(monkeypatch)
|
|
1713
|
+
monkeypatch.setattr("deepsieve_cli.main._tty", lambda: False)
|
|
1714
|
+
calls = []
|
|
1715
|
+
monkeypatch.setattr(httpx, "Client", _mock(lambda r: calls.append(r) or httpx.Response(200)))
|
|
1716
|
+
assert main(["runs", "reruns", RUN, "--approve"]) == 2
|
|
1717
|
+
assert main(["runs", "reruns", RUN, "--approve", "--expected-total", "2000"]) == 2 # no quote
|
|
1718
|
+
assert "--quote-id" in capsys.readouterr().err
|
|
1719
|
+
assert calls == []
|
|
1720
|
+
|
|
1721
|
+
|
|
1722
|
+
def test_runs_reruns_turns_auto_approve_on_only_with_a_cap_and_a_yes(monkeypatch, capsys):
|
|
1723
|
+
"""gh #2506: an agent may turn auto-approve ON (it lapses when its key is revoked). It is
|
|
1724
|
+
standing authority to spend, so the monthly cap is REQUIRED, and it is confirmed like an
|
|
1725
|
+
approval: interactively or with --yes, never silently in a pipe."""
|
|
1726
|
+
_signed_in(monkeypatch)
|
|
1727
|
+
calls = []
|
|
1728
|
+
|
|
1729
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
1730
|
+
calls.append((request.method, json.loads(request.content)))
|
|
1731
|
+
return httpx.Response(200, json=_RERUNS)
|
|
1732
|
+
|
|
1733
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
1734
|
+
monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: False)
|
|
1735
|
+
with pytest.raises(SystemExit):
|
|
1736
|
+
main(["runs", "reruns", RUN, "--approve", "--auto-approve-on"]) # one action at a time
|
|
1737
|
+
assert main(["runs", "reruns", RUN, "--auto-approve-on", "--yes"]) == 2 # no cap
|
|
1738
|
+
assert "--cap" in capsys.readouterr().err
|
|
1739
|
+
assert main(["runs", "reruns", RUN, "--auto-approve-on", "--cap", "50"]) == 2 # a pipe, no yes
|
|
1740
|
+
assert "--yes" in capsys.readouterr().err
|
|
1741
|
+
assert calls == [], "nothing may be sent without a cap and a yes"
|
|
1742
|
+
assert main(["runs", "reruns", RUN, "--auto-approve-on", "--cap", "50", "--yes"]) == 0
|
|
1743
|
+
assert calls == [("PATCH", {"auto_approve": True, "auto_approve_cap_cents": 5000})]
|
|
1744
|
+
assert main(["runs", "reruns", RUN, "--auto-approve-off"]) == 0
|
|
1745
|
+
assert calls[-1] == ("PATCH", {"auto_approve": False})
|
|
1746
|
+
|
|
1747
|
+
|
|
1748
|
+
@pytest.mark.parametrize("cap", ["nan", "inf", "1e400", "0.004", "-1", "21474836.48"])
|
|
1749
|
+
def test_runs_reruns_refuses_a_cap_the_api_cannot_take_before_asking(monkeypatch, capsys, cap):
|
|
1750
|
+
"""seat-2506b F3/F4: a cap that is not a finite number used to traceback, and one that
|
|
1751
|
+
rounds under a cent was confirmed as "$0.00 a month" and then refused by the API. The CLI
|
|
1752
|
+
applies the API's own rule (1 cent to the column's ceiling) before it asks or sends."""
|
|
1753
|
+
_signed_in(monkeypatch)
|
|
1754
|
+
calls, asked = [], []
|
|
1755
|
+
monkeypatch.setattr(
|
|
1756
|
+
httpx, "Client", _mock(lambda r: calls.append(r) or httpx.Response(200, json=_RERUNS))
|
|
1757
|
+
)
|
|
1758
|
+
monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: True)
|
|
1759
|
+
monkeypatch.setattr("deepsieve_cli.main._yes", lambda prompt: asked.append(prompt) or True)
|
|
1760
|
+
assert main(["runs", "reruns", RUN, "--auto-approve-on", "--cap", cap]) == 2
|
|
1761
|
+
assert calls == [] and asked == [], "it asked or sent for a cap the API refuses"
|
|
1762
|
+
assert "--cap" in capsys.readouterr().err
|
|
1763
|
+
|
|
1764
|
+
|
|
1765
|
+
def test_runs_reruns_refuses_cap_or_yes_without_auto_approve_on(monkeypatch, capsys):
|
|
1766
|
+
"""seat-2506b F5: --cap and --yes belong to --auto-approve-on; silently ignoring them let a
|
|
1767
|
+
user believe --yes had confirmed an approval."""
|
|
1768
|
+
_signed_in(monkeypatch)
|
|
1769
|
+
calls = []
|
|
1770
|
+
monkeypatch.setattr(
|
|
1771
|
+
httpx, "Client", _mock(lambda r: calls.append(r) or httpx.Response(200, json=_RERUNS))
|
|
1772
|
+
)
|
|
1773
|
+
assert main(["runs", "reruns", RUN, "--cap", "50"]) == 2
|
|
1774
|
+
assert main(["runs", "reruns", RUN, "--approve", "--yes"]) == 2
|
|
1775
|
+
assert calls == []
|
|
1776
|
+
assert "--auto-approve-on" in capsys.readouterr().err
|
|
1777
|
+
|
|
1778
|
+
|
|
1779
|
+
def test_runs_reruns_asks_before_turning_auto_approve_on(monkeypatch, capsys):
|
|
1780
|
+
_signed_in(monkeypatch)
|
|
1781
|
+
calls = []
|
|
1782
|
+
|
|
1783
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
1784
|
+
calls.append((request.method, json.loads(request.content)))
|
|
1785
|
+
return httpx.Response(200, json=_RERUNS)
|
|
1786
|
+
|
|
1787
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
1788
|
+
monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: True)
|
|
1789
|
+
asked = []
|
|
1790
|
+
monkeypatch.setattr("deepsieve_cli.main._yes", lambda prompt: asked.append(prompt) or False)
|
|
1791
|
+
assert main(["runs", "reruns", RUN, "--auto-approve-on", "--cap", "12.5"]) == 0
|
|
1792
|
+
assert calls == [], "a declined prompt sent something"
|
|
1793
|
+
assert "$12.50 a month" in asked[0] and "revoked" in asked[0]
|
|
1794
|
+
|
|
1795
|
+
|
|
1796
|
+
def test_runs_reruns_a_moved_price_says_nothing_was_charged(monkeypatch, capsys):
|
|
1797
|
+
_signed_in(monkeypatch)
|
|
1798
|
+
|
|
1799
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
1800
|
+
return httpx.Response(
|
|
1801
|
+
409,
|
|
1802
|
+
json={
|
|
1803
|
+
"error": {
|
|
1804
|
+
"type": "conflict_error",
|
|
1805
|
+
"code": "rerun_price_changed",
|
|
1806
|
+
"message": "moved",
|
|
1807
|
+
"details": {"total_cents": 3000, "count": 3},
|
|
1808
|
+
}
|
|
1809
|
+
},
|
|
1810
|
+
)
|
|
1811
|
+
|
|
1812
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
1813
|
+
assert main(["runs", "reruns", RUN, "--approve", "--expected-total", "2000", "--quote-id", "rq_abc"]) != 0
|
|
1814
|
+
err = capsys.readouterr().err
|
|
1815
|
+
assert "$30.00" in err and "Nothing was charged" in err
|
|
1816
|
+
|
|
1817
|
+
|
|
1818
|
+
def test_runs_reruns_past_the_ceiling_refuses_before_asking(monkeypatch, capsys):
|
|
1819
|
+
"""gh #2351 delta review: past what the API lists there is no quote. The CLI must say why
|
|
1820
|
+
and stop, never ask "Approve 501?" over rows it could not show (and never POST)."""
|
|
1821
|
+
_signed_in(monkeypatch)
|
|
1822
|
+
monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: True)
|
|
1823
|
+
asked: list = []
|
|
1824
|
+
monkeypatch.setattr("deepsieve_cli.main._yes", lambda prompt: asked.append(prompt) or True)
|
|
1825
|
+
withheld = "501 are waiting; more than 500 can't be approved at once through the API."
|
|
1826
|
+
calls = []
|
|
1827
|
+
|
|
1828
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
1829
|
+
calls.append(request.method)
|
|
1830
|
+
return httpx.Response(
|
|
1831
|
+
200,
|
|
1832
|
+
json={**_RERUNS, "awaiting_count": 501, "quote_id": None, "quote_withheld": withheld},
|
|
1833
|
+
)
|
|
1834
|
+
|
|
1835
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
1836
|
+
assert main(["runs", "reruns", RUN, "--approve"]) == 2
|
|
1837
|
+
captured = capsys.readouterr()
|
|
1838
|
+
assert withheld in captured.out and "Nothing was charged" in captured.err
|
|
1839
|
+
assert asked == [] and calls == ["GET"]
|
|
1840
|
+
|
|
1841
|
+
|
|
1842
|
+
def test_runs_reruns_nothing_waiting_any_more_is_said_plainly(monkeypatch, capsys):
|
|
1843
|
+
_signed_in(monkeypatch)
|
|
1844
|
+
err_body = {"error": {"code": "rerun_price_changed", "message": "m", "param": "expected_total_cents",
|
|
1845
|
+
"retriable": False, "details": {"total_cents": 0, "count": 0}}}
|
|
1846
|
+
monkeypatch.setattr(httpx, "Client", _mock(lambda r: httpx.Response(409, json=err_body)))
|
|
1847
|
+
assert main(["runs", "reruns", RUN, "--approve", "--expected-total", "2000", "--quote-id", "rq_abc"]) != 0
|
|
1848
|
+
err = capsys.readouterr().err
|
|
1849
|
+
assert "nothing is waiting any more" in err and "$0.00" not in err
|
|
1850
|
+
|
|
1851
|
+
|
|
1852
|
+
_LINK = {
|
|
1853
|
+
"object": "change",
|
|
1854
|
+
"id": "11111111-1111-4111-8111-111111111111",
|
|
1855
|
+
"change_id": "11111111-1111-4111-8111-111111111111",
|
|
1856
|
+
"kind": "link_removed",
|
|
1857
|
+
"link": {"record_name": "Growth Fund", "target_name": "Ann", "to_entity": "individuals"},
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
|
|
1861
|
+
def test_links_remove_confirms_sends_the_link_and_prints_the_undo(monkeypatch, capsys):
|
|
1862
|
+
"""gh #2514: it changes data, so it is confirmed like a merge; never silent in a pipe."""
|
|
1863
|
+
_signed_in(monkeypatch)
|
|
1864
|
+
seen = []
|
|
1865
|
+
|
|
1866
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
1867
|
+
seen.append((request.method, request.url.path, json.loads(request.content)))
|
|
1868
|
+
return httpx.Response(200, json=_LINK)
|
|
1869
|
+
|
|
1870
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
1871
|
+
monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: False)
|
|
1872
|
+
args = ["links", "remove", "funds", RUN, "individuals", RUN]
|
|
1873
|
+
assert main(args) == 2 and seen == [], "a pipe without --yes must not change data"
|
|
1874
|
+
assert main([*args, "--yes"]) == 0
|
|
1875
|
+
assert seen == [("POST", "/v1/links/remove", {
|
|
1876
|
+
"entity": "funds", "record_id": RUN, "to_entity": "individuals", "target_id": RUN})]
|
|
1877
|
+
out = capsys.readouterr().out
|
|
1878
|
+
assert "removed Ann from Growth Fund" in out and "deepsieve changes undo 1111" in out
|
|
1879
|
+
|
|
1880
|
+
|
|
1881
|
+
def test_links_remove_refuses_a_bad_id_before_any_request(monkeypatch):
|
|
1882
|
+
_signed_in(monkeypatch)
|
|
1883
|
+
calls = []
|
|
1884
|
+
monkeypatch.setattr(httpx, "Client", _mock(lambda r: calls.append(r) or httpx.Response(200)))
|
|
1885
|
+
assert main(["links", "remove", "funds", "nope", "individuals", RUN, "--yes"]) == 2
|
|
1886
|
+
assert calls == []
|
|
1887
|
+
|
|
1888
|
+
|
|
1889
|
+
# ── merge: what you previewed is what you get (#2409) ─────────────────────────
|
|
1890
|
+
|
|
1891
|
+
|
|
1892
|
+
def test_merge_preview_prints_the_digest_to_apply_exactly_that_merge(monkeypatch, capsys):
|
|
1893
|
+
_signed_in(monkeypatch)
|
|
1894
|
+
monkeypatch.setattr(
|
|
1895
|
+
httpx, "Client", _mock(lambda r: httpx.Response(200, json=_merge_result(preview_digest="d1")))
|
|
1896
|
+
)
|
|
1897
|
+
assert main(["merge", "preview", "books", "--survivor", _S, "--loser", _L]) == 0
|
|
1898
|
+
assert "--preview-digest d1" in capsys.readouterr().out
|
|
1899
|
+
|
|
1900
|
+
|
|
1901
|
+
def test_merge_apply_sends_the_digest_or_the_blind_flag_never_both(monkeypatch, capsys):
|
|
1902
|
+
_signed_in(monkeypatch)
|
|
1903
|
+
sent = []
|
|
1904
|
+
|
|
1905
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
1906
|
+
sent.append(json.loads(request.content))
|
|
1907
|
+
return httpx.Response(201, json=_merge_result(applied=True, change_id="c-1", unresolved_ties=[]))
|
|
1908
|
+
|
|
1909
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
1910
|
+
base = ["merge", "apply", "books", "--survivor", _S, "--loser", _L, "--yes"]
|
|
1911
|
+
assert main([*base, "--preview-digest", "d1"]) == 0
|
|
1912
|
+
assert sent[-1]["preview_digest"] == "d1" and "apply_without_preview" not in sent[-1]
|
|
1913
|
+
assert main([*base, "--without-preview"]) == 0
|
|
1914
|
+
assert sent[-1]["apply_without_preview"] is True and "preview_digest" not in sent[-1]
|
|
1915
|
+
n = len(sent)
|
|
1916
|
+
assert main([*base, "--preview-digest", "d1", "--without-preview"]) == 2
|
|
1917
|
+
assert len(sent) == n, "a contradiction is refused before any request"
|
|
1918
|
+
assert "not both" in capsys.readouterr().err
|
|
1919
|
+
|
|
1920
|
+
|
|
1921
|
+
def test_a_changed_merge_shows_what_it_would_do_now_and_exits_as_usage(monkeypatch, capsys):
|
|
1922
|
+
"""The person sees what moved: the refusal's current preview, printed, and exit 2 (fixed by
|
|
1923
|
+
re-checking and re-sending, not by retrying unchanged)."""
|
|
1924
|
+
_signed_in(monkeypatch)
|
|
1925
|
+
now = _merge_result(preview_digest="d2")
|
|
1926
|
+
now["conflicts"][0] |= {"survivor_value": "Cat"}
|
|
1927
|
+
body = {"error": {"type": "conflict_error", "code": "merge_changed", "retriable": False,
|
|
1928
|
+
"message": "This merge changed since its preview.", "param": "preview_digest",
|
|
1929
|
+
"details": {"preview": now}}}
|
|
1930
|
+
monkeypatch.setattr(httpx, "Client", _mock(lambda r: httpx.Response(409, json=body)))
|
|
1931
|
+
argv = ["merge", "apply", "books", "--survivor", _S, "--loser", _L, "--yes", "--preview-digest", "d1"]
|
|
1932
|
+
assert main(argv) == 2
|
|
1933
|
+
captured = capsys.readouterr()
|
|
1934
|
+
assert "changed since its preview" in captured.err
|
|
1935
|
+
assert "survivor='Cat'" in captured.out and "--preview-digest d2" in captured.out
|
|
1936
|
+
|
|
1937
|
+
|
|
1938
|
+
def test_a_changed_merge_under_json_prints_the_current_merge_as_json(monkeypatch, capsys):
|
|
1939
|
+
"""seat-2409 5c: --json keeps stdout parseable on merge_changed."""
|
|
1940
|
+
_signed_in(monkeypatch)
|
|
1941
|
+
now = _merge_result(preview_digest="d2")
|
|
1942
|
+
body = {"error": {"type": "conflict_error", "code": "merge_changed", "retriable": False,
|
|
1943
|
+
"message": "This merge changed since its preview.", "param": "preview_digest",
|
|
1944
|
+
"details": {"preview": now}}}
|
|
1945
|
+
monkeypatch.setattr(httpx, "Client", _mock(lambda r: httpx.Response(409, json=body)))
|
|
1946
|
+
argv = ["merge", "apply", "books", "--survivor", _S, "--loser", _L, "--yes",
|
|
1947
|
+
"--preview-digest", "d1", "--json"]
|
|
1948
|
+
assert main(argv) == 2
|
|
1949
|
+
printed = json.loads(capsys.readouterr().out)
|
|
1950
|
+
assert printed["preview_digest"] == "d2"
|
|
1951
|
+
|
|
1952
|
+
|
|
1953
|
+
def test_run_create_says_which_seeds_were_counted_once(monkeypatch, capsys):
|
|
1954
|
+
"""gh #2716 — the 202's `folded_duplicates` is printed, so a CLI user sees why fewer seeds
|
|
1955
|
+
were researched than were sent; nothing is printed when nothing folded."""
|
|
1956
|
+
cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
|
|
1957
|
+
folds = [
|
|
1958
|
+
{"seed": "Fund X, L.P.", "kept_as": "Fund X", "reason": "legal_form"},
|
|
1959
|
+
{"seed": "acme", "kept_as": "Acme", "reason": "case"},
|
|
1960
|
+
]
|
|
1961
|
+
reply = {"id": "run_1", "status": "queued", "folded_duplicates": folds}
|
|
1962
|
+
|
|
1963
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
1964
|
+
return httpx.Response(202, json=reply)
|
|
1965
|
+
|
|
1966
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
1967
|
+
assert main(["runs", "create", "--seeds", "Fund X,Fund X, L.P.,Acme,acme", "--dry-run"]) == 0
|
|
1968
|
+
printed = capsys.readouterr().out
|
|
1969
|
+
assert '2 counted once: "Fund X, L.P." as "Fund X", "acme" as "Acme".' in printed
|
|
1970
|
+
reply.pop("folded_duplicates")
|
|
1971
|
+
assert main(["runs", "create", "--seeds", "Fund X", "--dry-run"]) == 0
|
|
1972
|
+
assert "counted once" not in capsys.readouterr().out
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|