deepsieve-cli 0.8.0__tar.gz → 0.9.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.8.0 → deepsieve_cli-0.9.0}/.gitignore +3 -0
- {deepsieve_cli-0.8.0 → deepsieve_cli-0.9.0}/PKG-INFO +1 -1
- {deepsieve_cli-0.8.0 → deepsieve_cli-0.9.0}/pyproject.toml +1 -1
- {deepsieve_cli-0.8.0 → deepsieve_cli-0.9.0}/src/deepsieve_cli/main.py +194 -4
- deepsieve_cli-0.9.0/tests/fixtures/conflict_page.json +48 -0
- {deepsieve_cli-0.8.0 → deepsieve_cli-0.9.0}/tests/test_cli.py +190 -1
- {deepsieve_cli-0.8.0 → deepsieve_cli-0.9.0}/README.md +0 -0
- {deepsieve_cli-0.8.0 → deepsieve_cli-0.9.0}/src/deepsieve_cli/__init__.py +0 -0
- {deepsieve_cli-0.8.0 → deepsieve_cli-0.9.0}/src/deepsieve_cli/__main__.py +0 -0
- {deepsieve_cli-0.8.0 → deepsieve_cli-0.9.0}/src/deepsieve_cli/client.py +0 -0
- {deepsieve_cli-0.8.0 → deepsieve_cli-0.9.0}/src/deepsieve_cli/config.py +0 -0
|
@@ -139,3 +139,6 @@ page-*.yml
|
|
|
139
139
|
# same mechanism as the node_modules note above, a second time. Reports belong
|
|
140
140
|
# in the session scratchpad; a copy in the tree is only ever a transient.
|
|
141
141
|
.review-*/
|
|
142
|
+
|
|
143
|
+
# gh #3106: the old build scripts/check-cross-version-seam.sh archives (never committed)
|
|
144
|
+
/.cross-version/
|
|
@@ -343,6 +343,11 @@ def cmd_runs_create(args) -> int:
|
|
|
343
343
|
if args.query and args.seeds:
|
|
344
344
|
err("pass at most one of --query or --seeds")
|
|
345
345
|
return EXIT_USAGE
|
|
346
|
+
phrases = [s.strip() for s in (getattr(args, "phrase", None) or []) if s.strip()]
|
|
347
|
+
file_types = [s.strip() for s in (getattr(args, "file_type", None) or []) if s.strip()]
|
|
348
|
+
if getattr(args, "no_search_hints", False) and (phrases or file_types):
|
|
349
|
+
err("pass --no-search-hints, or --phrase/--file-type, not both")
|
|
350
|
+
return EXIT_USAGE
|
|
346
351
|
# gh #1282 B1: a billable run must NAME its Blueprint (the API refuses one that does not);
|
|
347
352
|
# say so before anything else, with where the id comes from. A dry run is free and may omit it.
|
|
348
353
|
if not args.dry_run and not getattr(args, "blueprint", None):
|
|
@@ -364,6 +369,16 @@ def cmd_runs_create(args) -> int:
|
|
|
364
369
|
if excluded:
|
|
365
370
|
# gh #2641 — repeatable; the server resolves and refuses, the CLI only carries.
|
|
366
371
|
body["excluded_sources"] = excluded
|
|
372
|
+
# gh #2657 — the exact phrases and file types the researcher also searches for (never a
|
|
373
|
+
# filter). Given, they REPLACE the ones the server reads from --query; --no-search-hints
|
|
374
|
+
# sends {} so a misread one is dropped. Neither: the parsed ones, as before.
|
|
375
|
+
if getattr(args, "no_search_hints", False):
|
|
376
|
+
body["search_hints"] = {}
|
|
377
|
+
elif phrases or file_types:
|
|
378
|
+
body["search_hints"] = {
|
|
379
|
+
**({"exact_phrases": phrases} if phrases else {}),
|
|
380
|
+
**({"file_types": file_types} if file_types else {}),
|
|
381
|
+
}
|
|
367
382
|
if getattr(args, "blueprint", None):
|
|
368
383
|
body["blueprint_id"] = args.blueprint
|
|
369
384
|
if getattr(args, "expected_total", None) is not None:
|
|
@@ -738,6 +753,13 @@ def _refund_line(refund: dict | None) -> str:
|
|
|
738
753
|
if kind == "kept_credits":
|
|
739
754
|
used = plural(credits, "run credit", "run credits") if credits is not None else "run credits"
|
|
740
755
|
return f"Your earlier run used {used}. It finished with no results, and a finished run isn't refunded."
|
|
756
|
+
if kind == "kept_mixed":
|
|
757
|
+
# gh #1858: a finished mixed run kept its credits AND its prepaid part.
|
|
758
|
+
if credits is not None and amount is not None:
|
|
759
|
+
used = f"{plural(credits, 'run credit', 'run credits')} and {_usd(amount)} of your prepaid balance"
|
|
760
|
+
else:
|
|
761
|
+
used = "run credits and your prepaid balance"
|
|
762
|
+
return f"Your earlier run used {used}. It finished with no results, and a finished run isn't refunded."
|
|
741
763
|
if kind == "refunded_to_ended_plan":
|
|
742
764
|
return f"Your plan has ended, so the credits for your {run} were returned to it and can't be used."
|
|
743
765
|
if kind == "refunded_to_ended_period":
|
|
@@ -757,6 +779,10 @@ def _refund_line(refund: dict | None) -> str:
|
|
|
757
779
|
if kind == "partially_refunded_to_plan":
|
|
758
780
|
return (f"Part of your {run} returned nothing. The credits for that part were returned to "
|
|
759
781
|
"the plan they came from.")
|
|
782
|
+
if kind == "not_refunded_account_closed":
|
|
783
|
+
# gh #3045: closure voids prepaid credit, so nothing was sent: never "refunded".
|
|
784
|
+
return (f"Nothing was returned for your {run}, because your account is closed and a "
|
|
785
|
+
"closed account can't hold prepaid credit.")
|
|
760
786
|
if kind == "refunded_unknown":
|
|
761
787
|
return f"Your {run} was refunded in full."
|
|
762
788
|
if kind == "refund_pending":
|
|
@@ -781,6 +807,16 @@ def _retry_cost(details: dict) -> str:
|
|
|
781
807
|
if funding.get("covered_by") == "plan_credits":
|
|
782
808
|
n = funding.get("plan_credits_needed")
|
|
783
809
|
return f"uses {n} plan run credit{'' if n == 1 else 's'} (list price {_usd(total)})"
|
|
810
|
+
if funding.get("covered_by") == "mixed":
|
|
811
|
+
# gh #1858: plan credits for the interactions they cover, the balance for the rest.
|
|
812
|
+
prepaid = _usd(funding.get("prepaid_cents"))
|
|
813
|
+
cost = (
|
|
814
|
+
f"uses your plan run credits and {prepaid} from your balance "
|
|
815
|
+
f"(list price {_usd(total)})"
|
|
816
|
+
)
|
|
817
|
+
if (details.get("shortfall_cents") or 0) > 0:
|
|
818
|
+
cost += f", and your balance is {_usd(details['shortfall_cents'])} short"
|
|
819
|
+
return cost
|
|
784
820
|
cost = f"charges {_usd(total)}"
|
|
785
821
|
if (details.get("shortfall_cents") or 0) > 0:
|
|
786
822
|
cost += f", and your balance is {_usd(details['shortfall_cents'])} short"
|
|
@@ -838,6 +874,10 @@ def cmd_runs_retry(args) -> int:
|
|
|
838
874
|
if args.expected_funding is not None and args.expected_total is None:
|
|
839
875
|
err("--expected-funding binds the rail of a quoted total: pass --expected-total too")
|
|
840
876
|
return EXIT_USAGE
|
|
877
|
+
# gh #1858: a mixed rail binds its split, so it needs the prepaid part; nothing else takes one.
|
|
878
|
+
if (args.expected_funding == "mixed") != (args.expected_prepaid is not None):
|
|
879
|
+
err("--expected-funding mixed and --expected-prepaid CENTS go together (funding.prepaid_cents)")
|
|
880
|
+
return EXIT_USAGE
|
|
841
881
|
client, _ = _require_auth(args)
|
|
842
882
|
path = f"/v1/research/runs/{rid}/retry"
|
|
843
883
|
payload: dict = {}
|
|
@@ -845,6 +885,8 @@ def cmd_runs_retry(args) -> int:
|
|
|
845
885
|
payload["expected_total_cents"] = args.expected_total
|
|
846
886
|
if args.expected_funding is not None:
|
|
847
887
|
payload["expected_funding"] = args.expected_funding
|
|
888
|
+
if args.expected_prepaid is not None:
|
|
889
|
+
payload["expected_prepaid_cents"] = args.expected_prepaid
|
|
848
890
|
# At most two refusals: the quote, and one re-quote if the price moved while the person was
|
|
849
891
|
# deciding. A third would be the API contradicting itself; stop rather than loop, and never ask
|
|
850
892
|
# a question whose yes would not be sent (seat-2389 L1).
|
|
@@ -863,11 +905,24 @@ def cmd_runs_retry(args) -> int:
|
|
|
863
905
|
# No quote to state: never prompt "$0.00" (seat-2389 L2).
|
|
864
906
|
err(f"{exc} (the API sent no total to confirm; nothing was charged)")
|
|
865
907
|
return EXIT_FAIL
|
|
908
|
+
funding = exc.details.get("funding") or {}
|
|
909
|
+
rail = funding.get("covered_by")
|
|
910
|
+
prepaid = funding.get("prepaid_cents")
|
|
911
|
+
if rail == "mixed" and (not isinstance(prepaid, int) or isinstance(prepaid, bool)):
|
|
912
|
+
# gh #1858 review: a mixed quote without its prepaid part cannot be confirmed
|
|
913
|
+
# (the API refuses `mixed` without it), so say so, never `--expected-prepaid
|
|
914
|
+
# None`. Checked before the quote text, which prices the split.
|
|
915
|
+
err(
|
|
916
|
+
f"{exc} (the API's quote names a split without its prepaid part; "
|
|
917
|
+
"nothing was charged)"
|
|
918
|
+
)
|
|
919
|
+
return EXIT_FAIL
|
|
866
920
|
ask = _retry_quote_text(
|
|
867
921
|
exc.details, moved=exc.code == "retry_price_changed", sent=payload
|
|
868
922
|
)
|
|
869
|
-
rail = (exc.details.get("funding") or {}).get("covered_by")
|
|
870
923
|
flags = f"--expected-total {total}" + (f" --expected-funding {rail}" if rail else "")
|
|
924
|
+
if rail == "mixed":
|
|
925
|
+
flags += f" --expected-prepaid {prepaid}"
|
|
871
926
|
if not _interactive():
|
|
872
927
|
if args.json:
|
|
873
928
|
print(json.dumps({"error": {"code": exc.code, "message": ask, "details": exc.details}}))
|
|
@@ -881,6 +936,8 @@ def cmd_runs_retry(args) -> int:
|
|
|
881
936
|
payload = {"expected_total_cents": total}
|
|
882
937
|
if rail:
|
|
883
938
|
payload["expected_funding"] = rail # gh #2456: the yes covers what pays too
|
|
939
|
+
if rail == "mixed":
|
|
940
|
+
payload["expected_prepaid_cents"] = prepaid # gh #1858: and the split
|
|
884
941
|
if body is None:
|
|
885
942
|
err("the API kept refusing the confirmation; nothing was charged")
|
|
886
943
|
return EXIT_FAIL
|
|
@@ -998,6 +1055,12 @@ def _describe_change(c: dict) -> str:
|
|
|
998
1055
|
if kind == "link_removed":
|
|
999
1056
|
link = c.get("link") or {}
|
|
1000
1057
|
return f"removed {link.get('target_name') or link.get('target_id')} from {link.get('record_name')}"
|
|
1058
|
+
if kind == "choice":
|
|
1059
|
+
ch = c.get("choice") or {}
|
|
1060
|
+
return (
|
|
1061
|
+
f"chose {ch.get('chosen_value')!r} over {ch.get('prior_value')!r} for "
|
|
1062
|
+
f"{ch.get('record_name')}.{ch.get('column')}"
|
|
1063
|
+
)
|
|
1001
1064
|
return str(kind)
|
|
1002
1065
|
|
|
1003
1066
|
|
|
@@ -1172,6 +1235,90 @@ def cmd_holds_keep(args) -> int:
|
|
|
1172
1235
|
def cmd_holds_release(args) -> int:
|
|
1173
1236
|
return _hold_verb(args, "release")
|
|
1174
1237
|
|
|
1238
|
+
|
|
1239
|
+
def cmd_holds_conflicts(args) -> int:
|
|
1240
|
+
"""Cells waiting for you to choose among the values one run stated (gh #2370)."""
|
|
1241
|
+
client, _ = _require_auth(args)
|
|
1242
|
+
params: dict[str, Any] = {"limit": args.limit}
|
|
1243
|
+
for flag, key in (("entity", "entity"), ("cursor", "cursor"), ("blueprint", "blueprint_id")):
|
|
1244
|
+
if getattr(args, flag, None):
|
|
1245
|
+
params[key] = getattr(args, flag)
|
|
1246
|
+
try:
|
|
1247
|
+
body = client.request("GET", "/v1/conflicts", params=params)
|
|
1248
|
+
except ApiError as exc:
|
|
1249
|
+
err(str(exc))
|
|
1250
|
+
return EXIT_FAIL
|
|
1251
|
+
if args.json:
|
|
1252
|
+
out(body, True)
|
|
1253
|
+
return EXIT_OK
|
|
1254
|
+
bp = body.get("blueprint") or {}
|
|
1255
|
+
if bp:
|
|
1256
|
+
print(f"Blueprint: {bp.get('name')} ({bp.get('id')})")
|
|
1257
|
+
rows = []
|
|
1258
|
+
for c in body.get("data", []):
|
|
1259
|
+
where = f"{c.get('record_name') or c.get('record_id')}.{c.get('column')}"[:40]
|
|
1260
|
+
# The value shown first, then each value it can be chosen from, with the evidence id
|
|
1261
|
+
# `holds choose` takes and `--expected` the shown value's.
|
|
1262
|
+
rows.append([c.get("cell_id", ""), where, str(c.get("value"))[:30], "shown", c.get("value_evidence_id") or "-"])
|
|
1263
|
+
for cand in c.get("candidates") or []:
|
|
1264
|
+
tag = cand.get("verdict") or ""
|
|
1265
|
+
if cand.get("refused_by"):
|
|
1266
|
+
tag += " · kept out by your knowledge"
|
|
1267
|
+
rows.append(["", "", str(cand.get("value"))[:30], tag[:40], cand.get("evidence_id") or "-"])
|
|
1268
|
+
print(
|
|
1269
|
+
_table(rows, ["cell", "cell of", "value", "verdict", "evidence"])
|
|
1270
|
+
if rows
|
|
1271
|
+
else "No cells waiting for you to choose."
|
|
1272
|
+
)
|
|
1273
|
+
if body.get("truncated"):
|
|
1274
|
+
print(
|
|
1275
|
+
f"\nnote: a PREVIEW, not every cell (preview_row_cap={body.get('preview_row_cap')}).",
|
|
1276
|
+
file=sys.stderr,
|
|
1277
|
+
)
|
|
1278
|
+
if body.get("has_more"):
|
|
1279
|
+
print(f"\nMore: --cursor {body.get('next_cursor')}")
|
|
1280
|
+
return EXIT_OK
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
def cmd_holds_choose(args) -> int:
|
|
1284
|
+
"""Make one of a cell's values the one it shows (gh #2370). It changes the dataset, so it is
|
|
1285
|
+
confirmed like the other hold verbs: interactively, or with --yes, never silently in a pipe."""
|
|
1286
|
+
client, _ = _require_auth(args)
|
|
1287
|
+
if not _CELL_ID.fullmatch(args.cell_id or ""):
|
|
1288
|
+
err("cell id must be one `deepsieve holds conflicts` printed (it starts with cel_)")
|
|
1289
|
+
return EXIT_USAGE
|
|
1290
|
+
body: dict[str, Any] = {}
|
|
1291
|
+
for name, raw in (("evidence_id", args.evidence_id), ("expected_value_evidence_id", args.expected)):
|
|
1292
|
+
if raw is None:
|
|
1293
|
+
continue
|
|
1294
|
+
try:
|
|
1295
|
+
body[name] = str(uuid.UUID(raw))
|
|
1296
|
+
except ValueError:
|
|
1297
|
+
err(f"{'evidence id' if name == 'evidence_id' else '--expected'} must be an evidence id (a UUID)")
|
|
1298
|
+
return EXIT_USAGE
|
|
1299
|
+
if not args.yes:
|
|
1300
|
+
if not _interactive():
|
|
1301
|
+
err("refusing to change data non-interactively; pass --yes")
|
|
1302
|
+
return EXIT_USAGE
|
|
1303
|
+
if input("Show this value in the cell, and protect it from later research? [y/N] ").strip().lower() not in ("y", "yes"):
|
|
1304
|
+
print("Cancelled.")
|
|
1305
|
+
return EXIT_OK
|
|
1306
|
+
headers = {"Idempotency-Key": args.idempotency_key} if args.idempotency_key else None
|
|
1307
|
+
try:
|
|
1308
|
+
result = client.request(
|
|
1309
|
+
"POST", f"/v1/cells/{args.cell_id}/choose", json_body=body, headers=headers
|
|
1310
|
+
)
|
|
1311
|
+
except ApiError as exc:
|
|
1312
|
+
err(str(exc))
|
|
1313
|
+
return EXIT_FAIL
|
|
1314
|
+
out(
|
|
1315
|
+
result,
|
|
1316
|
+
args.json,
|
|
1317
|
+
f"chosen; the cell stays protected: {result.get('column')} = {result.get('value')!r}"
|
|
1318
|
+
f" (undo: deepsieve changes undo {result.get('change_id')})",
|
|
1319
|
+
)
|
|
1320
|
+
return EXIT_OK
|
|
1321
|
+
|
|
1175
1322
|
def _merge_body(args) -> dict[str, Any] | None:
|
|
1176
1323
|
choices: dict[str, str] = {}
|
|
1177
1324
|
for c in args.choice or []:
|
|
@@ -1462,6 +1609,19 @@ def cmd_data_get(args) -> int:
|
|
|
1462
1609
|
err(str(exc))
|
|
1463
1610
|
return EXIT_FAIL
|
|
1464
1611
|
out(body, True) # rows are structured either way; --json only stills the notes
|
|
1612
|
+
conflicting = sum(
|
|
1613
|
+
1
|
|
1614
|
+
for row in body.get("data") or []
|
|
1615
|
+
for cite in (row.get("citations") or {}).values()
|
|
1616
|
+
if ((cite or {}).get("selection") or {}).get("state") == "conflicting"
|
|
1617
|
+
)
|
|
1618
|
+
if conflicting and not args.json:
|
|
1619
|
+
# gh #2370: a cell stated two ways shows the better-sourced value until someone chooses.
|
|
1620
|
+
print(
|
|
1621
|
+
f"\nnote: {conflicting} cell(s) here were stated more than one way and wait for you "
|
|
1622
|
+
"to choose: `deepsieve holds conflicts`.",
|
|
1623
|
+
file=sys.stderr,
|
|
1624
|
+
)
|
|
1465
1625
|
if body.get("truncated") and not args.json:
|
|
1466
1626
|
print(
|
|
1467
1627
|
"\nnote: this response is a PREVIEW, not the full dataset "
|
|
@@ -1837,6 +1997,16 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1837
1997
|
rc.add_argument("--excluded-source", action="append", metavar="PATTERN",
|
|
1838
1998
|
help="never cite this source (repeatable): a host, *.tld or "
|
|
1839
1999
|
"https://host/path/*; a citation on it is not written")
|
|
2000
|
+
rc.add_argument("--phrase", action="append", metavar="TEXT",
|
|
2001
|
+
help="also search for pages with this exact wording (repeatable; never a "
|
|
2002
|
+
"filter). With --file-type, replaces EVERY exact phrase and file type "
|
|
2003
|
+
"read from --query")
|
|
2004
|
+
rc.add_argument("--file-type", action="append", metavar="EXT",
|
|
2005
|
+
help="also look for this kind of document, e.g. pdf or xlsx (repeatable; "
|
|
2006
|
+
"never a filter). With --phrase, replaces EVERY exact phrase and file "
|
|
2007
|
+
"type read from --query")
|
|
2008
|
+
rc.add_argument("--no-search-hints", action="store_true",
|
|
2009
|
+
help="drop the exact phrases and file types read from --query")
|
|
1840
2010
|
rc.add_argument("--blueprint", help="Blueprint id to research; REQUIRED unless --dry-run "
|
|
1841
2011
|
"(`deepsieve whoami` shows your active Blueprint's id)")
|
|
1842
2012
|
rc.add_argument("--depth", default="standard", choices=["standard", "max"])
|
|
@@ -1882,10 +2052,17 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1882
2052
|
)
|
|
1883
2053
|
rr.add_argument(
|
|
1884
2054
|
"--expected-funding",
|
|
1885
|
-
choices=["free_run", "plan_credits", "balance"],
|
|
2055
|
+
choices=["free_run", "plan_credits", "balance", "mixed"],
|
|
1886
2056
|
default=None,
|
|
1887
2057
|
help="also bind what pays, as quoted (funding.covered_by); needs --expected-total",
|
|
1888
2058
|
)
|
|
2059
|
+
rr.add_argument(
|
|
2060
|
+
"--expected-prepaid",
|
|
2061
|
+
type=int,
|
|
2062
|
+
default=None,
|
|
2063
|
+
metavar="CENTS",
|
|
2064
|
+
help="with --expected-funding mixed: the balance's part, as quoted (funding.prepaid_cents)",
|
|
2065
|
+
)
|
|
1889
2066
|
rr.set_defaults(func=cmd_runs_retry)
|
|
1890
2067
|
|
|
1891
2068
|
rm = runs.add_parser(
|
|
@@ -1958,10 +2135,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1958
2135
|
ml = members.add_parser("list", help="who is in the organization, and seat usage", parents=[common])
|
|
1959
2136
|
ml.set_defaults(func=cmd_members_list)
|
|
1960
2137
|
|
|
1961
|
-
changes = sub.add_parser("changes", help="merges, renames, successor links and
|
|
2138
|
+
changes = sub.add_parser("changes", help="merges, renames, successor links, removed links and value choices", parents=[common]).add_subparsers(dest="sub", required=True)
|
|
1962
2139
|
cl = changes.add_parser("list", help="newest first; undone ones included unless --live", parents=[common])
|
|
1963
2140
|
cl.add_argument("--entity", help="only this entity's records (an entity key)")
|
|
1964
|
-
cl.add_argument("--kind", choices=["merge", "renamed", "succeeded_by", "link_removed"])
|
|
2141
|
+
cl.add_argument("--kind", choices=["merge", "renamed", "succeeded_by", "link_removed", "choice"])
|
|
1965
2142
|
cl.add_argument("--by", choices=["user", "automatic", "operator"], help="who made the change")
|
|
1966
2143
|
cl.add_argument("--live", action="store_true", help="leave out undone changes")
|
|
1967
2144
|
cl.add_argument("--limit", type=int, default=25)
|
|
@@ -2022,6 +2199,19 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2022
2199
|
hr.add_argument("--yes", action="store_true", help="do not ask for confirmation")
|
|
2023
2200
|
hr.add_argument("--idempotency-key", help="make a retry safe")
|
|
2024
2201
|
hr.set_defaults(func=cmd_holds_release)
|
|
2202
|
+
hc = holds.add_parser("conflicts", help="cells waiting for you to choose among one run's values", parents=[common])
|
|
2203
|
+
hc.add_argument("--entity", help="only this entity's cells (an entity key)")
|
|
2204
|
+
hc.add_argument("--limit", type=int, default=25)
|
|
2205
|
+
hc.add_argument("--cursor")
|
|
2206
|
+
hc.add_argument("--blueprint", help="Blueprint id (default: the active one)")
|
|
2207
|
+
hc.set_defaults(func=cmd_holds_conflicts)
|
|
2208
|
+
hch = holds.add_parser("choose", help="show one of a cell's values (needs data:write)", parents=[common])
|
|
2209
|
+
hch.add_argument("cell_id")
|
|
2210
|
+
hch.add_argument("evidence_id", help="the `evidence` id `holds conflicts` printed for the value to show")
|
|
2211
|
+
hch.add_argument("--expected", help="the `evidence` id of the value the cell showed when you looked (a change since is refused)")
|
|
2212
|
+
hch.add_argument("--yes", action="store_true", help="do not ask for confirmation")
|
|
2213
|
+
hch.add_argument("--idempotency-key", help="make a retry safe")
|
|
2214
|
+
hch.set_defaults(func=cmd_holds_choose)
|
|
2025
2215
|
|
|
2026
2216
|
merge = sub.add_parser("merge", help="mark rows as duplicates of one another", parents=[common]).add_subparsers(dest="sub", required=True)
|
|
2027
2217
|
for name, helptext, func in (
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"data": [
|
|
3
|
+
{
|
|
4
|
+
"object": "conflict",
|
|
5
|
+
"cell_id": "cel_YmxhaGJsYWhibGFoYmxhaA",
|
|
6
|
+
"entity": "books",
|
|
7
|
+
"record_id": "00000000-0000-0000-0000-000000000001",
|
|
8
|
+
"record_name": "Emma",
|
|
9
|
+
"column": "author",
|
|
10
|
+
"value": "Jane Austen",
|
|
11
|
+
"value_evidence_id": "6f1c2d3e-0000-4000-8000-00000000a001",
|
|
12
|
+
"selection": {
|
|
13
|
+
"state": "conflicting",
|
|
14
|
+
"reason": "the quality check supported 2 different values; the better-sourced one is shown until someone chooses"
|
|
15
|
+
},
|
|
16
|
+
"candidates": [
|
|
17
|
+
{
|
|
18
|
+
"kind": "also_stated",
|
|
19
|
+
"evidence_id": "6f1c2d3e-0000-4000-8000-00000000a002",
|
|
20
|
+
"verdict": "supported",
|
|
21
|
+
"source_hosts": [
|
|
22
|
+
"trade.test"
|
|
23
|
+
],
|
|
24
|
+
"captured_at": "2026-10-19T10:00:00+00:00",
|
|
25
|
+
"reason": null,
|
|
26
|
+
"choosable": true,
|
|
27
|
+
"refused_by": [
|
|
28
|
+
"k_cautionb"
|
|
29
|
+
],
|
|
30
|
+
"knowledge_decided": [],
|
|
31
|
+
"knowledge_cited": [],
|
|
32
|
+
"value": "J. Austen",
|
|
33
|
+
"note": "[ALSO STATED] this run also stated this value; the cell shows another"
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
"blueprint": {
|
|
37
|
+
"id": "bp_1",
|
|
38
|
+
"name": "Library"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
],
|
|
42
|
+
"has_more": false,
|
|
43
|
+
"next_cursor": null,
|
|
44
|
+
"blueprint": {
|
|
45
|
+
"id": "bp_1",
|
|
46
|
+
"name": "Library"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -775,6 +775,35 @@ def test_run_create_forwards_each_excluded_source(monkeypatch):
|
|
|
775
775
|
assert "excluded_sources" not in seen["body"]
|
|
776
776
|
|
|
777
777
|
|
|
778
|
+
def test_run_create_sends_search_hints_and_can_drop_them(monkeypatch, capsys):
|
|
779
|
+
"""gh #2657 — `--phrase` / `--file-type` send `search_hints` (replacing the parsed ones);
|
|
780
|
+
`--no-search-hints` sends `{}`, which drops every parsed hint; neither sends nothing, which
|
|
781
|
+
keeps the parsed ones. Both kinds of flag at once is a usage error, never a guess."""
|
|
782
|
+
cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
|
|
783
|
+
seen = {}
|
|
784
|
+
|
|
785
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
786
|
+
seen["body"] = json.loads(request.content)
|
|
787
|
+
return httpx.Response(202, json={"id": "run_1", "status": "queued"})
|
|
788
|
+
|
|
789
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
790
|
+
q = ["runs", "create", "--query", "Acme, PDFs only", "--dry-run"]
|
|
791
|
+
assert main([*q, "--phrase", " Request for Information ", "--file-type", "pdf"]) == 0
|
|
792
|
+
assert seen["body"]["search_hints"] == {
|
|
793
|
+
"exact_phrases": ["Request for Information"],
|
|
794
|
+
"file_types": ["pdf"],
|
|
795
|
+
}
|
|
796
|
+
assert main([*q, "--file-type", "xlsx"]) == 0
|
|
797
|
+
assert seen["body"]["search_hints"] == {"file_types": ["xlsx"]}
|
|
798
|
+
assert main([*q, "--no-search-hints"]) == 0
|
|
799
|
+
assert seen["body"]["search_hints"] == {}
|
|
800
|
+
assert main(q) == 0
|
|
801
|
+
assert "search_hints" not in seen["body"]
|
|
802
|
+
seen.clear()
|
|
803
|
+
assert main([*q, "--no-search-hints", "--phrase", "x y"]) == 2
|
|
804
|
+
assert seen == {}, "a usage error sends nothing"
|
|
805
|
+
|
|
806
|
+
|
|
778
807
|
def test_run_create_names_the_blueprint_it_sends_and_the_one_it_got(monkeypatch, capsys):
|
|
779
808
|
"""gh #1282: `--blueprint` reaches the body, and the Blueprint the run is on is
|
|
780
809
|
printed, so a run on the wrong one is visible at once."""
|
|
@@ -1226,6 +1255,68 @@ def test_holds_release_refuses_a_malformed_cell_id_and_sends_nothing(monkeypatch
|
|
|
1226
1255
|
assert calls == []
|
|
1227
1256
|
|
|
1228
1257
|
|
|
1258
|
+
# ---------------------------------------------------------------------------
|
|
1259
|
+
# gh #2370 B2: holds conflicts | choose
|
|
1260
|
+
# ---------------------------------------------------------------------------
|
|
1261
|
+
|
|
1262
|
+
#: The shape `GET /v1/conflicts` serves; `api/tests/test_v1_choose.py` asserts the API's keys
|
|
1263
|
+
#: equal this file's, so the CLI is never tested against a shape the API stopped sending.
|
|
1264
|
+
_CONFLICTS = json.loads((Path(__file__).parent / "fixtures" / "conflict_page.json").read_text())
|
|
1265
|
+
|
|
1266
|
+
|
|
1267
|
+
def test_holds_conflicts_lists_the_value_shown_and_each_value_to_choose(monkeypatch, capsys):
|
|
1268
|
+
_signed_in(monkeypatch)
|
|
1269
|
+
seen = {}
|
|
1270
|
+
|
|
1271
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
1272
|
+
seen["path"], seen["params"] = request.url.path, dict(request.url.params)
|
|
1273
|
+
return httpx.Response(200, json=_CONFLICTS)
|
|
1274
|
+
|
|
1275
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
1276
|
+
assert main(["holds", "conflicts", "--entity", "books"]) == 0
|
|
1277
|
+
assert seen["path"] == "/v1/conflicts" and seen["params"]["entity"] == "books"
|
|
1278
|
+
text = capsys.readouterr().out
|
|
1279
|
+
item = _CONFLICTS["data"][0]
|
|
1280
|
+
# Both values, and both evidence ids, so `holds choose` can name one and `--expected` the other.
|
|
1281
|
+
assert "Jane Austen" in text and "J. Austen" in text
|
|
1282
|
+
assert item["value_evidence_id"] in text and item["candidates"][0]["evidence_id"] in text
|
|
1283
|
+
assert "kept out by your knowledge" in text, "a refused value says so"
|
|
1284
|
+
|
|
1285
|
+
|
|
1286
|
+
def test_holds_choose_posts_the_value_and_the_value_seen(monkeypatch, capsys):
|
|
1287
|
+
_signed_in(monkeypatch)
|
|
1288
|
+
seen = {}
|
|
1289
|
+
item = _CONFLICTS["data"][0]
|
|
1290
|
+
|
|
1291
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
1292
|
+
seen["path"], seen["key"] = request.url.path, request.headers.get("idempotency-key")
|
|
1293
|
+
seen["body"] = json.loads(request.content or b"{}")
|
|
1294
|
+
return httpx.Response(200, json={"object": "cell", "column": "author", "value": "J. Austen",
|
|
1295
|
+
"held": True, "change_id": CID})
|
|
1296
|
+
|
|
1297
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
1298
|
+
cand = item["candidates"][0]["evidence_id"]
|
|
1299
|
+
rc = main(["holds", "choose", CELL, cand, "--expected", item["value_evidence_id"], "--yes",
|
|
1300
|
+
"--idempotency-key", "k-4"])
|
|
1301
|
+
assert rc == 0
|
|
1302
|
+
assert (seen["path"], seen["key"]) == (f"/v1/cells/{CELL}/choose", "k-4")
|
|
1303
|
+
assert seen["body"] == {"evidence_id": cand, "expected_value_evidence_id": item["value_evidence_id"]}
|
|
1304
|
+
out_ = capsys.readouterr().out
|
|
1305
|
+
assert "stays protected" in out_ and f"changes undo {CID}" in out_
|
|
1306
|
+
|
|
1307
|
+
|
|
1308
|
+
def test_holds_choose_asks_in_a_pipe_and_refuses_a_malformed_id(monkeypatch, capsys):
|
|
1309
|
+
_signed_in(monkeypatch)
|
|
1310
|
+
calls = []
|
|
1311
|
+
monkeypatch.setattr(httpx, "Client", _mock(lambda r: calls.append(r) or httpx.Response(200)))
|
|
1312
|
+
monkeypatch.setattr("deepsieve_cli.main._tty", lambda: False)
|
|
1313
|
+
assert main(["holds", "choose", CELL, CID]) == 2
|
|
1314
|
+
assert "pass --yes" in capsys.readouterr().err
|
|
1315
|
+
assert main(["holds", "choose", CELL, "not-a-uuid", "--yes"]) == 2
|
|
1316
|
+
assert main(["holds", "choose", "../x#", CID, "--yes"]) == 2
|
|
1317
|
+
assert calls == [], "nothing sent"
|
|
1318
|
+
|
|
1319
|
+
|
|
1229
1320
|
def test_a_billable_run_without_a_blueprint_is_a_usage_error_before_any_request(monkeypatch, capsys):
|
|
1230
1321
|
"""gh #1282 B1: the API now requires `blueprint_id` on run create; the CLI says so first, with
|
|
1231
1322
|
where the id comes from, and sends nothing. A dry run may omit it (the API's own exception).
|
|
@@ -1686,10 +1777,18 @@ def test_a_quote_without_a_total_is_never_asked_as_zero(monkeypatch, capsys):
|
|
|
1686
1777
|
"The credits for your failed run were returned to the plan they came from."),
|
|
1687
1778
|
({"kind": "partially_refunded_to_plan", "run_status": "failed"},
|
|
1688
1779
|
"Part of your failed run returned nothing. The credits for that part were returned"),
|
|
1780
|
+
# gh #3045: a refund to a closed account sends nothing: never "refunded".
|
|
1781
|
+
({"kind": "not_refunded_account_closed", "run_status": "failed"},
|
|
1782
|
+
"Nothing was returned for your failed run, because your account is closed"),
|
|
1783
|
+
# gh #1858: a finished mixed run kept both halves.
|
|
1784
|
+
({"kind": "kept_mixed", "credits": 2, "amount_cents": 1000, "run_status": "completed"},
|
|
1785
|
+
"Your earlier run used 2 run credits and $10.00 of your prepaid balance. It finished"),
|
|
1689
1786
|
({"kind": "unconfirmed"}, "We can't confirm yet whether your earlier run was refunded."),
|
|
1690
1787
|
],
|
|
1691
1788
|
ids=["kept-credits", "pending", "ended-plan", "ended-period", "partial-ended-plan",
|
|
1692
|
-
"partial-ended-period", "returned-to-plan", "partial-returned-to-plan", "
|
|
1789
|
+
"partial-ended-period", "returned-to-plan", "partial-returned-to-plan", "closed-account",
|
|
1790
|
+
"kept-mixed",
|
|
1791
|
+
"unconfirmed"],
|
|
1693
1792
|
)
|
|
1694
1793
|
def test_every_refund_kind_reads_in_words_never_its_code(monkeypatch, capsys, refund, words):
|
|
1695
1794
|
"""seat-2389 L3: the app's sentences (site `refundHeadline`), never a raw enum name."""
|
|
@@ -2431,6 +2530,96 @@ def test_expected_funding_is_sent_beside_the_total(monkeypatch, capsys):
|
|
|
2431
2530
|
assert seen == [{"expected_total_cents": 1200, "expected_funding": "plan_credits"}]
|
|
2432
2531
|
|
|
2433
2532
|
|
|
2533
|
+
# gh #1858: a mixed quote's yes binds its split (`funding.prepaid_cents`) as well as its rail.
|
|
2534
|
+
|
|
2535
|
+
_MIXED_QUOTE = {
|
|
2536
|
+
**_RETRY_QUOTE,
|
|
2537
|
+
"total_price_cents": 3000,
|
|
2538
|
+
"funding": {"covered_by": "mixed", "plan_credits_needed": 3, "plan_credits_available": 2,
|
|
2539
|
+
"balance_cents": 5000, "prepaid_cents": 1000},
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
|
|
2543
|
+
def _mixed_handler(seen: list):
|
|
2544
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
2545
|
+
body = json.loads(request.content) if request.content else {}
|
|
2546
|
+
seen.append(body)
|
|
2547
|
+
if body.get("expected_prepaid_cents") != 1000 or body.get("expected_funding") != "mixed":
|
|
2548
|
+
return httpx.Response(409, json={"error": {
|
|
2549
|
+
"code": "retry_confirmation_required", "message": "confirm",
|
|
2550
|
+
"param": "expected_total_cents", "retriable": False, "details": _MIXED_QUOTE,
|
|
2551
|
+
}})
|
|
2552
|
+
return httpx.Response(202, json={
|
|
2553
|
+
"object": "run", "id": "new-run", "status": "queued",
|
|
2554
|
+
"retry": {"source_run_id": RUN, "mode": "restarted"},
|
|
2555
|
+
})
|
|
2556
|
+
|
|
2557
|
+
return handler
|
|
2558
|
+
|
|
2559
|
+
|
|
2560
|
+
def test_interactively_the_yes_binds_a_mixed_split(monkeypatch, capsys):
|
|
2561
|
+
_signed_in(monkeypatch)
|
|
2562
|
+
monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: True)
|
|
2563
|
+
prompts: list = []
|
|
2564
|
+
monkeypatch.setattr("builtins.input", lambda prompt="": prompts.append(prompt) or "y")
|
|
2565
|
+
seen: list = []
|
|
2566
|
+
monkeypatch.setattr(httpx, "Client", _mock(_mixed_handler(seen)))
|
|
2567
|
+
assert main(["runs", "retry", RUN]) == 0
|
|
2568
|
+
assert seen == [
|
|
2569
|
+
{},
|
|
2570
|
+
{"expected_total_cents": 3000, "expected_funding": "mixed", "expected_prepaid_cents": 1000},
|
|
2571
|
+
]
|
|
2572
|
+
assert "uses your plan run credits and $10.00 from your balance" in prompts[0]
|
|
2573
|
+
|
|
2574
|
+
|
|
2575
|
+
def test_in_a_pipe_the_hint_names_the_prepaid_flag_for_a_mixed_quote(monkeypatch, capsys):
|
|
2576
|
+
_signed_in(monkeypatch)
|
|
2577
|
+
monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: False)
|
|
2578
|
+
seen: list = []
|
|
2579
|
+
monkeypatch.setattr(httpx, "Client", _mock(_mixed_handler(seen)))
|
|
2580
|
+
assert main(["runs", "retry", RUN]) == 2
|
|
2581
|
+
err = capsys.readouterr().err
|
|
2582
|
+
assert "--expected-total 3000 --expected-funding mixed --expected-prepaid 1000" in err
|
|
2583
|
+
|
|
2584
|
+
|
|
2585
|
+
@pytest.mark.parametrize("interactive", [True, False], ids=["prompt", "pipe"])
|
|
2586
|
+
def test_a_mixed_quote_without_its_prepaid_part_is_never_confirmed(monkeypatch, capsys, interactive):
|
|
2587
|
+
"""gh #1858 review: a quote naming `mixed` with no `prepaid_cents` printed
|
|
2588
|
+
`--expected-prepaid None` in a pipe, and at a prompt sent a body the API refuses. It is
|
|
2589
|
+
refused here, nothing is sent after the first request, and the message says why."""
|
|
2590
|
+
_signed_in(monkeypatch)
|
|
2591
|
+
monkeypatch.setattr("deepsieve_cli.main._interactive", lambda: interactive)
|
|
2592
|
+
monkeypatch.setattr("builtins.input", lambda prompt="": "y")
|
|
2593
|
+
broken = {**_MIXED_QUOTE, "funding": {**_MIXED_QUOTE["funding"], "prepaid_cents": None}}
|
|
2594
|
+
seen: list = []
|
|
2595
|
+
|
|
2596
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
2597
|
+
seen.append(json.loads(request.content) if request.content else {})
|
|
2598
|
+
return httpx.Response(409, json={"error": {
|
|
2599
|
+
"code": "retry_confirmation_required", "message": "confirm",
|
|
2600
|
+
"param": "expected_total_cents", "retriable": False, "details": broken,
|
|
2601
|
+
}})
|
|
2602
|
+
|
|
2603
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
2604
|
+
assert main(["runs", "retry", RUN]) == 1
|
|
2605
|
+
err = capsys.readouterr().err
|
|
2606
|
+
assert "without its prepaid part" in err
|
|
2607
|
+
assert "None" not in err
|
|
2608
|
+
assert seen == [{}]
|
|
2609
|
+
|
|
2610
|
+
|
|
2611
|
+
def test_a_mixed_rail_and_its_prepaid_part_go_together(monkeypatch, capsys):
|
|
2612
|
+
_signed_in(monkeypatch)
|
|
2613
|
+
seen: list = []
|
|
2614
|
+
monkeypatch.setattr(httpx, "Client", _mock(_mixed_handler(seen)))
|
|
2615
|
+
alone = ["runs", "retry", RUN, "--expected-total", "3000", "--expected-funding", "mixed"]
|
|
2616
|
+
assert main(alone) == 2
|
|
2617
|
+
stray = ["runs", "retry", RUN, "--expected-total", "3000", "--expected-funding", "balance",
|
|
2618
|
+
"--expected-prepaid", "1000"]
|
|
2619
|
+
assert main(stray) == 2
|
|
2620
|
+
assert seen == []
|
|
2621
|
+
|
|
2622
|
+
|
|
2434
2623
|
def test_expected_funding_without_its_total_sends_nothing(monkeypatch, capsys):
|
|
2435
2624
|
_signed_in(monkeypatch)
|
|
2436
2625
|
seen: list = []
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|