deepsieve-cli 0.4.0__tar.gz → 0.5.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.4.0 → deepsieve_cli-0.5.0}/PKG-INFO +5 -5
- {deepsieve_cli-0.4.0 → deepsieve_cli-0.5.0}/README.md +4 -4
- {deepsieve_cli-0.4.0 → deepsieve_cli-0.5.0}/pyproject.toml +1 -1
- {deepsieve_cli-0.4.0 → deepsieve_cli-0.5.0}/src/deepsieve_cli/client.py +11 -1
- {deepsieve_cli-0.4.0 → deepsieve_cli-0.5.0}/src/deepsieve_cli/main.py +194 -25
- {deepsieve_cli-0.4.0 → deepsieve_cli-0.5.0}/tests/test_cli.py +307 -1
- {deepsieve_cli-0.4.0 → deepsieve_cli-0.5.0}/.gitignore +0 -0
- {deepsieve_cli-0.4.0 → deepsieve_cli-0.5.0}/src/deepsieve_cli/__init__.py +0 -0
- {deepsieve_cli-0.4.0 → deepsieve_cli-0.5.0}/src/deepsieve_cli/__main__.py +0 -0
- {deepsieve_cli-0.4.0 → deepsieve_cli-0.5.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.5.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
|
|
@@ -27,14 +27,14 @@ approval screen lists the extra scopes.
|
|
|
27
27
|
## Commands
|
|
28
28
|
|
|
29
29
|
```bash
|
|
30
|
-
deepsieve whoami # identity,
|
|
30
|
+
deepsieve whoami # identity, organization, active Blueprint, scopes
|
|
31
31
|
deepsieve runs create --query "..." --dry-run # free simulated run (~15s)
|
|
32
|
-
deepsieve runs create --query "..." --wait
|
|
33
|
-
deepsieve runs create --seeds "..." --allowed-source "*.gov" # only cite these
|
|
32
|
+
deepsieve runs create --blueprint <id> --query "..." --wait # real run: spends credits
|
|
33
|
+
deepsieve runs create --blueprint <id> --seeds "..." --allowed-source "*.gov" # only cite these
|
|
34
34
|
deepsieve runs list
|
|
35
35
|
deepsieve runs get <id> # exit 4 while still running
|
|
36
36
|
deepsieve runs cancel <id>
|
|
37
|
-
deepsieve members list # who is in the
|
|
37
|
+
deepsieve members list # who is in the organization + seat usage
|
|
38
38
|
deepsieve changes list --live # merges, renames, successor links
|
|
39
39
|
deepsieve changes undo <id> # asks first; --yes in scripts
|
|
40
40
|
deepsieve merge preview <entity> --survivor <id> --loser <id> # writes nothing
|
|
@@ -19,14 +19,14 @@ approval screen lists the extra scopes.
|
|
|
19
19
|
## Commands
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
|
-
deepsieve whoami # identity,
|
|
22
|
+
deepsieve whoami # identity, organization, active Blueprint, scopes
|
|
23
23
|
deepsieve runs create --query "..." --dry-run # free simulated run (~15s)
|
|
24
|
-
deepsieve runs create --query "..." --wait
|
|
25
|
-
deepsieve runs create --seeds "..." --allowed-source "*.gov" # only cite these
|
|
24
|
+
deepsieve runs create --blueprint <id> --query "..." --wait # real run: spends credits
|
|
25
|
+
deepsieve runs create --blueprint <id> --seeds "..." --allowed-source "*.gov" # only cite these
|
|
26
26
|
deepsieve runs list
|
|
27
27
|
deepsieve runs get <id> # exit 4 while still running
|
|
28
28
|
deepsieve runs cancel <id>
|
|
29
|
-
deepsieve members list # who is in the
|
|
29
|
+
deepsieve members list # who is in the organization + seat usage
|
|
30
30
|
deepsieve changes list --live # merges, renames, successor links
|
|
31
31
|
deepsieve changes undo <id> # asks first; --yes in scripts
|
|
32
32
|
deepsieve merge preview <entity> --survivor <id> --loser <id> # writes nothing
|
|
@@ -21,10 +21,19 @@ USER_AGENT = "deepsieve-cli"
|
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
class ApiError(Exception):
|
|
24
|
-
def __init__(
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
code: str,
|
|
27
|
+
message: str,
|
|
28
|
+
*,
|
|
29
|
+
request_id: str | None = None,
|
|
30
|
+
details: dict | None = None,
|
|
31
|
+
) -> None:
|
|
25
32
|
self.code = code
|
|
26
33
|
self.message = message
|
|
27
34
|
self.request_id = request_id
|
|
35
|
+
#: Structured facts from the error envelope (`locked_until`), when the API sent them.
|
|
36
|
+
self.details = details or {}
|
|
28
37
|
super().__init__(message)
|
|
29
38
|
|
|
30
39
|
def __str__(self) -> str:
|
|
@@ -87,6 +96,7 @@ class Client:
|
|
|
87
96
|
err.get("code", f"http_{r.status_code}"),
|
|
88
97
|
err.get("message", f"HTTP {r.status_code} from {path}"),
|
|
89
98
|
request_id=err.get("request_id"),
|
|
99
|
+
details=err.get("details"),
|
|
90
100
|
)
|
|
91
101
|
return body
|
|
92
102
|
|
|
@@ -28,6 +28,14 @@ from . import config
|
|
|
28
28
|
from .client import ApiError, Client
|
|
29
29
|
|
|
30
30
|
EXIT_OK, EXIT_FAIL, EXIT_USAGE, EXIT_AUTH, EXIT_PENDING = 0, 1, 2, 3, 4
|
|
31
|
+
#: gh #2307: the organization's model allowance is used for this period. Distinct from a
|
|
32
|
+
#: failure, so a script waits for the reset (or stops) instead of retrying.
|
|
33
|
+
EXIT_LIMIT = 5
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _api_exit(exc) -> int:
|
|
37
|
+
"""The exit code for a refused API call."""
|
|
38
|
+
return EXIT_LIMIT if getattr(exc, "code", None) == "model_allowance_exhausted" else EXIT_FAIL
|
|
31
39
|
|
|
32
40
|
CLIENT_NAME = "DeepSieve CLI"
|
|
33
41
|
|
|
@@ -36,9 +44,19 @@ CLIENT_NAME = "DeepSieve CLI"
|
|
|
36
44
|
|
|
37
45
|
|
|
38
46
|
def _tty() -> bool:
|
|
47
|
+
"""Whether OUTPUT goes to a terminal (formatting only)."""
|
|
39
48
|
return sys.stdout.isatty()
|
|
40
49
|
|
|
41
50
|
|
|
51
|
+
def _interactive() -> bool:
|
|
52
|
+
"""Whether a y/N question can really be put to a person: stdout AND stdin are terminals.
|
|
53
|
+
|
|
54
|
+
gh #2345 review: the gate checked stdout only, but every prompt reads stdin, so
|
|
55
|
+
`yes | deepsieve runs monitoring RID --on` from a terminal answered both questions and
|
|
56
|
+
committed 30 days of spend. A piped answer is a script, not a person saying yes."""
|
|
57
|
+
return _tty() and sys.stdin.isatty()
|
|
58
|
+
|
|
59
|
+
|
|
42
60
|
def out(data: Any, as_json: bool, human: str | None = None) -> None:
|
|
43
61
|
if as_json or human is None:
|
|
44
62
|
print(json.dumps(data, indent=2, default=str))
|
|
@@ -256,6 +274,12 @@ def cmd_runs_create(args) -> int:
|
|
|
256
274
|
if args.query and args.seeds:
|
|
257
275
|
err("pass at most one of --query or --seeds")
|
|
258
276
|
return EXIT_USAGE
|
|
277
|
+
# gh #1282 B1: a billable run must NAME its Blueprint (the API refuses one that does not);
|
|
278
|
+
# say so before anything else, with where the id comes from. A dry run is free and may omit it.
|
|
279
|
+
if not args.dry_run and not getattr(args, "blueprint", None):
|
|
280
|
+
err("pass --blueprint <id>: the Blueprint to research. `deepsieve whoami` shows your "
|
|
281
|
+
"active Blueprint's id (or add --dry-run for a free simulated run)")
|
|
282
|
+
return EXIT_USAGE
|
|
259
283
|
|
|
260
284
|
body: dict[str, Any] = {"depth": args.depth, "dry_run": args.dry_run, "monitored": args.monitored}
|
|
261
285
|
if args.query:
|
|
@@ -273,15 +297,14 @@ def cmd_runs_create(args) -> int:
|
|
|
273
297
|
# A real run spends money. Confirm interactively unless told not to — and
|
|
274
298
|
# never silently in a pipe, where nobody is watching.
|
|
275
299
|
if not args.dry_run and not args.yes:
|
|
276
|
-
if not
|
|
300
|
+
if not _interactive():
|
|
277
301
|
err("refusing to start a billable run non-interactively; pass --yes (or --dry-run)")
|
|
278
302
|
return EXIT_USAGE
|
|
279
303
|
print(f"This starts a REAL research run on {prof.origin} — it spends credits "
|
|
280
304
|
f"and takes 15-60 minutes.")
|
|
281
|
-
# gh #1282: say which Blueprint BEFORE it spends; the 202 is too late.
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
print(f"It runs on {target}.")
|
|
305
|
+
# gh #1282: say which Blueprint BEFORE it spends; the 202 is too late. (B1: a billable
|
|
306
|
+
# run always names one, checked above.)
|
|
307
|
+
print(f"It runs on Blueprint {args.blueprint}.")
|
|
285
308
|
if input("Continue? [y/N] ").strip().lower() not in ("y", "yes"):
|
|
286
309
|
print("Cancelled.")
|
|
287
310
|
return EXIT_OK
|
|
@@ -295,7 +318,8 @@ def cmd_runs_create(args) -> int:
|
|
|
295
318
|
)
|
|
296
319
|
except ApiError as exc:
|
|
297
320
|
err(str(exc))
|
|
298
|
-
|
|
321
|
+
# A --query is parsed by a model: the org's model allowance can refuse it (gh #2307).
|
|
322
|
+
return _api_exit(exc)
|
|
299
323
|
|
|
300
324
|
if args.json and not args.wait:
|
|
301
325
|
out(created, True)
|
|
@@ -351,6 +375,103 @@ def cmd_runs_cancel(args) -> int:
|
|
|
351
375
|
return EXIT_OK
|
|
352
376
|
|
|
353
377
|
|
|
378
|
+
def _last_billed_day(locked_until: str | None) -> str:
|
|
379
|
+
"""`locked_until` is an EXCLUSIVE 00:00Z instant; the last billed UTC day is the day before."""
|
|
380
|
+
from datetime import datetime, timedelta # noqa: PLC0415
|
|
381
|
+
|
|
382
|
+
try:
|
|
383
|
+
return (datetime.fromisoformat(str(locked_until)) - timedelta(days=1)).date().isoformat()
|
|
384
|
+
except ValueError:
|
|
385
|
+
return str(locked_until)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _yes(prompt: str) -> bool:
|
|
389
|
+
"""A y/N answer; end of input is a no, never a traceback."""
|
|
390
|
+
try:
|
|
391
|
+
return input(prompt).strip().lower() in ("y", "yes")
|
|
392
|
+
except EOFError:
|
|
393
|
+
return False
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def cmd_runs_monitoring(args) -> int:
|
|
397
|
+
"""Turn a report's monitoring on or off (gh #2345). A UTC day bills if monitoring was on at
|
|
398
|
+
any moment of it: OFF stops billing from tomorrow, ON bills today. Both are confirmed like
|
|
399
|
+
an undo: interactively, or with --yes, never silently in a pipe.
|
|
400
|
+
|
|
401
|
+
Turning it back ON after the free 30 days commits 30 UTC days (gh #2359). The API refuses
|
|
402
|
+
that ON (`lock_confirmation_required`) until it is sent with `commit_lock`: here, a second
|
|
403
|
+
explicit question, or --commit-lock. --yes alone never commits 30 days. The yes is bound to
|
|
404
|
+
the date shown (`expected_locked_until`): if a UTC midnight passes before it is sent, the
|
|
405
|
+
API answers `lock_quote_changed` with the new date and nothing is committed."""
|
|
406
|
+
rid = _uuid_arg(args.run_id, "run_id")
|
|
407
|
+
if rid is None:
|
|
408
|
+
return EXIT_USAGE
|
|
409
|
+
client, _ = _require_auth(args)
|
|
410
|
+
if not args.yes:
|
|
411
|
+
if not _interactive():
|
|
412
|
+
err("refusing to change monitoring non-interactively; pass --yes")
|
|
413
|
+
return EXIT_USAGE
|
|
414
|
+
if args.active:
|
|
415
|
+
what = "Turn monitoring ON? Past its free 30 days this report bills each UTC day it is on"
|
|
416
|
+
what += ", and this commits 30 days." if args.commit_lock else "."
|
|
417
|
+
else:
|
|
418
|
+
what = "Turn monitoring OFF? It stops being kept fresh, and billing, from tomorrow."
|
|
419
|
+
if not _yes(f"{what} [y/N] "):
|
|
420
|
+
print("Cancelled.")
|
|
421
|
+
return EXIT_OK
|
|
422
|
+
path = f"/v1/research/runs/{rid}/monitoring"
|
|
423
|
+
payload: dict = {"active": args.active}
|
|
424
|
+
if args.active and args.commit_lock:
|
|
425
|
+
payload["commit_lock"] = True
|
|
426
|
+
if args.expected_locked_until:
|
|
427
|
+
payload["expected_locked_until"] = args.expected_locked_until
|
|
428
|
+
# At most two refusals: the first quote, and one re-quote if a UTC midnight passed while the
|
|
429
|
+
# person was deciding. A third would be the API contradicting itself; stop rather than loop.
|
|
430
|
+
for _attempt in range(3):
|
|
431
|
+
try:
|
|
432
|
+
body = client.request("PATCH", path, json_body=payload)
|
|
433
|
+
break
|
|
434
|
+
except ApiError as exc:
|
|
435
|
+
if exc.code not in ("lock_confirmation_required", "lock_quote_changed"):
|
|
436
|
+
err(str(exc))
|
|
437
|
+
return _api_exit(exc)
|
|
438
|
+
until = exc.details.get("locked_until")
|
|
439
|
+
last = _last_billed_day(until)
|
|
440
|
+
moved = "The UTC day changed, so the date moved. " if exc.code == "lock_quote_changed" else ""
|
|
441
|
+
ask = (
|
|
442
|
+
f"{moved}Turning this back on commits 30 days: it stays on, and bills each UTC "
|
|
443
|
+
f"day, through {last}."
|
|
444
|
+
)
|
|
445
|
+
if not _interactive():
|
|
446
|
+
if args.json:
|
|
447
|
+
print(json.dumps({"error": {"code": exc.code, "message": ask, "details": exc.details}}))
|
|
448
|
+
err(f"{ask} Pass --commit-lock --expected-locked-until {until} to commit to that.")
|
|
449
|
+
return EXIT_USAGE
|
|
450
|
+
if not _yes(f"{ask} Commit to 30 days? [y/N] "):
|
|
451
|
+
print("Cancelled. Monitoring is still off.")
|
|
452
|
+
return EXIT_OK
|
|
453
|
+
payload = {"active": True, "commit_lock": True, "expected_locked_until": until}
|
|
454
|
+
else:
|
|
455
|
+
err("the API kept refusing the commitment; nothing was changed")
|
|
456
|
+
return EXIT_FAIL
|
|
457
|
+
if body.get("active") and body.get("stop_scheduled_at"):
|
|
458
|
+
human = (
|
|
459
|
+
f"monitoring stays ON for {rid} through {_last_billed_day(body['stop_scheduled_at'])}, "
|
|
460
|
+
"the end of the 30 days committed to; it stops after that"
|
|
461
|
+
)
|
|
462
|
+
elif body.get("active"):
|
|
463
|
+
state = "billing daily" if body.get("billing") else (
|
|
464
|
+
f"free until {body['free_until']}" if body.get("free_until") else "not started yet"
|
|
465
|
+
)
|
|
466
|
+
if body.get("locked_until"):
|
|
467
|
+
state += f", committed through {_last_billed_day(body['locked_until'])}"
|
|
468
|
+
human = f"monitoring ON for {rid} ({state})"
|
|
469
|
+
else:
|
|
470
|
+
human = f"monitoring OFF for {rid}; it stops billing from tomorrow (today still counts)"
|
|
471
|
+
out(body, args.json, human)
|
|
472
|
+
return EXIT_OK
|
|
473
|
+
|
|
474
|
+
|
|
354
475
|
def _uuid_arg(value: str, what: str) -> str | None:
|
|
355
476
|
"""An id interpolated into a URL path must be a UUID, never a path fragment."""
|
|
356
477
|
import uuid as _uuid # noqa: PLC0415
|
|
@@ -426,7 +547,7 @@ def cmd_chat_revert(args) -> int:
|
|
|
426
547
|
|
|
427
548
|
|
|
428
549
|
def cmd_members_list(args) -> int:
|
|
429
|
-
"""Who is in this
|
|
550
|
+
"""Who is in this organization and how many editor seats are in use (#985).
|
|
430
551
|
Read-only; inviting is a human-admin action in the UI."""
|
|
431
552
|
client, _ = _require_auth(args)
|
|
432
553
|
try:
|
|
@@ -507,7 +628,7 @@ def cmd_changes_undo(args) -> int:
|
|
|
507
628
|
err("change id must be a UUID (from `deepsieve changes list`)")
|
|
508
629
|
return EXIT_USAGE
|
|
509
630
|
if not args.yes:
|
|
510
|
-
if not
|
|
631
|
+
if not _interactive():
|
|
511
632
|
err("refusing to change data non-interactively; pass --yes")
|
|
512
633
|
return EXIT_USAGE
|
|
513
634
|
if input(f"Undo change {change_id}? This changes your dataset. [y/N] ").strip().lower() not in ("y", "yes"):
|
|
@@ -574,25 +695,25 @@ def cmd_holds_list(args) -> int:
|
|
|
574
695
|
|
|
575
696
|
|
|
576
697
|
def _hold_verb(args, verb: str) -> int:
|
|
577
|
-
"""Apply or release one held cell.
|
|
578
|
-
confirmed like an undo: interactively, or with --yes, never silently in a pipe."""
|
|
698
|
+
"""Apply, keep or release one held cell. Each changes what the dataset protects, so they
|
|
699
|
+
are confirmed like an undo: interactively, or with --yes, never silently in a pipe."""
|
|
579
700
|
client, _ = _require_auth(args)
|
|
580
701
|
if not _CELL_ID.fullmatch(args.cell_id or ""):
|
|
581
702
|
err("cell id must be one `deepsieve holds list` printed (it starts with cel_)")
|
|
582
703
|
return EXIT_USAGE
|
|
583
704
|
body: dict[str, Any] = {}
|
|
584
|
-
if verb
|
|
705
|
+
if verb in ("apply", "keep") and args.finding:
|
|
585
706
|
try:
|
|
586
707
|
body["finding_evidence_id"] = str(uuid.UUID(args.finding))
|
|
587
708
|
except ValueError:
|
|
588
709
|
err("--finding must be the finding's evidence id (a UUID)")
|
|
589
710
|
return EXIT_USAGE
|
|
590
711
|
if not args.yes:
|
|
591
|
-
if not
|
|
712
|
+
if not _interactive():
|
|
592
713
|
err("refusing to change data non-interactively; pass --yes")
|
|
593
714
|
return EXIT_USAGE
|
|
594
|
-
what =
|
|
595
|
-
if input(f"{what}
|
|
715
|
+
what = _HOLD_ASK[verb]
|
|
716
|
+
if input(f"{what}? [y/N] ").strip().lower() not in ("y", "yes"):
|
|
596
717
|
print("Cancelled.")
|
|
597
718
|
return EXIT_OK
|
|
598
719
|
headers = {"Idempotency-Key": args.idempotency_key} if args.idempotency_key else None
|
|
@@ -603,15 +724,30 @@ def _hold_verb(args, verb: str) -> int:
|
|
|
603
724
|
except ApiError as exc:
|
|
604
725
|
err(str(exc))
|
|
605
726
|
return EXIT_FAIL
|
|
606
|
-
|
|
607
|
-
out(result, args.json, f"{done}: {result.get('column')} = {result.get('value')!r}")
|
|
727
|
+
out(result, args.json, f"{_HOLD_DONE[verb]}: {result.get('column')} = {result.get('value')!r}")
|
|
608
728
|
return EXIT_OK
|
|
609
729
|
|
|
610
730
|
|
|
731
|
+
_HOLD_ASK = {
|
|
732
|
+
"apply": "Take research's newer value for this cell",
|
|
733
|
+
"keep": "Keep your value and dismiss research's newer value for this cell",
|
|
734
|
+
"release": "Stop protecting this cell",
|
|
735
|
+
}
|
|
736
|
+
_HOLD_DONE = {
|
|
737
|
+
"apply": "applied; the cell stays protected",
|
|
738
|
+
"keep": "kept; research's value is dismissed and the cell stays protected",
|
|
739
|
+
"release": "released; research may update it",
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
|
|
611
743
|
def cmd_holds_apply(args) -> int:
|
|
612
744
|
return _hold_verb(args, "apply")
|
|
613
745
|
|
|
614
746
|
|
|
747
|
+
def cmd_holds_keep(args) -> int:
|
|
748
|
+
return _hold_verb(args, "keep")
|
|
749
|
+
|
|
750
|
+
|
|
615
751
|
def cmd_holds_release(args) -> int:
|
|
616
752
|
return _hold_verb(args, "release")
|
|
617
753
|
|
|
@@ -651,6 +787,10 @@ def _print_merge(body: dict) -> None:
|
|
|
651
787
|
"or pass --accept-survivor-on-ties")
|
|
652
788
|
|
|
653
789
|
|
|
790
|
+
#: Merge refusals fixed by changing the command, not by retrying: exit 2, not 1.
|
|
791
|
+
_MERGE_USAGE_CODES = frozenset({"ties_unresolved", "merge_across_parents"})
|
|
792
|
+
|
|
793
|
+
|
|
654
794
|
def cmd_merge_preview(args) -> int:
|
|
655
795
|
"""What a merge would do; writes nothing (#1577 2c-3)."""
|
|
656
796
|
client, _ = _require_auth(args)
|
|
@@ -661,7 +801,7 @@ def cmd_merge_preview(args) -> int:
|
|
|
661
801
|
out_ = client.request("POST", "/v1/merges/preview", json_body=body)
|
|
662
802
|
except ApiError as exc:
|
|
663
803
|
err(str(exc))
|
|
664
|
-
return EXIT_FAIL
|
|
804
|
+
return EXIT_USAGE if exc.code in _MERGE_USAGE_CODES else EXIT_FAIL
|
|
665
805
|
if args.json:
|
|
666
806
|
out(out_, True)
|
|
667
807
|
else:
|
|
@@ -678,7 +818,7 @@ def cmd_merge_apply(args) -> int:
|
|
|
678
818
|
return EXIT_USAGE
|
|
679
819
|
body["accept_survivor_on_ties"] = bool(args.accept_survivor_on_ties)
|
|
680
820
|
if not args.yes:
|
|
681
|
-
if not
|
|
821
|
+
if not _interactive():
|
|
682
822
|
err("refusing to change data non-interactively; pass --yes")
|
|
683
823
|
return EXIT_USAGE
|
|
684
824
|
prompt = f"Merge {len(body['loser_ids'])} row(s) into {args.survivor}? This changes your dataset. [y/N] "
|
|
@@ -692,8 +832,9 @@ def cmd_merge_apply(args) -> int:
|
|
|
692
832
|
err(str(exc))
|
|
693
833
|
# An open tie is fixed by changing the command (--choice, or
|
|
694
834
|
# --accept-survivor-on-ties), so it exits as a usage error: a script can tell it
|
|
695
|
-
# apart from a failure without --json.
|
|
696
|
-
|
|
835
|
+
# apart from a failure without --json. So is a merge across parents (#2254): no retry
|
|
836
|
+
# can succeed, only a different set of rows.
|
|
837
|
+
return EXIT_USAGE if exc.code in _MERGE_USAGE_CODES else EXIT_FAIL
|
|
697
838
|
if args.json:
|
|
698
839
|
out(out_, True)
|
|
699
840
|
else:
|
|
@@ -1005,8 +1146,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1005
1146
|
help="restrict evidence to this source (repeatable): a host, *.gov, "
|
|
1006
1147
|
"https://host/path/*, or blueprint:primary; a cell cited only "
|
|
1007
1148
|
"elsewhere is not written")
|
|
1008
|
-
rc.add_argument("--blueprint", help="Blueprint id to research
|
|
1009
|
-
"
|
|
1149
|
+
rc.add_argument("--blueprint", help="Blueprint id to research; REQUIRED unless --dry-run "
|
|
1150
|
+
"(`deepsieve whoami` shows your active Blueprint's id)")
|
|
1010
1151
|
rc.add_argument("--depth", default="standard", choices=["standard", "max"])
|
|
1011
1152
|
rc.add_argument("--dry-run", action="store_true", help="free simulated run (~15s)")
|
|
1012
1153
|
rc.add_argument("--no-monitor", dest="monitored", action="store_false",
|
|
@@ -1028,6 +1169,28 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1028
1169
|
rx.add_argument("run_id")
|
|
1029
1170
|
rx.set_defaults(func=cmd_runs_cancel)
|
|
1030
1171
|
|
|
1172
|
+
rm = runs.add_parser(
|
|
1173
|
+
"monitoring",
|
|
1174
|
+
help="turn a report's monitoring on or off (ON bills daily past the free 30 days)",
|
|
1175
|
+
parents=[common],
|
|
1176
|
+
)
|
|
1177
|
+
rm.add_argument("run_id")
|
|
1178
|
+
onoff = rm.add_mutually_exclusive_group(required=True)
|
|
1179
|
+
onoff.add_argument("--on", dest="active", action="store_true")
|
|
1180
|
+
onoff.add_argument("--off", dest="active", action="store_false")
|
|
1181
|
+
rm.add_argument("--yes", action="store_true", help="do not ask for confirmation")
|
|
1182
|
+
rm.add_argument(
|
|
1183
|
+
"--commit-lock",
|
|
1184
|
+
action="store_true",
|
|
1185
|
+
help="with --on after the free 30 days: commit to 30 days of monitoring (billed daily)",
|
|
1186
|
+
)
|
|
1187
|
+
rm.add_argument(
|
|
1188
|
+
"--expected-locked-until",
|
|
1189
|
+
default="",
|
|
1190
|
+
help="with --commit-lock: the locked_until you were quoted; refused if the date has moved",
|
|
1191
|
+
)
|
|
1192
|
+
rm.set_defaults(func=cmd_runs_monitoring)
|
|
1193
|
+
|
|
1031
1194
|
chat = sub.add_parser("chat", help="a run's chat and the changes it proposed", parents=[common]).add_subparsers(dest="sub", required=True)
|
|
1032
1195
|
ct = chat.add_parser("thread", help="the thread, with each change's status and warnings", parents=[common])
|
|
1033
1196
|
ct.add_argument("run_id")
|
|
@@ -1041,8 +1204,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1041
1204
|
cp.add_argument("action_id")
|
|
1042
1205
|
cp.set_defaults(func=fn)
|
|
1043
1206
|
|
|
1044
|
-
members = sub.add_parser("members", help="your
|
|
1045
|
-
ml = members.add_parser("list", help="who is in the
|
|
1207
|
+
members = sub.add_parser("members", help="your organization's team", parents=[common]).add_subparsers(dest="sub", required=True)
|
|
1208
|
+
ml = members.add_parser("list", help="who is in the organization, and seat usage", parents=[common])
|
|
1046
1209
|
ml.set_defaults(func=cmd_members_list)
|
|
1047
1210
|
|
|
1048
1211
|
changes = sub.add_parser("changes", help="merges, renames and successor links", parents=[common]).add_subparsers(dest="sub", required=True)
|
|
@@ -1077,6 +1240,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1077
1240
|
ha.add_argument("--yes", action="store_true", help="do not ask for confirmation")
|
|
1078
1241
|
ha.add_argument("--idempotency-key", help="make a retry safe")
|
|
1079
1242
|
ha.set_defaults(func=cmd_holds_apply)
|
|
1243
|
+
hk = holds.add_parser("keep", help="keep your value and dismiss research's newer one (needs data:write)", parents=[common])
|
|
1244
|
+
hk.add_argument("cell_id")
|
|
1245
|
+
hk.add_argument("--finding", help="the `finding` id `holds list` printed for the value you reviewed (a newer one since is refused)")
|
|
1246
|
+
hk.add_argument("--yes", action="store_true", help="do not ask for confirmation")
|
|
1247
|
+
hk.add_argument("--idempotency-key", help="make a retry safe")
|
|
1248
|
+
hk.set_defaults(func=cmd_holds_keep)
|
|
1080
1249
|
hr = holds.add_parser("release", help="stop protecting a cell (needs data:write)", parents=[common])
|
|
1081
1250
|
hr.add_argument("cell_id")
|
|
1082
1251
|
hr.add_argument("--yes", action="store_true", help="do not ask for confirmation")
|
|
@@ -1143,7 +1312,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1143
1312
|
return EXIT_FAIL
|
|
1144
1313
|
except ApiError as exc:
|
|
1145
1314
|
err(str(exc))
|
|
1146
|
-
return
|
|
1315
|
+
return _api_exit(exc)
|
|
1147
1316
|
|
|
1148
1317
|
|
|
1149
1318
|
if __name__ == "__main__": # pragma: no cover
|
|
@@ -208,7 +208,7 @@ def test_commands_refuse_without_a_credential(capsys):
|
|
|
208
208
|
def test_billable_run_is_refused_non_interactively_without_yes(monkeypatch, capsys):
|
|
209
209
|
cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
|
|
210
210
|
monkeypatch.setattr("deepsieve_cli.main._tty", lambda: False)
|
|
211
|
-
code = main(["runs", "create", "--query", "anything"])
|
|
211
|
+
code = main(["runs", "create", "--query", "anything", "--blueprint", "bp-a"])
|
|
212
212
|
assert code == 2
|
|
213
213
|
assert "refusing to start a billable run" in capsys.readouterr().err
|
|
214
214
|
|
|
@@ -976,6 +976,19 @@ def test_an_unsettled_tie_surfaces_the_servers_refusal(monkeypatch, capsys):
|
|
|
976
976
|
assert f"author::{_L}" in capsys.readouterr().err
|
|
977
977
|
|
|
978
978
|
|
|
979
|
+
def test_a_merge_across_parents_exits_as_a_usage_error(monkeypatch, capsys):
|
|
980
|
+
"""#2254: no retry can succeed, only different rows, so a script sees 2, never 1."""
|
|
981
|
+
_signed_in(monkeypatch)
|
|
982
|
+
body = {"error": {"type": "conflict_error", "code": "merge_across_parents", "retriable": False,
|
|
983
|
+
"message": "These books belong to different publishers (A, B).",
|
|
984
|
+
"param": "loser_ids"}}
|
|
985
|
+
monkeypatch.setattr(httpx, "Client", _mock(lambda r: httpx.Response(409, json=body)))
|
|
986
|
+
for argv in (["merge", "apply", "books", "--survivor", _S, "--loser", _L, "--yes"],
|
|
987
|
+
["merge", "preview", "books", "--survivor", _S, "--loser", _L]):
|
|
988
|
+
assert main(argv) == 2, argv
|
|
989
|
+
assert "different publishers" in capsys.readouterr().err
|
|
990
|
+
|
|
991
|
+
|
|
979
992
|
# ---------------------------------------------------------------------------
|
|
980
993
|
# #2196 PR 2: holds list | apply | release
|
|
981
994
|
# ---------------------------------------------------------------------------
|
|
@@ -1045,6 +1058,31 @@ def test_holds_apply_posts_the_finding_and_the_idempotency_key(monkeypatch, caps
|
|
|
1045
1058
|
assert "stays protected" in capsys.readouterr().out
|
|
1046
1059
|
|
|
1047
1060
|
|
|
1061
|
+
def test_holds_keep_posts_the_finding_to_keep_and_asks_in_a_pipe(monkeypatch, capsys):
|
|
1062
|
+
"""#2294: Keep mine, the third verb, confirmed like the other two."""
|
|
1063
|
+
_signed_in(monkeypatch)
|
|
1064
|
+
seen = {}
|
|
1065
|
+
|
|
1066
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
1067
|
+
seen["path"], seen["key"] = request.url.path, request.headers.get("idempotency-key")
|
|
1068
|
+
seen["body"] = json.loads(request.content or b"{}")
|
|
1069
|
+
return httpx.Response(200, json={"object": "cell", "column": "author", "value": "Austen",
|
|
1070
|
+
"held": True})
|
|
1071
|
+
|
|
1072
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
1073
|
+
rc = main(["holds", "keep", CELL, "--finding", CID, "--yes", "--idempotency-key", "k-3"])
|
|
1074
|
+
assert rc == 0
|
|
1075
|
+
assert (seen["path"], seen["key"]) == (f"/v1/cells/{CELL}/keep", "k-3")
|
|
1076
|
+
assert seen["body"] == {"finding_evidence_id": CID}
|
|
1077
|
+
assert "dismissed" in capsys.readouterr().out
|
|
1078
|
+
|
|
1079
|
+
monkeypatch.setattr("deepsieve_cli.main._tty", lambda: False)
|
|
1080
|
+
seen.clear()
|
|
1081
|
+
assert main(["holds", "keep", CELL]) == 2
|
|
1082
|
+
assert "pass --yes" in capsys.readouterr().err
|
|
1083
|
+
assert seen == {}, "nothing sent"
|
|
1084
|
+
|
|
1085
|
+
|
|
1048
1086
|
def test_holds_release_refuses_a_malformed_cell_id_and_sends_nothing(monkeypatch, capsys):
|
|
1049
1087
|
"""The id goes into the URL path, so a crafted one could reach another endpoint."""
|
|
1050
1088
|
_signed_in(monkeypatch)
|
|
@@ -1052,3 +1090,271 @@ def test_holds_release_refuses_a_malformed_cell_id_and_sends_nothing(monkeypatch
|
|
|
1052
1090
|
monkeypatch.setattr(httpx, "Client", _mock(lambda r: calls.append(r) or httpx.Response(200)))
|
|
1053
1091
|
assert main(["holds", "release", "../research/runs/x/cancel#", "--yes"]) == 2
|
|
1054
1092
|
assert calls == []
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
def test_a_billable_run_without_a_blueprint_is_a_usage_error_before_any_request(monkeypatch, capsys):
|
|
1096
|
+
"""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)."""
|
|
1098
|
+
cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
|
|
1099
|
+
monkeypatch.setattr("deepsieve_cli.main._tty", lambda: False)
|
|
1100
|
+
sent: list[str] = []
|
|
1101
|
+
|
|
1102
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
1103
|
+
sent.append(f"{request.method} {request.url.path}")
|
|
1104
|
+
return httpx.Response(202, json={"id": "run_1", "status": "queued"})
|
|
1105
|
+
|
|
1106
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
1107
|
+
code = main(["runs", "create", "--query", "anything", "--yes"])
|
|
1108
|
+
err = capsys.readouterr().err
|
|
1109
|
+
assert code == 2, err
|
|
1110
|
+
assert "--blueprint" in err and "whoami" in err
|
|
1111
|
+
assert sent == [], sent
|
|
1112
|
+
# Positive control: the same handler DOES see a request when the call is allowed.
|
|
1113
|
+
assert main(["runs", "create", "--query", "anything", "--dry-run"]) == 0
|
|
1114
|
+
assert sent == ["POST /v1/research/runs"], sent
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
def test_a_run_refused_by_the_model_allowance_exits_5_not_1(monkeypatch, capsys):
|
|
1118
|
+
"""gh #2307: a script can tell "the org's model allowance is used" from a failure."""
|
|
1119
|
+
_signed_in(monkeypatch)
|
|
1120
|
+
body = {"error": {"type": "rate_limit_error", "code": "model_allowance_exhausted",
|
|
1121
|
+
"retriable": False, "param": None,
|
|
1122
|
+
"message": "Your organization has used this period's allowance."}}
|
|
1123
|
+
monkeypatch.setattr(httpx, "Client", _mock(lambda r: httpx.Response(429, json=body)))
|
|
1124
|
+
assert main(["runs", "create", "--query", "EU payment vendors", "--blueprint", "bp-a", "--yes"]) == 5
|
|
1125
|
+
assert "allowance" in capsys.readouterr().err
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
# ── runs monitoring (gh #2345) ───────────────────────────────────────────────
|
|
1129
|
+
|
|
1130
|
+
RUN = "3f2a8e1c-0000-4000-8000-000000002345"
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
def _monitoring_body(active: bool) -> dict:
|
|
1134
|
+
return {
|
|
1135
|
+
"object": "monitoring",
|
|
1136
|
+
"run_id": RUN,
|
|
1137
|
+
"active": active,
|
|
1138
|
+
"units": 2,
|
|
1139
|
+
"in_free_window": True,
|
|
1140
|
+
"free_until": "2026-10-28",
|
|
1141
|
+
"billing": False,
|
|
1142
|
+
"reruns": {"status": "ok"},
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
@pytest.mark.parametrize("flag,active", [("--on", True), ("--off", False)])
|
|
1147
|
+
def test_runs_monitoring_patches_the_state_asked_for(monkeypatch, capsys, flag, active):
|
|
1148
|
+
_signed_in(monkeypatch)
|
|
1149
|
+
seen = {}
|
|
1150
|
+
|
|
1151
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
1152
|
+
seen["method"], seen["path"] = request.method, request.url.path
|
|
1153
|
+
seen["body"] = json.loads(request.content)
|
|
1154
|
+
return httpx.Response(200, json=_monitoring_body(active))
|
|
1155
|
+
|
|
1156
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
1157
|
+
assert main(["runs", "monitoring", RUN, flag, "--yes"]) == 0
|
|
1158
|
+
assert seen == {
|
|
1159
|
+
"method": "PATCH",
|
|
1160
|
+
"path": f"/v1/research/runs/{RUN}/monitoring",
|
|
1161
|
+
"body": {"active": active},
|
|
1162
|
+
}
|
|
1163
|
+
text = capsys.readouterr().out
|
|
1164
|
+
assert ("ON" if active else "OFF") in text
|
|
1165
|
+
|
|
1166
|
+
|
|
1167
|
+
def test_runs_monitoring_is_refused_in_a_pipe_without_yes_and_sends_nothing(monkeypatch, capsys):
|
|
1168
|
+
_signed_in(monkeypatch)
|
|
1169
|
+
monkeypatch.setattr("deepsieve_cli.main._tty", lambda: False)
|
|
1170
|
+
calls = []
|
|
1171
|
+
monkeypatch.setattr(httpx, "Client", _mock(lambda r: calls.append(r) or httpx.Response(200)))
|
|
1172
|
+
assert main(["runs", "monitoring", RUN, "--on"]) == 2
|
|
1173
|
+
assert "pass --yes" in capsys.readouterr().err
|
|
1174
|
+
assert calls == []
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
def test_runs_monitoring_needs_on_or_off_and_a_uuid(monkeypatch, capsys):
|
|
1178
|
+
_signed_in(monkeypatch)
|
|
1179
|
+
monkeypatch.setattr(httpx, "Client", _mock(lambda r: pytest.fail("no request expected")))
|
|
1180
|
+
with pytest.raises(SystemExit) as exc:
|
|
1181
|
+
main(["runs", "monitoring", RUN, "--yes"]) # neither --on nor --off
|
|
1182
|
+
assert exc.value.code == 2
|
|
1183
|
+
assert main(["runs", "monitoring", "../billing", "--off", "--yes"]) == 2
|
|
1184
|
+
|
|
1185
|
+
|
|
1186
|
+
def test_runs_monitoring_surfaces_a_404(monkeypatch, capsys):
|
|
1187
|
+
_signed_in(monkeypatch)
|
|
1188
|
+
|
|
1189
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
1190
|
+
return httpx.Response(
|
|
1191
|
+
404,
|
|
1192
|
+
json={"error": {"code": "not_found", "message": "This run has no monitoring to set"}},
|
|
1193
|
+
)
|
|
1194
|
+
|
|
1195
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
1196
|
+
assert main(["runs", "monitoring", RUN, "--off", "--yes"]) == 1
|
|
1197
|
+
assert "no monitoring" in capsys.readouterr().err
|
|
1198
|
+
|
|
1199
|
+
|
|
1200
|
+
# ── runs monitoring: the 30-day re-enable lock (gh #2345 x #2359) ────────────
|
|
1201
|
+
|
|
1202
|
+
_LOCK_409 = {
|
|
1203
|
+
"error": {
|
|
1204
|
+
"code": "lock_confirmation_required",
|
|
1205
|
+
"message": "Turning this back on commits 30 days",
|
|
1206
|
+
"param": "commit_lock",
|
|
1207
|
+
"retriable": False,
|
|
1208
|
+
"details": {"locked_until": "2026-10-28T00:00:00+00:00"},
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
def _lock_handler(seen: list):
|
|
1214
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
1215
|
+
body = json.loads(request.content)
|
|
1216
|
+
seen.append(body)
|
|
1217
|
+
if not body.get("commit_lock"):
|
|
1218
|
+
return httpx.Response(409, json=_LOCK_409)
|
|
1219
|
+
return httpx.Response(
|
|
1220
|
+
200,
|
|
1221
|
+
json={**_monitoring_body(True), "locked_until": "2026-10-28T00:00:00+00:00"},
|
|
1222
|
+
)
|
|
1223
|
+
|
|
1224
|
+
return handler
|
|
1225
|
+
|
|
1226
|
+
|
|
1227
|
+
def test_a_lock_in_a_pipe_is_refused_naming_the_last_day_and_the_flag(monkeypatch, capsys):
|
|
1228
|
+
"""--yes alone never commits 30 days: a script must say --commit-lock."""
|
|
1229
|
+
_signed_in(monkeypatch)
|
|
1230
|
+
monkeypatch.setattr("deepsieve_cli.main._tty", lambda: False)
|
|
1231
|
+
seen: list = []
|
|
1232
|
+
monkeypatch.setattr(httpx, "Client", _mock(_lock_handler(seen)))
|
|
1233
|
+
assert main(["runs", "monitoring", RUN, "--on", "--yes"]) == 2
|
|
1234
|
+
err_text = capsys.readouterr().err
|
|
1235
|
+
print("refusal:", err_text)
|
|
1236
|
+
assert "through 2026-10-27" in err_text and "--commit-lock" in err_text
|
|
1237
|
+
assert "--expected-locked-until 2026-10-28T00:00:00+00:00" in err_text
|
|
1238
|
+
assert seen == [{"active": True}]
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
def test_commit_lock_commits_in_one_call(monkeypatch, capsys):
|
|
1242
|
+
_signed_in(monkeypatch)
|
|
1243
|
+
seen: list = []
|
|
1244
|
+
monkeypatch.setattr(httpx, "Client", _mock(_lock_handler(seen)))
|
|
1245
|
+
assert main(["runs", "monitoring", RUN, "--on", "--yes", "--commit-lock"]) == 0
|
|
1246
|
+
assert seen == [{"active": True, "commit_lock": True}]
|
|
1247
|
+
assert "committed through 2026-10-27" in capsys.readouterr().out
|
|
1248
|
+
|
|
1249
|
+
|
|
1250
|
+
def test_interactively_the_lock_is_a_second_explicit_question(monkeypatch, capsys):
|
|
1251
|
+
_signed_in(monkeypatch)
|
|
1252
|
+
monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: True)
|
|
1253
|
+
answers = iter(["y", "y"])
|
|
1254
|
+
monkeypatch.setattr("builtins.input", lambda prompt="": next(answers))
|
|
1255
|
+
seen: list = []
|
|
1256
|
+
monkeypatch.setattr(httpx, "Client", _mock(_lock_handler(seen)))
|
|
1257
|
+
assert main(["runs", "monitoring", RUN, "--on"]) == 0
|
|
1258
|
+
# The yes is bound to the date the person was shown (gh #2345).
|
|
1259
|
+
assert seen == [
|
|
1260
|
+
{"active": True},
|
|
1261
|
+
{
|
|
1262
|
+
"active": True,
|
|
1263
|
+
"commit_lock": True,
|
|
1264
|
+
"expected_locked_until": "2026-10-28T00:00:00+00:00",
|
|
1265
|
+
},
|
|
1266
|
+
]
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
def test_declining_the_lock_sends_nothing_more(monkeypatch, capsys):
|
|
1270
|
+
_signed_in(monkeypatch)
|
|
1271
|
+
monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: True)
|
|
1272
|
+
answers = iter(["y", "n"])
|
|
1273
|
+
monkeypatch.setattr("builtins.input", lambda prompt="": next(answers))
|
|
1274
|
+
seen: list = []
|
|
1275
|
+
monkeypatch.setattr(httpx, "Client", _mock(_lock_handler(seen)))
|
|
1276
|
+
assert main(["runs", "monitoring", RUN, "--on"]) == 0
|
|
1277
|
+
assert seen == [{"active": True}]
|
|
1278
|
+
assert "still off" in capsys.readouterr().out
|
|
1279
|
+
|
|
1280
|
+
|
|
1281
|
+
def test_a_piped_yes_from_a_terminal_never_commits_30_days(monkeypatch, capsys):
|
|
1282
|
+
"""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."""
|
|
1284
|
+
import io
|
|
1285
|
+
|
|
1286
|
+
_signed_in(monkeypatch)
|
|
1287
|
+
monkeypatch.setattr("sys.stdout.isatty", lambda: True, raising=False)
|
|
1288
|
+
monkeypatch.setattr("deepsieve_cli.main._tty", lambda: True)
|
|
1289
|
+
monkeypatch.setattr("sys.stdin", io.StringIO("y\ny\ny\n"))
|
|
1290
|
+
seen: list = []
|
|
1291
|
+
monkeypatch.setattr(httpx, "Client", _mock(_lock_handler(seen)))
|
|
1292
|
+
rc = main(["runs", "monitoring", RUN, "--on"])
|
|
1293
|
+
print("piped yes:", rc, seen)
|
|
1294
|
+
assert rc == 2
|
|
1295
|
+
assert seen == [], "no request at all: a pipe is refused before the first question"
|
|
1296
|
+
|
|
1297
|
+
|
|
1298
|
+
def test_a_lock_refusal_under_json_is_machine_readable(monkeypatch, capsys):
|
|
1299
|
+
_signed_in(monkeypatch)
|
|
1300
|
+
monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: False)
|
|
1301
|
+
seen: list = []
|
|
1302
|
+
monkeypatch.setattr(httpx, "Client", _mock(_lock_handler(seen)))
|
|
1303
|
+
assert main(["runs", "monitoring", RUN, "--on", "--yes", "--json"]) == 2
|
|
1304
|
+
out_json = json.loads(capsys.readouterr().out)
|
|
1305
|
+
assert out_json["error"]["code"] == "lock_confirmation_required"
|
|
1306
|
+
assert out_json["error"]["details"] == {"locked_until": "2026-10-28T00:00:00+00:00"}
|
|
1307
|
+
|
|
1308
|
+
|
|
1309
|
+
def test_a_moved_date_is_asked_again_with_the_new_date(monkeypatch, capsys):
|
|
1310
|
+
"""Quoted before 00:00Z, confirmed after: the API answers `lock_quote_changed` with the new
|
|
1311
|
+
date; the person is asked again and the new yes is bound to the new date."""
|
|
1312
|
+
_signed_in(monkeypatch)
|
|
1313
|
+
monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: True)
|
|
1314
|
+
answers = iter(["y", "y", "y"])
|
|
1315
|
+
prompts: list = []
|
|
1316
|
+
monkeypatch.setattr("builtins.input", lambda prompt="": prompts.append(prompt) or next(answers))
|
|
1317
|
+
seen: list = []
|
|
1318
|
+
moved = {
|
|
1319
|
+
"error": {
|
|
1320
|
+
"code": "lock_quote_changed",
|
|
1321
|
+
"message": "The UTC day changed",
|
|
1322
|
+
"details": {"locked_until": "2026-10-29T00:00:00+00:00"},
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
def handler(request):
|
|
1327
|
+
body = json.loads(request.content)
|
|
1328
|
+
seen.append(body)
|
|
1329
|
+
if not body.get("commit_lock"):
|
|
1330
|
+
return httpx.Response(409, json=_LOCK_409)
|
|
1331
|
+
if body.get("expected_locked_until") != "2026-10-29T00:00:00+00:00":
|
|
1332
|
+
return httpx.Response(409, json=moved)
|
|
1333
|
+
return httpx.Response(200, json={**_monitoring_body(True), "locked_until": "2026-10-29T00:00:00+00:00"})
|
|
1334
|
+
|
|
1335
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
1336
|
+
assert main(["runs", "monitoring", RUN, "--on"]) == 0
|
|
1337
|
+
assert [b.get("expected_locked_until") for b in seen] == [
|
|
1338
|
+
None,
|
|
1339
|
+
"2026-10-28T00:00:00+00:00",
|
|
1340
|
+
"2026-10-29T00:00:00+00:00",
|
|
1341
|
+
]
|
|
1342
|
+
assert "through 2026-10-28" in prompts[-1] and "date moved" in prompts[-1]
|
|
1343
|
+
|
|
1344
|
+
|
|
1345
|
+
def test_end_of_input_at_the_lock_question_is_a_no_not_a_traceback(monkeypatch, capsys):
|
|
1346
|
+
_signed_in(monkeypatch)
|
|
1347
|
+
monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: True)
|
|
1348
|
+
answers = iter(["y"])
|
|
1349
|
+
|
|
1350
|
+
def _input(prompt=""):
|
|
1351
|
+
try:
|
|
1352
|
+
return next(answers)
|
|
1353
|
+
except StopIteration:
|
|
1354
|
+
raise EOFError from None
|
|
1355
|
+
|
|
1356
|
+
monkeypatch.setattr("builtins.input", _input)
|
|
1357
|
+
seen: list = []
|
|
1358
|
+
monkeypatch.setattr(httpx, "Client", _mock(_lock_handler(seen)))
|
|
1359
|
+
assert main(["runs", "monitoring", RUN, "--on"]) == 0
|
|
1360
|
+
assert seen == [{"active": True}]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|