deepsieve-cli 0.6.0__tar.gz → 0.8.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.6.0 → deepsieve_cli-0.8.0}/PKG-INFO +4 -1
- {deepsieve_cli-0.6.0 → deepsieve_cli-0.8.0}/README.md +3 -0
- {deepsieve_cli-0.6.0 → deepsieve_cli-0.8.0}/pyproject.toml +1 -1
- {deepsieve_cli-0.6.0 → deepsieve_cli-0.8.0}/src/deepsieve_cli/main.py +347 -5
- {deepsieve_cli-0.6.0 → deepsieve_cli-0.8.0}/tests/test_cli.py +489 -1
- {deepsieve_cli-0.6.0 → deepsieve_cli-0.8.0}/.gitignore +0 -0
- {deepsieve_cli-0.6.0 → deepsieve_cli-0.8.0}/src/deepsieve_cli/__init__.py +0 -0
- {deepsieve_cli-0.6.0 → deepsieve_cli-0.8.0}/src/deepsieve_cli/__main__.py +0 -0
- {deepsieve_cli-0.6.0 → deepsieve_cli-0.8.0}/src/deepsieve_cli/client.py +0 -0
- {deepsieve_cli-0.6.0 → deepsieve_cli-0.8.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.8.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
|
|
@@ -44,8 +44,11 @@ deepsieve runs retry <id> # a retry that charges shows its t
|
|
|
44
44
|
deepsieve runs reruns <id> # a report's re-research waiting for approval
|
|
45
45
|
deepsieve runs monitoring <id> --off # stop a report's monitoring; asks first
|
|
46
46
|
deepsieve links remove <entity> <record-id> <linked-entity> <target-id> # asks first
|
|
47
|
+
deepsieve subscription cancel # at period end; nothing new is charged; asks first
|
|
48
|
+
deepsieve subscription resume # undo it while the period runs; asks first
|
|
47
49
|
deepsieve data catalog # entities + columns (never guess)
|
|
48
50
|
deepsieve data get companies --receipts # rows with per-cell citations
|
|
51
|
+
deepsieve data history companies headquarters_city --record <id> # one cell's change history
|
|
49
52
|
deepsieve chat thread <run-id> # changes, with status + warnings
|
|
50
53
|
deepsieve chat confirm <action-id> # apply a change that waits for confirmation
|
|
51
54
|
deepsieve chat dismiss <action-id> # decline it; nothing is written
|
|
@@ -36,8 +36,11 @@ deepsieve runs retry <id> # a retry that charges shows its t
|
|
|
36
36
|
deepsieve runs reruns <id> # a report's re-research waiting for approval
|
|
37
37
|
deepsieve runs monitoring <id> --off # stop a report's monitoring; asks first
|
|
38
38
|
deepsieve links remove <entity> <record-id> <linked-entity> <target-id> # asks first
|
|
39
|
+
deepsieve subscription cancel # at period end; nothing new is charged; asks first
|
|
40
|
+
deepsieve subscription resume # undo it while the period runs; asks first
|
|
39
41
|
deepsieve data catalog # entities + columns (never guess)
|
|
40
42
|
deepsieve data get companies --receipts # rows with per-cell citations
|
|
43
|
+
deepsieve data history companies headquarters_city --record <id> # one cell's change history
|
|
41
44
|
deepsieve chat thread <run-id> # changes, with status + warnings
|
|
42
45
|
deepsieve chat confirm <action-id> # apply a change that waits for confirmation
|
|
43
46
|
deepsieve chat dismiss <action-id> # decline it; nothing is written
|
|
@@ -198,9 +198,51 @@ def cmd_whoami(args) -> int:
|
|
|
198
198
|
print(f"workspace {me.get('workspace_schema')}")
|
|
199
199
|
print(f"role {me.get('role')}")
|
|
200
200
|
print(f"scopes {', '.join(me.get('scopes') or []) or '(full)'}")
|
|
201
|
+
trial_line = _trial_line(me.get("subscription"), me.get("app_url"))
|
|
202
|
+
if trial_line:
|
|
203
|
+
print(f"trial {trial_line}")
|
|
201
204
|
return EXIT_OK
|
|
202
205
|
|
|
203
206
|
|
|
207
|
+
def _trial_line(subscription: dict | None, app_url: str | None) -> str | None:
|
|
208
|
+
"""The free trial in one line (gh #2896), from the dates the server derived: never
|
|
209
|
+
counted here, so the CLI and the billing page cannot disagree about the day. A cancel is
|
|
210
|
+
read from `cancel_at_period_end`, never inferred from a missing amount."""
|
|
211
|
+
sub = subscription or {}
|
|
212
|
+
trial = sub.get("trial") or {}
|
|
213
|
+
cancelled = bool(sub.get("cancel_at_period_end"))
|
|
214
|
+
|
|
215
|
+
def charge() -> str:
|
|
216
|
+
cents = trial.get("first_charge_cents")
|
|
217
|
+
amount = f"{_usd(cents)} " if cents is not None else ""
|
|
218
|
+
return f"first charge {amount}on {trial.get('first_charge_on')}"
|
|
219
|
+
|
|
220
|
+
if trial.get("conversion_failed"):
|
|
221
|
+
if cancelled:
|
|
222
|
+
return "ended; the first charge was declined and the plan is cancelled, so it will not be retried."
|
|
223
|
+
return "ended, and the first charge was declined. Update the card in the app."
|
|
224
|
+
if trial.get("ended_without_charge"):
|
|
225
|
+
return f"cancelled and ended on {trial.get('ends_on')}; nothing was charged."
|
|
226
|
+
if trial.get("conversion_submitted"):
|
|
227
|
+
return f"ended; {charge()} is being processed and cannot be stopped."
|
|
228
|
+
if trial.get("conversion_pending"):
|
|
229
|
+
return f"ended; {charge()} is due."
|
|
230
|
+
if trial.get("active"):
|
|
231
|
+
left = trial.get("days_left")
|
|
232
|
+
days = f"{left} day{'' if left == 1 else 's'} left" if left is not None else "active"
|
|
233
|
+
if cancelled:
|
|
234
|
+
return f"{days}; cancelled, so it will not be charged (access until {trial.get('ends_on')})"
|
|
235
|
+
if trial.get("first_charge_on"):
|
|
236
|
+
return f"{days}; {charge()}"
|
|
237
|
+
return days
|
|
238
|
+
if trial.get("eligible") is True:
|
|
239
|
+
length = trial.get("offer_days")
|
|
240
|
+
what = f"a {length}-day trial" if length else "a trial"
|
|
241
|
+
where = f"{app_url}/pricing" if app_url else "the pricing page"
|
|
242
|
+
return f"{what} is available. Start one from {where} (it takes a card, so it needs a person)."
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
|
|
204
246
|
def cmd_profiles(args) -> int:
|
|
205
247
|
profiles = config.list_profiles()
|
|
206
248
|
active = config.profile_name(args.profile)
|
|
@@ -262,11 +304,28 @@ def cmd_runs_get(args) -> int:
|
|
|
262
304
|
print(f"blueprint {bp.get('name')} ({bp.get('id')})")
|
|
263
305
|
if body.get("error"):
|
|
264
306
|
print(f"error {body['error'].get('message')}")
|
|
307
|
+
for line in _recheck_lines(body):
|
|
308
|
+
print(f"result {line}")
|
|
265
309
|
if not body.get("done"):
|
|
266
310
|
return EXIT_PENDING
|
|
267
311
|
return EXIT_OK if body.get("status") == "completed" else EXIT_FAIL
|
|
268
312
|
|
|
269
313
|
|
|
314
|
+
def _recheck_lines(body: dict) -> list[str]:
|
|
315
|
+
"""gh #2781 — what a re-check run found (`check_results`, one line per checked value) or what
|
|
316
|
+
a follow-up search found (`update_result`), in the sentences the API wrote for a person."""
|
|
317
|
+
lines = []
|
|
318
|
+
for entry in body.get("check_results") or []:
|
|
319
|
+
line = str(entry.get("message") or "")
|
|
320
|
+
if entry.get("followup_run_id"):
|
|
321
|
+
line += f" (follow-up run {entry['followup_run_id']})"
|
|
322
|
+
lines.append(line)
|
|
323
|
+
update = body.get("update_result") or {}
|
|
324
|
+
if update.get("message"):
|
|
325
|
+
lines.append(str(update["message"]))
|
|
326
|
+
return lines
|
|
327
|
+
|
|
328
|
+
|
|
270
329
|
def _folded_text(folds: list[dict]) -> str:
|
|
271
330
|
"""gh #2716 — the seeds a request counted once, in one line (the first 4, then a count)."""
|
|
272
331
|
if not folds:
|
|
@@ -387,6 +446,8 @@ def _wait(client: Client, run_id: str, args) -> int:
|
|
|
387
446
|
print(f"run {run_id} finished: {body.get('status')}")
|
|
388
447
|
if body.get("error"):
|
|
389
448
|
print(f" {body['error'].get('message')}")
|
|
449
|
+
for line in _recheck_lines(body):
|
|
450
|
+
print(f" {line}")
|
|
390
451
|
return EXIT_OK if body.get("status") == "completed" else EXIT_FAIL
|
|
391
452
|
if not args.json and _tty():
|
|
392
453
|
print(f" status: {body.get('status')}…", end="\r", flush=True)
|
|
@@ -683,6 +744,19 @@ def _refund_line(refund: dict | None) -> str:
|
|
|
683
744
|
# gh #2460: the plan is still running; an earlier billing period's allowance ended.
|
|
684
745
|
return (f"The credits for your {run} went back to the billing period they came from, "
|
|
685
746
|
"which has ended, so they can't be used again.")
|
|
747
|
+
if kind == "partially_refunded_to_ended_plan":
|
|
748
|
+
# gh #2462: only the part that returned nothing gave its credits back.
|
|
749
|
+
return (f"Part of your {run} returned nothing. Your plan has ended, so the credits for "
|
|
750
|
+
"that part were returned to it and can't be used.")
|
|
751
|
+
if kind == "partially_refunded_to_ended_period":
|
|
752
|
+
return (f"Part of your {run} returned nothing. The credits for that part went back to the "
|
|
753
|
+
"billing period they came from, which has ended, so they can't be used again.")
|
|
754
|
+
if kind == "refunded_to_plan":
|
|
755
|
+
# gh #2302: whether the credits can be spent cannot be told: never "back", never "ended".
|
|
756
|
+
return f"The credits for your {run} were returned to the plan they came from."
|
|
757
|
+
if kind == "partially_refunded_to_plan":
|
|
758
|
+
return (f"Part of your {run} returned nothing. The credits for that part were returned to "
|
|
759
|
+
"the plan they came from.")
|
|
686
760
|
if kind == "refunded_unknown":
|
|
687
761
|
return f"Your {run} was refunded in full."
|
|
688
762
|
if kind == "refund_pending":
|
|
@@ -713,9 +787,24 @@ def _retry_cost(details: dict) -> str:
|
|
|
713
787
|
return cost
|
|
714
788
|
|
|
715
789
|
|
|
716
|
-
def _retry_quote_text(details: dict, moved: bool) -> str:
|
|
790
|
+
def _retry_quote_text(details: dict, moved: bool, sent: dict | None = None) -> str:
|
|
791
|
+
# gh #2456 (review, seat-contract 2a): say WHAT moved. `sent` is the confirmation just
|
|
792
|
+
# refused; a rail that moved at the same total is not a price change.
|
|
793
|
+
sent = sent or {}
|
|
794
|
+
rail_moved = bool(sent.get("expected_funding")) and sent.get("expected_funding") != (
|
|
795
|
+
details.get("funding") or {}
|
|
796
|
+
).get("covered_by")
|
|
797
|
+
total_moved = sent.get("expected_total_cents") != details.get("total_price_cents")
|
|
798
|
+
if not moved:
|
|
799
|
+
what = ""
|
|
800
|
+
elif rail_moved and not total_moved:
|
|
801
|
+
what = "What would pay for this retry changed since the quote; the total did not."
|
|
802
|
+
elif rail_moved:
|
|
803
|
+
what = "The price, and what would pay for this retry, changed since the quote."
|
|
804
|
+
else:
|
|
805
|
+
what = "The price changed since the quote."
|
|
717
806
|
parts = [
|
|
718
|
-
|
|
807
|
+
what,
|
|
719
808
|
_refund_line(details.get("refund")),
|
|
720
809
|
f"Retrying starts a new run and {_retry_cost(details)}.",
|
|
721
810
|
]
|
|
@@ -737,15 +826,25 @@ def cmd_runs_retry(args) -> int:
|
|
|
737
826
|
(`retry_confirmation_required`) until it is sent with the total it was quoted: here, an
|
|
738
827
|
explicit question showing the refund and the total, or --expected-total CENTS. In a pipe it
|
|
739
828
|
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.
|
|
829
|
+
with the new total; nothing is charged in between.
|
|
830
|
+
|
|
831
|
+
gh #2456: the yes is bound to what PAYS as well as the total. The interactive confirmation
|
|
832
|
+
sends the quote's `funding.covered_by` as `expected_funding`, so a retry agreed as "uses 1
|
|
833
|
+
plan run credit" is never paid from the balance instead; --expected-funding does the same
|
|
834
|
+
in a script."""
|
|
741
835
|
rid = _uuid_arg(args.run_id, "run_id")
|
|
742
836
|
if rid is None:
|
|
743
837
|
return EXIT_USAGE
|
|
838
|
+
if args.expected_funding is not None and args.expected_total is None:
|
|
839
|
+
err("--expected-funding binds the rail of a quoted total: pass --expected-total too")
|
|
840
|
+
return EXIT_USAGE
|
|
744
841
|
client, _ = _require_auth(args)
|
|
745
842
|
path = f"/v1/research/runs/{rid}/retry"
|
|
746
843
|
payload: dict = {}
|
|
747
844
|
if args.expected_total is not None:
|
|
748
845
|
payload["expected_total_cents"] = args.expected_total
|
|
846
|
+
if args.expected_funding is not None:
|
|
847
|
+
payload["expected_funding"] = args.expected_funding
|
|
749
848
|
# At most two refusals: the quote, and one re-quote if the price moved while the person was
|
|
750
849
|
# deciding. A third would be the API contradicting itself; stop rather than loop, and never ask
|
|
751
850
|
# a question whose yes would not be sent (seat-2389 L1).
|
|
@@ -764,11 +863,15 @@ def cmd_runs_retry(args) -> int:
|
|
|
764
863
|
# No quote to state: never prompt "$0.00" (seat-2389 L2).
|
|
765
864
|
err(f"{exc} (the API sent no total to confirm; nothing was charged)")
|
|
766
865
|
return EXIT_FAIL
|
|
767
|
-
ask = _retry_quote_text(
|
|
866
|
+
ask = _retry_quote_text(
|
|
867
|
+
exc.details, moved=exc.code == "retry_price_changed", sent=payload
|
|
868
|
+
)
|
|
869
|
+
rail = (exc.details.get("funding") or {}).get("covered_by")
|
|
870
|
+
flags = f"--expected-total {total}" + (f" --expected-funding {rail}" if rail else "")
|
|
768
871
|
if not _interactive():
|
|
769
872
|
if args.json:
|
|
770
873
|
print(json.dumps({"error": {"code": exc.code, "message": ask, "details": exc.details}}))
|
|
771
|
-
err(f"{ask} Pass
|
|
874
|
+
err(f"{ask} Pass {flags} to confirm that charge.")
|
|
772
875
|
return EXIT_USAGE
|
|
773
876
|
if attempt == attempts - 1:
|
|
774
877
|
break # the last refusal: no request would follow, so ask nothing
|
|
@@ -776,6 +879,8 @@ def cmd_runs_retry(args) -> int:
|
|
|
776
879
|
print("Cancelled. Nothing was charged.")
|
|
777
880
|
return EXIT_OK
|
|
778
881
|
payload = {"expected_total_cents": total}
|
|
882
|
+
if rail:
|
|
883
|
+
payload["expected_funding"] = rail # gh #2456: the yes covers what pays too
|
|
779
884
|
if body is None:
|
|
780
885
|
err("the API kept refusing the confirmation; nothing was charged")
|
|
781
886
|
return EXIT_FAIL
|
|
@@ -1222,6 +1327,68 @@ def cmd_links_remove(args) -> int:
|
|
|
1222
1327
|
return EXIT_OK
|
|
1223
1328
|
|
|
1224
1329
|
|
|
1330
|
+
def _subscription_summary(sub: dict, app_url: str | None) -> str:
|
|
1331
|
+
"""The subscription after a cancel or resume, from the server's own fields (gh #2903)."""
|
|
1332
|
+
lines = [f"status {sub.get('status')}"]
|
|
1333
|
+
if sub.get("cancel_at_period_end"):
|
|
1334
|
+
trial_ = sub.get("trial") or {}
|
|
1335
|
+
tail = (
|
|
1336
|
+
"the first charge already in flight still goes ahead"
|
|
1337
|
+
if trial_.get("conversion_submitted")
|
|
1338
|
+
else "nothing new is charged"
|
|
1339
|
+
)
|
|
1340
|
+
lines.append(f"cancelled access until {sub.get('access_through')}; {tail}")
|
|
1341
|
+
elif sub.get("access_through"):
|
|
1342
|
+
lines.append(f"renews {sub.get('access_through')}")
|
|
1343
|
+
trial_line = _trial_line(sub, app_url)
|
|
1344
|
+
if trial_line:
|
|
1345
|
+
lines.append(f"trial {trial_line}")
|
|
1346
|
+
if sub.get("retry"):
|
|
1347
|
+
lines.append(f"retry {sub['retry']}")
|
|
1348
|
+
return "\n".join(lines)
|
|
1349
|
+
|
|
1350
|
+
|
|
1351
|
+
def _subscription_change(args, verb: str, prompt: str) -> int:
|
|
1352
|
+
client, _ = _require_auth(args)
|
|
1353
|
+
if not args.yes:
|
|
1354
|
+
if not _interactive():
|
|
1355
|
+
err(f"refusing to {verb} the subscription non-interactively; pass --yes")
|
|
1356
|
+
return EXIT_USAGE
|
|
1357
|
+
if input(prompt).strip().lower() not in ("y", "yes"):
|
|
1358
|
+
print("Nothing changed.")
|
|
1359
|
+
return EXIT_OK
|
|
1360
|
+
headers = {"Idempotency-Key": args.idempotency_key} if args.idempotency_key else None
|
|
1361
|
+
try:
|
|
1362
|
+
sub_ = client.request("POST", f"/v1/subscription/{verb}", headers=headers)
|
|
1363
|
+
except ApiError as exc:
|
|
1364
|
+
err(str(exc))
|
|
1365
|
+
return _api_exit(exc)
|
|
1366
|
+
out(sub_, args.json, _subscription_summary(sub_, None))
|
|
1367
|
+
return EXIT_OK
|
|
1368
|
+
|
|
1369
|
+
|
|
1370
|
+
def cmd_subscription_cancel(args) -> int:
|
|
1371
|
+
"""Cancel the subscription at the end of the period (gh #2903). Confirmed interactively,
|
|
1372
|
+
or with --yes, never silently in a pipe. Undone by `deepsieve subscription resume`."""
|
|
1373
|
+
return _subscription_change(
|
|
1374
|
+
args,
|
|
1375
|
+
"cancel",
|
|
1376
|
+
"Cancel the subscription at the end of the period? Nothing new is charged after it (a "
|
|
1377
|
+
"first charge already in flight still goes ahead). [y/N] ",
|
|
1378
|
+
)
|
|
1379
|
+
|
|
1380
|
+
|
|
1381
|
+
def cmd_subscription_resume(args) -> int:
|
|
1382
|
+
"""Undo a cancellation while the period runs (gh #2903). A past-due account gets one
|
|
1383
|
+
immediate charge attempt, so this is confirmed like a cancel."""
|
|
1384
|
+
return _subscription_change(
|
|
1385
|
+
args,
|
|
1386
|
+
"resume",
|
|
1387
|
+
"Resume the subscription? Its renewal is charged as before, and a past-due account is "
|
|
1388
|
+
"charged once now. [y/N] ",
|
|
1389
|
+
)
|
|
1390
|
+
|
|
1391
|
+
|
|
1225
1392
|
def _blueprint_id(client, args) -> str | None:
|
|
1226
1393
|
"""The Blueprint these commands address: ``--blueprint``, else the active one.
|
|
1227
1394
|
|
|
@@ -1304,6 +1471,148 @@ def cmd_data_get(args) -> int:
|
|
|
1304
1471
|
return EXIT_OK
|
|
1305
1472
|
|
|
1306
1473
|
|
|
1474
|
+
#: Width a value may take in a history row. The tag after it ("QA corrected") is never cut.
|
|
1475
|
+
_HISTORY_VALUE_WIDTH = 30
|
|
1476
|
+
|
|
1477
|
+
#: An entity or column key. Both go into the URL path; anything else is refused here.
|
|
1478
|
+
_KEY = re.compile(r"[A-Za-z0-9_-]{1,128}")
|
|
1479
|
+
|
|
1480
|
+
|
|
1481
|
+
def _hv(value: Any) -> str:
|
|
1482
|
+
text = "(empty)" if value is None else str(value)
|
|
1483
|
+
w = _HISTORY_VALUE_WIDTH
|
|
1484
|
+
return text if len(text) <= w else text[: w - 1] + "…"
|
|
1485
|
+
|
|
1486
|
+
|
|
1487
|
+
def _describe_history(e: dict) -> str:
|
|
1488
|
+
"""One change log entry in the source popover's words (`LineageBlock`, #2129).
|
|
1489
|
+
|
|
1490
|
+
A `change` this CLI does not know prints the bare value, as `unknown` does, with no tag: the
|
|
1491
|
+
#2129 changelog entry tells clients to read an unrecognised `change` as `unknown`."""
|
|
1492
|
+
kind, value = e.get("change"), _hv(e.get("value"))
|
|
1493
|
+
if kind == "changed":
|
|
1494
|
+
return f"{_hv(e.get('previous_value'))} → {value}"
|
|
1495
|
+
if kind == "amended":
|
|
1496
|
+
pre = e.get("pre_amend")
|
|
1497
|
+
if pre == "recorded":
|
|
1498
|
+
return f"{_hv(e.get('pre_amend_value'))} → {value} QA corrected"
|
|
1499
|
+
if pre == "not_recorded":
|
|
1500
|
+
return f"{value} QA corrected · earlier value not recorded"
|
|
1501
|
+
# A missing or unknown `pre_amend` reads as `none`: say only what is known.
|
|
1502
|
+
return f"{value} QA corrected"
|
|
1503
|
+
if kind == "unchanged":
|
|
1504
|
+
return "re-verified"
|
|
1505
|
+
if kind == "observed":
|
|
1506
|
+
return f"seen, not applied: {value}"
|
|
1507
|
+
if kind == "not_applied":
|
|
1508
|
+
return f"found, not applied: {value}"
|
|
1509
|
+
return value
|
|
1510
|
+
|
|
1511
|
+
|
|
1512
|
+
def _history_when(captured_at: Any) -> str:
|
|
1513
|
+
"""`captured_at` in UTC, to the second, saying so (a bare slice dropped the offset)."""
|
|
1514
|
+
from datetime import datetime, timezone # noqa: PLC0415
|
|
1515
|
+
|
|
1516
|
+
if not captured_at:
|
|
1517
|
+
return "date unknown"
|
|
1518
|
+
try:
|
|
1519
|
+
at = datetime.fromisoformat(str(captured_at))
|
|
1520
|
+
except ValueError:
|
|
1521
|
+
return str(captured_at)
|
|
1522
|
+
if at.tzinfo is None:
|
|
1523
|
+
return str(captured_at)
|
|
1524
|
+
return at.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
1525
|
+
|
|
1526
|
+
|
|
1527
|
+
def cmd_data_history(args) -> int:
|
|
1528
|
+
"""A cell's change history (#2960): a column's log across records, one record's cell
|
|
1529
|
+
(`--record`), or one cell by its id (`--cell`: a `--json` entry's `cell_id`, or `holds list`)."""
|
|
1530
|
+
by_cell = args.cell is not None
|
|
1531
|
+
if by_cell and (args.entity or args.column):
|
|
1532
|
+
err("give either <entity> <column>, or --cell <cell id>, not both")
|
|
1533
|
+
return EXIT_USAGE
|
|
1534
|
+
if not by_cell and not (args.entity and args.column):
|
|
1535
|
+
err("give <entity> <column> (see `deepsieve data catalog`), or --cell <cell id>")
|
|
1536
|
+
return EXIT_USAGE
|
|
1537
|
+
if by_cell and (args.record is not None or args.blueprint is not None):
|
|
1538
|
+
err("--record and --blueprint go with <entity> <column>; a cell id names its own")
|
|
1539
|
+
return EXIT_USAGE
|
|
1540
|
+
if args.rechecks and not by_cell:
|
|
1541
|
+
err("--rechecks needs --cell (re-checks are listed per cell)")
|
|
1542
|
+
return EXIT_USAGE
|
|
1543
|
+
if by_cell and not _CELL_ID.fullmatch(args.cell):
|
|
1544
|
+
# The id goes into the URL path; anything else is refused before a request.
|
|
1545
|
+
err("cell id must be a `cell_id` from `--json` output or `deepsieve holds list` (cel_…)")
|
|
1546
|
+
return EXIT_USAGE
|
|
1547
|
+
if not by_cell and not (_KEY.fullmatch(args.entity) and _KEY.fullmatch(args.column)):
|
|
1548
|
+
err("entity and column must be keys from `deepsieve data catalog`")
|
|
1549
|
+
return EXIT_USAGE
|
|
1550
|
+
if args.blueprint is not None:
|
|
1551
|
+
# It goes into the URL path too; a mangled one read another endpoint (review, LOW-2).
|
|
1552
|
+
try:
|
|
1553
|
+
args.blueprint = str(uuid.UUID(args.blueprint))
|
|
1554
|
+
except ValueError:
|
|
1555
|
+
err("--blueprint must be a Blueprint id (a UUID)")
|
|
1556
|
+
return EXIT_USAGE
|
|
1557
|
+
params: dict[str, Any] = {"limit": args.limit}
|
|
1558
|
+
if args.record is not None:
|
|
1559
|
+
try:
|
|
1560
|
+
params["record_id"] = str(uuid.UUID(args.record))
|
|
1561
|
+
except ValueError:
|
|
1562
|
+
err("--record must be a record id (a UUID, from `deepsieve data get`)")
|
|
1563
|
+
return EXIT_USAGE
|
|
1564
|
+
for flag in ("cursor", "since"):
|
|
1565
|
+
if getattr(args, flag):
|
|
1566
|
+
params[flag] = getattr(args, flag)
|
|
1567
|
+
if args.rechecks:
|
|
1568
|
+
params["include"] = "rechecks"
|
|
1569
|
+
client, _ = _require_auth(args)
|
|
1570
|
+
if by_cell:
|
|
1571
|
+
path = f"/v1/cells/{args.cell}/changes"
|
|
1572
|
+
else:
|
|
1573
|
+
bp_id = _blueprint_id(client, args)
|
|
1574
|
+
if bp_id is None:
|
|
1575
|
+
return EXIT_FAIL
|
|
1576
|
+
path = f"/v1/blueprints/{bp_id}/entities/{args.entity}/columns/{args.column}/changes"
|
|
1577
|
+
try:
|
|
1578
|
+
body = client.request("GET", path, params=params)
|
|
1579
|
+
except ApiError as exc:
|
|
1580
|
+
err(str(exc))
|
|
1581
|
+
return _api_exit(exc)
|
|
1582
|
+
if not isinstance(body, dict) or not isinstance(body.get("data"), list):
|
|
1583
|
+
# "Found nothing" must never read like "could not look": a 200 that is not a change log
|
|
1584
|
+
# (an HTML page from the wrong origin) is a failure, not an empty history.
|
|
1585
|
+
err(f"unexpected response from {path}: not a change log (is the profile's origin the API?)")
|
|
1586
|
+
return EXIT_FAIL
|
|
1587
|
+
if args.json:
|
|
1588
|
+
out(body, True)
|
|
1589
|
+
return EXIT_OK
|
|
1590
|
+
bp = body.get("blueprint") or {}
|
|
1591
|
+
if bp:
|
|
1592
|
+
print(f"Blueprint: {bp.get('name')} ({bp.get('id')})")
|
|
1593
|
+
cell = body.get("cell") or {}
|
|
1594
|
+
if cell:
|
|
1595
|
+
print(f"Cell: {cell.get('record_name') or cell.get('record_id')}.{cell.get('column')}")
|
|
1596
|
+
rows = []
|
|
1597
|
+
for e in body["data"]:
|
|
1598
|
+
row = [_history_when(e.get("captured_at"))]
|
|
1599
|
+
if not by_cell:
|
|
1600
|
+
row.append(str(e.get("record_name") or e.get("record_id") or "")[:30])
|
|
1601
|
+
row += [_describe_history(e), e.get("run_id") or "-"]
|
|
1602
|
+
rows.append(row)
|
|
1603
|
+
headers = ["when (utc)", "change", "run"]
|
|
1604
|
+
if not by_cell:
|
|
1605
|
+
headers.insert(1, "record")
|
|
1606
|
+
print(_table(rows, headers) if rows else "No history found.")
|
|
1607
|
+
if body.get("has_more") and body.get("next_cursor"):
|
|
1608
|
+
print(f"\nMore: --cursor {body.get('next_cursor')}")
|
|
1609
|
+
if body.get("truncated"):
|
|
1610
|
+
# A clipped log must never read as a complete one (the same rule as `data get`).
|
|
1611
|
+
reason = body.get("truncated_reason") or "this is a preview, not the full log."
|
|
1612
|
+
print(f"\nnote: {reason}", file=sys.stderr)
|
|
1613
|
+
return EXIT_OK
|
|
1614
|
+
|
|
1615
|
+
|
|
1307
1616
|
def _remote_server_name(prof) -> str:
|
|
1308
1617
|
"""The MCP server name this deployment tells agents to register.
|
|
1309
1618
|
|
|
@@ -1571,6 +1880,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1571
1880
|
metavar="CENTS",
|
|
1572
1881
|
help="confirm a charging retry at the total you were quoted, in cents",
|
|
1573
1882
|
)
|
|
1883
|
+
rr.add_argument(
|
|
1884
|
+
"--expected-funding",
|
|
1885
|
+
choices=["free_run", "plan_credits", "balance"],
|
|
1886
|
+
default=None,
|
|
1887
|
+
help="also bind what pays, as quoted (funding.covered_by); needs --expected-total",
|
|
1888
|
+
)
|
|
1574
1889
|
rr.set_defaults(func=cmd_runs_retry)
|
|
1575
1890
|
|
|
1576
1891
|
rm = runs.add_parser(
|
|
@@ -1672,6 +1987,16 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1672
1987
|
lr.add_argument("--idempotency-key", help="make a retry safe")
|
|
1673
1988
|
lr.set_defaults(func=cmd_links_remove)
|
|
1674
1989
|
|
|
1990
|
+
subs = sub.add_parser("subscription", help="cancel or resume the subscription", parents=[common]).add_subparsers(dest="sub", required=True)
|
|
1991
|
+
sc = subs.add_parser("cancel", help="cancel at the end of the period; nothing new is charged (needs billing:cancel)", parents=[common])
|
|
1992
|
+
sc.add_argument("--yes", action="store_true", help="do not ask for confirmation")
|
|
1993
|
+
sc.add_argument("--idempotency-key", help="make a retry safe")
|
|
1994
|
+
sc.set_defaults(func=cmd_subscription_cancel)
|
|
1995
|
+
sr = subs.add_parser("resume", help="undo a cancellation while the period runs (needs billing:write)", parents=[common])
|
|
1996
|
+
sr.add_argument("--yes", action="store_true", help="do not ask for confirmation")
|
|
1997
|
+
sr.add_argument("--idempotency-key", help="make a retry safe")
|
|
1998
|
+
sr.set_defaults(func=cmd_subscription_resume)
|
|
1999
|
+
|
|
1675
2000
|
holds = sub.add_parser("holds", help="cells you set on purpose, and newer research", parents=[common]).add_subparsers(dest="sub", required=True)
|
|
1676
2001
|
hl = holds.add_parser("list", help="newest first; --pending for those with newer research", parents=[common])
|
|
1677
2002
|
hl.add_argument("--entity", help="only this entity's cells (an entity key)")
|
|
@@ -1735,6 +2060,23 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1735
2060
|
dg.add_argument("--blueprint", help="Blueprint id (default: the active one)")
|
|
1736
2061
|
dg.set_defaults(func=cmd_data_get)
|
|
1737
2062
|
|
|
2063
|
+
dh = data.add_parser(
|
|
2064
|
+
"history",
|
|
2065
|
+
help="a cell's change history: <entity> <column> [--record ID], or --cell ID",
|
|
2066
|
+
parents=[common],
|
|
2067
|
+
)
|
|
2068
|
+
dh.add_argument("entity", nargs="?", help="entity key (see `deepsieve data catalog`)")
|
|
2069
|
+
dh.add_argument("column", nargs="?", help="column key")
|
|
2070
|
+
dh.add_argument("--record", help="only this record's cell (a record id)")
|
|
2071
|
+
dh.add_argument("--cell", help="one cell, by its cell_id (in --json output, or from `holds list`)")
|
|
2072
|
+
dh.add_argument("--rechecks", action="store_true",
|
|
2073
|
+
help="with --cell: also re-checks that found a change and did not apply it")
|
|
2074
|
+
dh.add_argument("--since", help="only changes after this ISO timestamp")
|
|
2075
|
+
dh.add_argument("--limit", type=int, default=25)
|
|
2076
|
+
dh.add_argument("--cursor")
|
|
2077
|
+
dh.add_argument("--blueprint", help="Blueprint id (default: the active one)")
|
|
2078
|
+
dh.set_defaults(func=cmd_data_history)
|
|
2079
|
+
|
|
1738
2080
|
setup = sub.add_parser("setup", help="connect other tools", parents=[common]).add_subparsers(
|
|
1739
2081
|
dest="sub", required=True
|
|
1740
2082
|
)
|
|
@@ -820,6 +820,61 @@ def test_runs_get_and_whoami_name_the_blueprint(monkeypatch, capsys):
|
|
|
820
820
|
assert "blueprint Widgets (bp-a)" in printed and "workspace ws_x" in printed
|
|
821
821
|
|
|
822
822
|
|
|
823
|
+
# ── a re-check's result (#2781) ─────────────────────────────────────────────
|
|
824
|
+
|
|
825
|
+
_RECHECK = {
|
|
826
|
+
"id": "rc_1", "status": "completed", "done": True, "run_type": "reverify",
|
|
827
|
+
"check_results": [
|
|
828
|
+
{"evidence_id": "e1", "outcome": "confirmed",
|
|
829
|
+
"message": "Checked: the value is still correct.", "followup_run_id": None},
|
|
830
|
+
{"evidence_id": "e2", "outcome": "looking",
|
|
831
|
+
"message": "Checked: the value looks out of date. Looking for the current one.",
|
|
832
|
+
"followup_run_id": "fu_1"},
|
|
833
|
+
],
|
|
834
|
+
"update_result": None,
|
|
835
|
+
}
|
|
836
|
+
_FOLLOWUP = {
|
|
837
|
+
"id": "fu_1", "status": "completed", "done": True, "run_type": "research_update",
|
|
838
|
+
"check_results": None,
|
|
839
|
+
"update_result": {"outcome": "updated",
|
|
840
|
+
"message": "Found the current value and updated the cell."},
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
@pytest.mark.parametrize("command", [["runs", "get"], ["runs", "wait", "--interval", "0"]],
|
|
845
|
+
ids=["get", "wait"])
|
|
846
|
+
def test_a_recheck_says_what_it_found_and_names_the_followup(monkeypatch, capsys, command):
|
|
847
|
+
"""The human view printed only id/status/error, so a person could not see what a re-check
|
|
848
|
+
found without --json. The follow-up id it prints is the one the next command takes."""
|
|
849
|
+
cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
|
|
850
|
+
runs = {"rc_1": _RECHECK, "fu_1": _FOLLOWUP}
|
|
851
|
+
|
|
852
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
853
|
+
return httpx.Response(200, json=runs[request.url.path.rsplit("/", 1)[-1]])
|
|
854
|
+
|
|
855
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
856
|
+
assert main([*command, "rc_1"]) == 0
|
|
857
|
+
out = capsys.readouterr().out
|
|
858
|
+
print(out)
|
|
859
|
+
assert "Checked: the value is still correct." in out
|
|
860
|
+
assert "Looking for the current one. (follow-up run fu_1)" in out
|
|
861
|
+
assert out.count("follow-up run") == 1, "only the entry that started a search names one"
|
|
862
|
+
|
|
863
|
+
assert main([*command, "fu_1"]) == 0
|
|
864
|
+
out = capsys.readouterr().out
|
|
865
|
+
print(out)
|
|
866
|
+
assert "Found the current value and updated the cell." in out
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def test_a_run_with_no_recheck_result_prints_no_extra_lines(monkeypatch, capsys):
|
|
870
|
+
cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
|
|
871
|
+
body = {"id": "r", "status": "completed", "done": True,
|
|
872
|
+
"check_results": None, "update_result": None}
|
|
873
|
+
monkeypatch.setattr(httpx, "Client", _mock(lambda r: httpx.Response(200, json=body)))
|
|
874
|
+
assert main(["runs", "get", "r"]) == 0
|
|
875
|
+
assert "result" not in capsys.readouterr().out
|
|
876
|
+
|
|
877
|
+
|
|
823
878
|
# ── chat changes (#2187) ────────────────────────────────────────────────────
|
|
824
879
|
|
|
825
880
|
|
|
@@ -1621,9 +1676,20 @@ def test_a_quote_without_a_total_is_never_asked_as_zero(monkeypatch, capsys):
|
|
|
1621
1676
|
"Your plan has ended, so the credits for your stopped run were returned to it"),
|
|
1622
1677
|
({"kind": "refunded_to_ended_period", "run_status": "failed"},
|
|
1623
1678
|
"The credits for your failed run went back to the billing period they came from"),
|
|
1679
|
+
# gh #2462: a PARTIAL refund to an ended plan or period is said of the part.
|
|
1680
|
+
({"kind": "partially_refunded_to_ended_plan", "run_status": "failed"},
|
|
1681
|
+
"Part of your failed run returned nothing. Your plan has ended, so the credits for that part"),
|
|
1682
|
+
({"kind": "partially_refunded_to_ended_period", "run_status": "failed"},
|
|
1683
|
+
"Part of your failed run returned nothing. The credits for that part went back"),
|
|
1684
|
+
# gh #2302: whether the credits can be spent cannot be told.
|
|
1685
|
+
({"kind": "refunded_to_plan", "run_status": "failed"},
|
|
1686
|
+
"The credits for your failed run were returned to the plan they came from."),
|
|
1687
|
+
({"kind": "partially_refunded_to_plan", "run_status": "failed"},
|
|
1688
|
+
"Part of your failed run returned nothing. The credits for that part were returned"),
|
|
1624
1689
|
({"kind": "unconfirmed"}, "We can't confirm yet whether your earlier run was refunded."),
|
|
1625
1690
|
],
|
|
1626
|
-
ids=["kept-credits", "pending", "ended-plan", "ended-period", "
|
|
1691
|
+
ids=["kept-credits", "pending", "ended-plan", "ended-period", "partial-ended-plan",
|
|
1692
|
+
"partial-ended-period", "returned-to-plan", "partial-returned-to-plan", "unconfirmed"],
|
|
1627
1693
|
)
|
|
1628
1694
|
def test_every_refund_kind_reads_in_words_never_its_code(monkeypatch, capsys, refund, words):
|
|
1629
1695
|
"""seat-2389 L3: the app's sentences (site `refundHeadline`), never a raw enum name."""
|
|
@@ -1970,3 +2036,425 @@ def test_run_create_says_which_seeds_were_counted_once(monkeypatch, capsys):
|
|
|
1970
2036
|
reply.pop("folded_duplicates")
|
|
1971
2037
|
assert main(["runs", "create", "--seeds", "Fund X", "--dry-run"]) == 0
|
|
1972
2038
|
assert "counted once" not in capsys.readouterr().out
|
|
2039
|
+
|
|
2040
|
+
|
|
2041
|
+
# ── the free trial on whoami (gh #2896) ─────────────────────────────────────
|
|
2042
|
+
|
|
2043
|
+
|
|
2044
|
+
@pytest.mark.parametrize(
|
|
2045
|
+
"subscription, line",
|
|
2046
|
+
[
|
|
2047
|
+
({"cancel_at_period_end": False,
|
|
2048
|
+
"trial": {"active": True, "days_left": 12, "first_charge_on": "2026-11-13",
|
|
2049
|
+
"first_charge_cents": 9900, "ends_on": "2026-11-13"}},
|
|
2050
|
+
"trial 12 days left; first charge $99.00 on 2026-11-13"),
|
|
2051
|
+
({"cancel_at_period_end": True,
|
|
2052
|
+
"trial": {"active": True, "days_left": 1, "first_charge_on": None,
|
|
2053
|
+
"first_charge_cents": None, "ends_on": "2026-11-13"}},
|
|
2054
|
+
"trial 1 day left; cancelled, so it will not be charged (access until 2026-11-13)"),
|
|
2055
|
+
# Not cancelled, amount unknown (a tier with no self-serve price): never "cancelled".
|
|
2056
|
+
({"cancel_at_period_end": False,
|
|
2057
|
+
"trial": {"active": True, "days_left": 10, "first_charge_on": "2026-11-13",
|
|
2058
|
+
"first_charge_cents": None, "ends_on": "2026-11-13"}},
|
|
2059
|
+
"trial 10 days left; first charge on 2026-11-13"),
|
|
2060
|
+
({"trial": {"conversion_pending": True, "first_charge_on": "2026-11-13",
|
|
2061
|
+
"first_charge_cents": 9900}},
|
|
2062
|
+
"trial ended; first charge $99.00 on 2026-11-13 is due."),
|
|
2063
|
+
({"cancel_at_period_end": True,
|
|
2064
|
+
"trial": {"conversion_submitted": True, "first_charge_on": "2026-11-13",
|
|
2065
|
+
"first_charge_cents": 9900}},
|
|
2066
|
+
"trial ended; first charge $99.00 on 2026-11-13 is being processed and cannot "
|
|
2067
|
+
"be stopped."),
|
|
2068
|
+
({"trial": {"active": False, "conversion_failed": True}},
|
|
2069
|
+
"trial ended, and the first charge was declined. Update the card in the app."),
|
|
2070
|
+
({"cancel_at_period_end": True, "trial": {"active": False, "conversion_failed": True}},
|
|
2071
|
+
"trial ended; the first charge was declined and the plan is cancelled, so it "
|
|
2072
|
+
"will not be retried."),
|
|
2073
|
+
({"cancel_at_period_end": True,
|
|
2074
|
+
"trial": {"active": False, "ended_without_charge": True, "ends_on": "2026-11-13"}},
|
|
2075
|
+
"trial cancelled and ended on 2026-11-13; nothing was charged."),
|
|
2076
|
+
({"trial": {"active": False, "eligible": True, "offer_days": 30}},
|
|
2077
|
+
"trial a 30-day trial is available. Start one from https://app.x.test/pricing "
|
|
2078
|
+
"(it takes a card, so it needs a person)."),
|
|
2079
|
+
],
|
|
2080
|
+
ids=["charging", "cancelled", "amount-unknown", "pending", "submitted", "declined",
|
|
2081
|
+
"declined-then-cancelled", "ended-without-charge", "eligible"],
|
|
2082
|
+
)
|
|
2083
|
+
def test_whoami_states_the_trial_from_the_servers_dates(monkeypatch, capsys, subscription, line):
|
|
2084
|
+
cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
|
|
2085
|
+
seen = []
|
|
2086
|
+
|
|
2087
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
2088
|
+
seen.append(request.url.path)
|
|
2089
|
+
return httpx.Response(200, json={
|
|
2090
|
+
"org_id": "o", "role": "owner", "app_url": "https://app.x.test",
|
|
2091
|
+
"subscription": {"status": "trialing", **subscription},
|
|
2092
|
+
})
|
|
2093
|
+
|
|
2094
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
2095
|
+
assert main(["whoami"]) == 0
|
|
2096
|
+
assert seen == ["/v1/me"]
|
|
2097
|
+
assert line in capsys.readouterr().out.splitlines()
|
|
2098
|
+
|
|
2099
|
+
|
|
2100
|
+
@pytest.mark.parametrize(
|
|
2101
|
+
"subscription",
|
|
2102
|
+
[None, {"status": "active", "trial": None},
|
|
2103
|
+
{"status": "none", "trial": {"active": False, "eligible": None}}],
|
|
2104
|
+
ids=["unknown", "no-trial-block", "eligibility-unknown"],
|
|
2105
|
+
)
|
|
2106
|
+
def test_whoami_prints_no_trial_line_when_there_is_nothing_to_say(
|
|
2107
|
+
monkeypatch, capsys, subscription
|
|
2108
|
+
):
|
|
2109
|
+
cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
|
|
2110
|
+
monkeypatch.setattr(httpx, "Client", _mock(lambda r: httpx.Response(
|
|
2111
|
+
200, json={"org_id": "o", "role": "owner", "subscription": subscription})))
|
|
2112
|
+
assert main(["whoami"]) == 0
|
|
2113
|
+
out = capsys.readouterr().out
|
|
2114
|
+
assert "role owner" in out, "control: whoami printed its identity"
|
|
2115
|
+
assert "trial" not in out
|
|
2116
|
+
|
|
2117
|
+
|
|
2118
|
+
# ── subscription cancel / resume (gh #2903) ─────────────────────────────────
|
|
2119
|
+
|
|
2120
|
+
|
|
2121
|
+
def test_subscription_cancel_refuses_in_a_pipe_without_yes(monkeypatch, capsys):
|
|
2122
|
+
cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
|
|
2123
|
+
seen = []
|
|
2124
|
+
monkeypatch.setattr(httpx, "Client", _mock(lambda r: seen.append(r) or httpx.Response(200)))
|
|
2125
|
+
assert main(["subscription", "cancel"]) == 2
|
|
2126
|
+
assert seen == [], "nothing reached the API"
|
|
2127
|
+
assert "pass --yes" in capsys.readouterr().err
|
|
2128
|
+
|
|
2129
|
+
|
|
2130
|
+
@pytest.mark.parametrize("verb", ["cancel", "resume"])
|
|
2131
|
+
def test_subscription_change_posts_and_prints_what_the_server_says(monkeypatch, capsys, verb):
|
|
2132
|
+
cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
|
|
2133
|
+
seen = []
|
|
2134
|
+
|
|
2135
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
2136
|
+
seen.append((request.method, request.url.path, request.headers.get("Idempotency-Key")))
|
|
2137
|
+
return httpx.Response(200, json={
|
|
2138
|
+
"object": "subscription", "status": "trialing",
|
|
2139
|
+
"cancel_at_period_end": verb == "cancel", "access_through": "2026-11-13",
|
|
2140
|
+
"retry": None,
|
|
2141
|
+
"trial": {"active": True, "days_left": 12, "ends_on": "2026-11-13",
|
|
2142
|
+
"first_charge_on": None if verb == "cancel" else "2026-11-13",
|
|
2143
|
+
"first_charge_cents": None if verb == "cancel" else 9900},
|
|
2144
|
+
})
|
|
2145
|
+
|
|
2146
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
2147
|
+
assert main(["subscription", verb, "--yes", "--idempotency-key", "k-1"]) == 0
|
|
2148
|
+
assert seen == [("POST", f"/v1/subscription/{verb}", "k-1")]
|
|
2149
|
+
printed = capsys.readouterr().out.splitlines()
|
|
2150
|
+
if verb == "cancel":
|
|
2151
|
+
assert "cancelled access until 2026-11-13; nothing new is charged" in printed
|
|
2152
|
+
else:
|
|
2153
|
+
assert "trial 12 days left; first charge $99.00 on 2026-11-13" in printed
|
|
2154
|
+
|
|
2155
|
+
|
|
2156
|
+
def test_subscription_resume_after_the_end_exits_non_zero_with_the_servers_words(
|
|
2157
|
+
monkeypatch, capsys
|
|
2158
|
+
):
|
|
2159
|
+
cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
|
|
2160
|
+
monkeypatch.setattr(httpx, "Client", _mock(lambda r: httpx.Response(409, json={
|
|
2161
|
+
"error": {"type": "conflict_error", "code": "subscription_ended",
|
|
2162
|
+
"message": "This subscription has ended, so it can't be resumed.",
|
|
2163
|
+
"retriable": False, "request_id": "r"}})))
|
|
2164
|
+
assert main(["subscription", "resume", "--yes"]) != 0
|
|
2165
|
+
assert "can't be resumed" in capsys.readouterr().err
|
|
2166
|
+
|
|
2167
|
+
|
|
2168
|
+
# ── data history: a cell's change log (#2960) ────────────────────────────────
|
|
2169
|
+
|
|
2170
|
+
BP = "0b5e4f1a-1111-4222-8333-444455556666"
|
|
2171
|
+
REC = "9a8b7c6d-0000-4000-8000-000000000001"
|
|
2172
|
+
|
|
2173
|
+
|
|
2174
|
+
def _entry(change, **over):
|
|
2175
|
+
base = {
|
|
2176
|
+
"cell_id": CELL, "entity_key": "books", "record_id": REC, "record_name": "Emma",
|
|
2177
|
+
"column": "author", "evidence_id": "e-1", "value": "J. Austen",
|
|
2178
|
+
"captured_at": "2026-10-15T09:30:00+00:00", "run_id": "run-1", "source_stage": "extract",
|
|
2179
|
+
"source_urls": [], "confidence_label": "high", "qa_notes": None,
|
|
2180
|
+
"previous_evidence_id": None, "previous_value": None, "lineage": "none",
|
|
2181
|
+
"change": change, "pre_amend": "none", "pre_amend_value": None,
|
|
2182
|
+
}
|
|
2183
|
+
return base | over
|
|
2184
|
+
|
|
2185
|
+
|
|
2186
|
+
def _history(*entries, **over):
|
|
2187
|
+
return {"object": "list", "data": list(entries), "has_more": False, "next_cursor": None,
|
|
2188
|
+
"blueprint": {"id": BP, "name": "Library"}} | over
|
|
2189
|
+
|
|
2190
|
+
|
|
2191
|
+
def _serve(monkeypatch, body, status=200):
|
|
2192
|
+
seen: list[httpx.Request] = []
|
|
2193
|
+
|
|
2194
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
2195
|
+
seen.append(request)
|
|
2196
|
+
return httpx.Response(status, json=body)
|
|
2197
|
+
|
|
2198
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
2199
|
+
return seen
|
|
2200
|
+
|
|
2201
|
+
|
|
2202
|
+
#: Every `change` the API publishes, plus one it may add later, with the line each must print.
|
|
2203
|
+
#: The wording is the source popover's (`source-popover.tsx` `LineageBlock`).
|
|
2204
|
+
_KINDS = [
|
|
2205
|
+
(_entry("changed", previous_value="Austen", lineage="recorded"), "Austen → J. Austen"),
|
|
2206
|
+
(_entry("changed", previous_value=None, lineage="recorded"), "(empty) → J. Austen"),
|
|
2207
|
+
(_entry("amended", pre_amend="recorded", pre_amend_value="Jane Austin"),
|
|
2208
|
+
"Jane Austin → J. Austen QA corrected"),
|
|
2209
|
+
(_entry("amended", pre_amend="not_recorded"),
|
|
2210
|
+
"J. Austen QA corrected · earlier value not recorded"),
|
|
2211
|
+
(_entry("amended"), "J. Austen QA corrected"),
|
|
2212
|
+
(_entry("unchanged"), "re-verified"),
|
|
2213
|
+
(_entry("observed", value="Austen, J."), "seen, not applied: Austen, J."),
|
|
2214
|
+
(_entry("not_applied", value="Jane"), "found, not applied: Jane"),
|
|
2215
|
+
(_entry("initial"), "J. Austen"),
|
|
2216
|
+
(_entry("unknown", value=None), "(empty)"),
|
|
2217
|
+
(_entry("rewritten_by_a_future_writer"), "J. Austen"),
|
|
2218
|
+
]
|
|
2219
|
+
|
|
2220
|
+
|
|
2221
|
+
@pytest.mark.parametrize("entry,line", _KINDS, ids=[
|
|
2222
|
+
f"{i}-{e['change']}-{e['pre_amend']}" for i, (e, _) in enumerate(_KINDS)])
|
|
2223
|
+
def test_history_prints_every_change_kind_in_the_popovers_words(monkeypatch, capsys, entry, line):
|
|
2224
|
+
_signed_in(monkeypatch)
|
|
2225
|
+
_serve(monkeypatch, _history(entry))
|
|
2226
|
+
assert main(["data", "history", "--cell", CELL]) == 0
|
|
2227
|
+
out_lines = capsys.readouterr().out.splitlines()
|
|
2228
|
+
header = next(r for r in out_lines if r.startswith("WHEN"))
|
|
2229
|
+
row = out_lines[out_lines.index(header) + 1]
|
|
2230
|
+
# Slice the CHANGE column out of the row by the header's offsets and compare it EXACTLY: a
|
|
2231
|
+
# substring check let a tag or a prefix on a bare-value kind through (review, MEDIUM-1).
|
|
2232
|
+
start, end = header.index("CHANGE"), header.index("RUN")
|
|
2233
|
+
assert row[start:end].rstrip() == line
|
|
2234
|
+
|
|
2235
|
+
|
|
2236
|
+
def test_history_truncates_a_long_value_and_never_the_qa_tag(monkeypatch, capsys):
|
|
2237
|
+
_signed_in(monkeypatch)
|
|
2238
|
+
long_old, long_new = "A" * 200, "B" * 200
|
|
2239
|
+
_serve(monkeypatch, _history(
|
|
2240
|
+
_entry("amended", pre_amend="recorded", pre_amend_value=long_old, value=long_new),
|
|
2241
|
+
_entry("amended", pre_amend="not_recorded", value=long_new),
|
|
2242
|
+
))
|
|
2243
|
+
assert main(["data", "history", "--cell", CELL]) == 0
|
|
2244
|
+
text = capsys.readouterr().out
|
|
2245
|
+
assert long_old not in text and long_new not in text
|
|
2246
|
+
assert "A" * 29 + "… → " + "B" * 29 + "… QA corrected" in text
|
|
2247
|
+
assert "B" * 29 + "… QA corrected · earlier value not recorded" in text
|
|
2248
|
+
|
|
2249
|
+
|
|
2250
|
+
def test_history_json_is_the_api_envelope_reprinted(monkeypatch, capsys):
|
|
2251
|
+
_signed_in(monkeypatch)
|
|
2252
|
+
body = _history(
|
|
2253
|
+
_entry("amended", pre_amend="recorded", pre_amend_value="Jane Austin"),
|
|
2254
|
+
_entry("amended", pre_amend="not_recorded"),
|
|
2255
|
+
cell={"cell_id": CELL, "entity_key": "books", "record_id": REC, "record_name": "Emma",
|
|
2256
|
+
"column": "author"},
|
|
2257
|
+
)
|
|
2258
|
+
_serve(monkeypatch, body)
|
|
2259
|
+
assert main(["data", "history", "--cell", CELL, "--json"]) == 0
|
|
2260
|
+
printed = capsys.readouterr().out
|
|
2261
|
+
assert printed == json.dumps(body, indent=2) + "\n"
|
|
2262
|
+
assert json.loads(printed)["data"][0]["pre_amend_value"] == "Jane Austin"
|
|
2263
|
+
|
|
2264
|
+
|
|
2265
|
+
def test_history_by_cell_reads_the_cell_endpoint_with_its_flags(monkeypatch, capsys):
|
|
2266
|
+
_signed_in(monkeypatch)
|
|
2267
|
+
seen = _serve(monkeypatch, _history(_entry("initial"), has_more=True, next_cursor="nx",
|
|
2268
|
+
cell={"record_name": "Emma", "column": "author"}))
|
|
2269
|
+
rc = main(["data", "history", "--cell", CELL, "--rechecks", "--since",
|
|
2270
|
+
"2026-10-01T00:00:00Z", "--cursor", "cc", "--limit", "5"])
|
|
2271
|
+
assert rc == 0
|
|
2272
|
+
assert len(seen) == 1 # a cell id names its Blueprint: no /v1/blueprints lookup
|
|
2273
|
+
assert seen[0].url.path == f"/v1/cells/{CELL}/changes"
|
|
2274
|
+
assert dict(seen[0].url.params) == {"limit": "5", "cursor": "cc",
|
|
2275
|
+
"since": "2026-10-01T00:00:00Z", "include": "rechecks"}
|
|
2276
|
+
text = capsys.readouterr().out
|
|
2277
|
+
assert "Cell: Emma.author" in text and "--cursor nx" in text
|
|
2278
|
+
assert "RECORD" not in text # one cell: the record is in the Cell line, not a column
|
|
2279
|
+
|
|
2280
|
+
|
|
2281
|
+
def test_history_by_column_resolves_the_active_blueprint_and_passes_the_record(monkeypatch, capsys):
|
|
2282
|
+
_signed_in(monkeypatch)
|
|
2283
|
+
seen: list[httpx.Request] = []
|
|
2284
|
+
|
|
2285
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
2286
|
+
seen.append(request)
|
|
2287
|
+
if request.url.path == "/v1/blueprints":
|
|
2288
|
+
return httpx.Response(200, json={"data": [{"id": BP, "is_active": True}]})
|
|
2289
|
+
return httpx.Response(200, json=_history(_entry("changed", previous_value="Austen")))
|
|
2290
|
+
|
|
2291
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
2292
|
+
assert main(["data", "history", "books", "author", "--record", REC]) == 0
|
|
2293
|
+
assert [r.url.path for r in seen] == [
|
|
2294
|
+
"/v1/blueprints", f"/v1/blueprints/{BP}/entities/books/columns/author/changes"]
|
|
2295
|
+
assert dict(seen[1].url.params) == {"limit": "25", "record_id": REC}
|
|
2296
|
+
text = capsys.readouterr().out
|
|
2297
|
+
assert "RECORD" in text and "Emma" in text and "Austen → J. Austen" in text
|
|
2298
|
+
|
|
2299
|
+
|
|
2300
|
+
def test_history_with_blueprint_skips_the_lookup(monkeypatch):
|
|
2301
|
+
_signed_in(monkeypatch)
|
|
2302
|
+
seen = _serve(monkeypatch, _history())
|
|
2303
|
+
assert main(["data", "history", "books", "author", "--blueprint", BP]) == 0
|
|
2304
|
+
assert [r.url.path for r in seen] == [f"/v1/blueprints/{BP}/entities/books/columns/author/changes"]
|
|
2305
|
+
|
|
2306
|
+
|
|
2307
|
+
def test_history_empty_page_says_so(monkeypatch, capsys):
|
|
2308
|
+
_signed_in(monkeypatch)
|
|
2309
|
+
_serve(monkeypatch, _history())
|
|
2310
|
+
assert main(["data", "history", "--cell", CELL]) == 0
|
|
2311
|
+
assert "No history found." in capsys.readouterr().out
|
|
2312
|
+
|
|
2313
|
+
|
|
2314
|
+
@pytest.mark.parametrize("argv", [
|
|
2315
|
+
["data", "history"],
|
|
2316
|
+
["data", "history", "books"],
|
|
2317
|
+
["data", "history", "books", "author", "--cell", CELL],
|
|
2318
|
+
["data", "history", "--cell", CELL, "--record", REC],
|
|
2319
|
+
["data", "history", "--cell", CELL, "--blueprint", BP],
|
|
2320
|
+
["data", "history", "books", "author", "--rechecks"],
|
|
2321
|
+
["data", "history", "--cell", "../../v1/holds"],
|
|
2322
|
+
["data", "history", "books", "author", "--record", "not-a-uuid"],
|
|
2323
|
+
["data", "history", "books", "../holds", "--blueprint", BP],
|
|
2324
|
+
["data", "history", "books", "author", "--record", ""],
|
|
2325
|
+
["data", "history", "books", "author", "--blueprint", "../../v1/holds#"],
|
|
2326
|
+
["data", "history", "--cell", CELL, "--record", ""],
|
|
2327
|
+
], ids=["neither", "entity-only", "both-forms", "cell-with-record", "cell-with-blueprint",
|
|
2328
|
+
"rechecks-without-cell", "bad-cell-id", "bad-record", "bad-column", "empty-record",
|
|
2329
|
+
"bad-blueprint", "cell-with-empty-record"])
|
|
2330
|
+
def test_history_usage_errors_exit_2_and_send_nothing(monkeypatch, capsys, argv):
|
|
2331
|
+
_signed_in(monkeypatch)
|
|
2332
|
+
seen = _serve(monkeypatch, _history())
|
|
2333
|
+
assert main(argv) == 2
|
|
2334
|
+
assert capsys.readouterr().err.startswith("error: ")
|
|
2335
|
+
assert seen == []
|
|
2336
|
+
|
|
2337
|
+
|
|
2338
|
+
def test_history_outside_the_preview_surfaces_the_refusal(monkeypatch, capsys):
|
|
2339
|
+
_signed_in(monkeypatch)
|
|
2340
|
+
_serve(monkeypatch, {"error": {"type": "payment_error", "code": "payment_required",
|
|
2341
|
+
"message": "This row is outside your free preview.",
|
|
2342
|
+
"retriable": False, "request_id": "r"}}, status=402)
|
|
2343
|
+
assert main(["data", "history", "--cell", CELL]) == 1
|
|
2344
|
+
assert "outside your free preview" in capsys.readouterr().err
|
|
2345
|
+
|
|
2346
|
+
|
|
2347
|
+
def test_history_a_preview_page_says_it_is_clipped_on_stderr(monkeypatch, capsys):
|
|
2348
|
+
_signed_in(monkeypatch)
|
|
2349
|
+
_serve(monkeypatch, _history(_entry("initial"), truncated=True,
|
|
2350
|
+
truncated_reason="Free preview: only changes on the 5 preview rows."))
|
|
2351
|
+
assert main(["data", "history", "books", "author", "--blueprint", BP]) == 0
|
|
2352
|
+
captured = capsys.readouterr()
|
|
2353
|
+
assert "Free preview" in captured.err and "Free preview" not in captured.out
|
|
2354
|
+
|
|
2355
|
+
|
|
2356
|
+
@pytest.mark.parametrize("body", [{"raw": "<html>not the API</html>"}, {"data": None}],
|
|
2357
|
+
ids=["html-page", "no-list"])
|
|
2358
|
+
def test_history_a_response_that_is_not_a_change_log_is_a_failure_not_empty(monkeypatch, capsys,
|
|
2359
|
+
body):
|
|
2360
|
+
"""Found nothing and could not look must not print the same line (review, LOW-3)."""
|
|
2361
|
+
_signed_in(monkeypatch)
|
|
2362
|
+
_serve(monkeypatch, body)
|
|
2363
|
+
assert main(["data", "history", "--cell", CELL]) == 1
|
|
2364
|
+
captured = capsys.readouterr()
|
|
2365
|
+
assert "No history found." not in captured.out
|
|
2366
|
+
assert "not a change log" in captured.err
|
|
2367
|
+
|
|
2368
|
+
|
|
2369
|
+
def test_history_times_are_shown_in_utc(monkeypatch, capsys):
|
|
2370
|
+
_signed_in(monkeypatch)
|
|
2371
|
+
_serve(monkeypatch, _history(_entry("initial", captured_at="2026-10-15T11:30:00+02:00"),
|
|
2372
|
+
_entry("initial", captured_at=None)))
|
|
2373
|
+
assert main(["data", "history", "--cell", CELL]) == 0
|
|
2374
|
+
text = capsys.readouterr().out
|
|
2375
|
+
assert "WHEN (UTC)" in text and "2026-10-15T09:30:00Z" in text and "date unknown" in text
|
|
2376
|
+
|
|
2377
|
+
|
|
2378
|
+
# gh #2456: the yes binds what pays as well as the total.
|
|
2379
|
+
|
|
2380
|
+
_RAIL_QUOTE = {
|
|
2381
|
+
**_RETRY_QUOTE,
|
|
2382
|
+
"funding": {"covered_by": "plan_credits", "plan_credits_needed": 2,
|
|
2383
|
+
"plan_credits_available": 5, "balance_cents": 0},
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
|
|
2387
|
+
def _rail_handler(seen: list):
|
|
2388
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
2389
|
+
body = json.loads(request.content) if request.content else {}
|
|
2390
|
+
seen.append(body)
|
|
2391
|
+
if body.get("expected_funding") != "plan_credits" or body.get("expected_total_cents") != 1200:
|
|
2392
|
+
return httpx.Response(409, json={"error": {
|
|
2393
|
+
"code": "retry_confirmation_required", "message": "confirm",
|
|
2394
|
+
"param": "expected_total_cents", "retriable": False, "details": _RAIL_QUOTE,
|
|
2395
|
+
}})
|
|
2396
|
+
return httpx.Response(202, json={
|
|
2397
|
+
"object": "run", "id": "new-run", "status": "queued",
|
|
2398
|
+
"retry": {"source_run_id": RUN, "mode": "restarted"},
|
|
2399
|
+
})
|
|
2400
|
+
|
|
2401
|
+
return handler
|
|
2402
|
+
|
|
2403
|
+
|
|
2404
|
+
def test_interactively_the_yes_binds_the_rail_shown(monkeypatch, capsys):
|
|
2405
|
+
_signed_in(monkeypatch)
|
|
2406
|
+
monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: True)
|
|
2407
|
+
prompts: list = []
|
|
2408
|
+
monkeypatch.setattr("builtins.input", lambda prompt="": prompts.append(prompt) or "y")
|
|
2409
|
+
seen: list = []
|
|
2410
|
+
monkeypatch.setattr(httpx, "Client", _mock(_rail_handler(seen)))
|
|
2411
|
+
assert main(["runs", "retry", RUN]) == 0
|
|
2412
|
+
assert seen == [{}, {"expected_total_cents": 1200, "expected_funding": "plan_credits"}]
|
|
2413
|
+
assert "uses 2 plan run credits" in prompts[0]
|
|
2414
|
+
|
|
2415
|
+
|
|
2416
|
+
def test_in_a_pipe_the_hint_names_the_rail_flag(monkeypatch, capsys):
|
|
2417
|
+
_signed_in(monkeypatch)
|
|
2418
|
+
monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: False)
|
|
2419
|
+
seen: list = []
|
|
2420
|
+
monkeypatch.setattr(httpx, "Client", _mock(_rail_handler(seen)))
|
|
2421
|
+
assert main(["runs", "retry", RUN]) == 2
|
|
2422
|
+
assert "--expected-total 1200 --expected-funding plan_credits" in capsys.readouterr().err
|
|
2423
|
+
|
|
2424
|
+
|
|
2425
|
+
def test_expected_funding_is_sent_beside_the_total(monkeypatch, capsys):
|
|
2426
|
+
_signed_in(monkeypatch)
|
|
2427
|
+
seen: list = []
|
|
2428
|
+
monkeypatch.setattr(httpx, "Client", _mock(_rail_handler(seen)))
|
|
2429
|
+
argv = ["runs", "retry", RUN, "--expected-total", "1200", "--expected-funding", "plan_credits"]
|
|
2430
|
+
assert main(argv) == 0
|
|
2431
|
+
assert seen == [{"expected_total_cents": 1200, "expected_funding": "plan_credits"}]
|
|
2432
|
+
|
|
2433
|
+
|
|
2434
|
+
def test_expected_funding_without_its_total_sends_nothing(monkeypatch, capsys):
|
|
2435
|
+
_signed_in(monkeypatch)
|
|
2436
|
+
seen: list = []
|
|
2437
|
+
monkeypatch.setattr(httpx, "Client", _mock(_rail_handler(seen)))
|
|
2438
|
+
assert main(["runs", "retry", RUN, "--expected-funding", "plan_credits"]) == 2
|
|
2439
|
+
assert seen == []
|
|
2440
|
+
|
|
2441
|
+
|
|
2442
|
+
def test_a_moved_rail_at_the_same_total_is_not_called_a_price_change(monkeypatch, capsys):
|
|
2443
|
+
"""gh #2456 review (seat-contract 2a): the person is told what moved."""
|
|
2444
|
+
_signed_in(monkeypatch)
|
|
2445
|
+
monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: True)
|
|
2446
|
+
prompts: list = []
|
|
2447
|
+
monkeypatch.setattr("builtins.input", lambda prompt="": prompts.append(prompt) or "n")
|
|
2448
|
+
balance_quote = {**_RAIL_QUOTE, "funding": {**_RAIL_QUOTE["funding"], "covered_by": "balance"}}
|
|
2449
|
+
|
|
2450
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
2451
|
+
return httpx.Response(409, json={"error": {
|
|
2452
|
+
"code": "retry_price_changed", "message": "moved", "param": "expected_funding",
|
|
2453
|
+
"retriable": False, "details": balance_quote,
|
|
2454
|
+
}})
|
|
2455
|
+
|
|
2456
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
2457
|
+
argv = ["runs", "retry", RUN, "--expected-total", "1200", "--expected-funding", "plan_credits"]
|
|
2458
|
+
assert main(argv) == 0
|
|
2459
|
+
assert "What would pay for this retry changed" in prompts[0]
|
|
2460
|
+
assert "price changed" not in prompts[0]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|