oneshot-python 0.39.0__tar.gz → 0.40.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.
Files changed (51) hide show
  1. oneshot_python-0.40.0/CHANGELOG.md +7 -0
  2. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/PKG-INFO +1 -1
  3. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/README.md +15 -0
  4. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/oneshot/client.py +62 -40
  5. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/oneshot/lookup_manifest.py +73 -4
  6. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/pyproject.toml +1 -1
  7. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/tests/test_person_quote_credits.py +48 -0
  8. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/tests/test_phones_pending.py +6 -0
  9. oneshot_python-0.40.0/tests/test_product_find.py +18 -0
  10. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/.gitignore +0 -0
  11. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/oneshot/__init__.py +0 -0
  12. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/oneshot/_errors.py +0 -0
  13. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/oneshot/_types.py +0 -0
  14. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/oneshot/lookup.py +0 -0
  15. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/oneshot/physical_mail.py +0 -0
  16. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/oneshot/x402.py +0 -0
  17. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/tests/__init__.py +0 -0
  18. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/tests/conftest.py +0 -0
  19. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/tests/test_access_token.py +0 -0
  20. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/tests/test_approvals.py +0 -0
  21. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/tests/test_balance.py +0 -0
  22. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/tests/test_browser.py +0 -0
  23. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/tests/test_budgets.py +0 -0
  24. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/tests/test_charge_amount.py +0 -0
  25. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/tests/test_compute.py +0 -0
  26. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/tests/test_compute_manifest.py +0 -0
  27. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/tests/test_compute_outputs.py +0 -0
  28. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/tests/test_declared_routes.py +0 -0
  29. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/tests/test_declared_worker_reads.py +0 -0
  30. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/tests/test_domains.py +0 -0
  31. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/tests/test_domains_signed.py +0 -0
  32. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/tests/test_email_payload.py +0 -0
  33. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/tests/test_emergency_error.py +0 -0
  34. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/tests/test_hunts.py +0 -0
  35. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/tests/test_inbound_voice.py +0 -0
  36. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/tests/test_inbox_notifications.py +0 -0
  37. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/tests/test_linkedin.py +0 -0
  38. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/tests/test_lookup.py +0 -0
  39. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/tests/test_max_cost_header.py +0 -0
  40. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/tests/test_paid_call_proof.py +0 -0
  41. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/tests/test_payment_rejection.py +0 -0
  42. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/tests/test_physical_mail.py +0 -0
  43. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/tests/test_poll_backoff.py +0 -0
  44. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/tests/test_request_id.py +0 -0
  45. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/tests/test_signed_job_and_analytics.py +0 -0
  46. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/tests/test_tag_receipt_value.py +0 -0
  47. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/tests/test_top_up_credits.py +0 -0
  48. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/tests/test_update_build.py +0 -0
  49. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/tests/test_wait_false.py +0 -0
  50. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/tests/test_x402.py +0 -0
  51. {oneshot_python-0.39.0 → oneshot_python-0.40.0}/uv.lock +0 -0
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## 0.40.0 — breaking release (#1336)
4
+
5
+ - Renamed `commerce_search()` / `acommerce_search()` → `product_find()` / `aproduct_find()`, without aliases.
6
+ - Product discovery now calls `POST /v1/tools/find/product` (price key `find/product`), replacing `POST /v1/tools/commerce/search` (`commerce/search`). Input, results and Purchase compatibility are unchanged.
7
+ - Release together with the person/company and other tool renames in #1334 and #1382; publish only with the coordinated server release.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: oneshot-python
3
- Version: 0.39.0
3
+ Version: 0.40.0
4
4
  Summary: Core Python SDK for the OneShot API — HTTP client with x402 payment handling — 48 tools
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
@@ -343,3 +343,18 @@ async form).
343
343
  ## License
344
344
 
345
345
  MIT
346
+
347
+ ## Optional phone lookup
348
+
349
+ Phone lookup is off by default. Source-returned numbers and published business or record numbers still appear. `include_phone=True` adds $0.10 × the requested search limit (default 100 → $10), or $0.10 for profile/dossier, for up to that many people with no guaranteed hit. Fallbacks and callbacks cost nothing extra. A completed lookup with no phone match is charged; a failed job is refunded.
350
+
351
+ `max_cost` and per-transaction budgets apply to the full quote before authorization, even when credits cover it. Wallet and access-token sessions both obtain a quote before execution and retry with `x-quote-id`.
352
+
353
+ ```python
354
+ people = client.person_find(job_titles=["CTO"], limit=3, include_phone=True, max_cost=1, wait_for_phones=True)
355
+ profile = client.person_profile(email="jane@example.com", include_phone=True, max_cost=1, wait_for_phones=True)
356
+ dossier = client.person_dossier(email="jane@example.com", include_phone=True, max_cost=1, wait_for_phones=True, timeout_sec=600)
357
+ # Async equivalents: await client.aperson_find(...), aperson_profile(...), aperson_dossier(...).
358
+ ```
359
+
360
+ `wait=False` returns the job and receipt IDs immediately. `wait_for_phones=True` keeps polling for pending phone callbacks after completion; it does not buy phones by itself. On timeout, the latest result can still have `phones_pending: True`.
@@ -60,7 +60,7 @@ try:
60
60
 
61
61
  SDK_VERSION = _pkg_version("oneshot-python")
62
62
  except Exception: # pragma: no cover - editable/source runs without dist metadata
63
- SDK_VERSION = "0.39.0"
63
+ SDK_VERSION = "0.40.0"
64
64
 
65
65
  _BASE_URL = "https://win.oneshotagent.com"
66
66
  _HUNT_BASE_URL = "https://api.soulhunt.ai"
@@ -917,6 +917,7 @@ class OneShotClient:
917
917
  on the ``x-quote-id`` header from the very first attempt, since
918
918
  quote-based routes reject before the 402 dance even starts without it.
919
919
  """
920
+ person_quote = endpoint in ("/v1/tools/find/person", "/v1/tools/lookup/person-profile", "/v1/tools/lookup/person-dossier")
920
921
  memo = payload.get("memo")
921
922
  if memo is not None:
922
923
  if not isinstance(memo, str) or not memo.strip():
@@ -999,7 +1000,7 @@ class OneShotClient:
999
1000
  "processing",
1000
1001
  ):
1001
1002
  if not wait:
1002
- return result if endpoint.endswith(("/linkedin/invite", "/linkedin/invite/withdraw")) else {"request_id": result["request_id"], "status": result["status"]}
1003
+ return result if person_quote or endpoint.endswith(("/linkedin/invite", "/linkedin/invite/withdraw")) else {"request_id": result["request_id"], "status": result["status"]}
1003
1004
  return await self._poll_job(
1004
1005
  client,
1005
1006
  result["request_id"],
@@ -1039,6 +1040,9 @@ class OneShotClient:
1039
1040
  f"Quote ${total} exceeds max_cost ${max_cost}"
1040
1041
  )
1041
1042
 
1043
+ if person_quote and total is not None:
1044
+ self._assert_within_budget(total)
1045
+
1042
1046
  self._log(f"Payment required: {payment_request['amount']} USDC")
1043
1047
 
1044
1048
  if self._access_token:
@@ -1068,7 +1072,7 @@ class OneShotClient:
1068
1072
  result = resp2.json()
1069
1073
  if isinstance(result, dict) and result.get("request_id") and result.get("status") in ("pending", "processing"):
1070
1074
  if not wait:
1071
- return result if endpoint.endswith(("/linkedin/invite", "/linkedin/invite/withdraw")) else {"request_id": result["request_id"], "status": result["status"]}
1075
+ return result if person_quote or endpoint.endswith(("/linkedin/invite", "/linkedin/invite/withdraw")) else {"request_id": result["request_id"], "status": result["status"]}
1072
1076
  return await self._poll_job(
1073
1077
  client, result["request_id"], timeout_sec,
1074
1078
  wait_for_phones=wait_for_phones, phone_timeout_sec=phone_timeout_sec,
@@ -1158,7 +1162,7 @@ class OneShotClient:
1158
1162
  ):
1159
1163
  self._log(f"Job queued: {result['request_id']}")
1160
1164
  if not wait:
1161
- return result if endpoint.endswith(("/linkedin/invite", "/linkedin/invite/withdraw")) else {"request_id": result["request_id"], "status": result["status"]}
1165
+ return result if person_quote or endpoint.endswith(("/linkedin/invite", "/linkedin/invite/withdraw")) else {"request_id": result["request_id"], "status": result["status"]}
1162
1166
  return await self._poll_job(
1163
1167
  client,
1164
1168
  result["request_id"],
@@ -2073,21 +2077,33 @@ class OneShotClient:
2073
2077
  approval_id = kwargs.pop("approval_id", None)
2074
2078
  return await self.acall_tool("/v1/tools/sms/send", {"message": message, "to_number": to_number, **kwargs}, approval_id=approval_id)
2075
2079
 
2076
- def person_find(self, **kwargs: Any) -> Any:
2077
- """Search for people by job title, company, etc. Blocking."""
2078
- return self.call_tool("/v1/tools/find/person", kwargs)
2079
-
2080
- async def aperson_find(self, **kwargs: Any) -> Any:
2081
- """Search for people by job title, company, etc. Async."""
2082
- return await self.acall_tool("/v1/tools/find/person", kwargs)
2083
-
2084
- def person_profile(self, **kwargs: Any) -> Any:
2085
- """Enrich a person's profile from LinkedIn URL, email, or name. Blocking."""
2086
- return self.call_tool("/v1/tools/lookup/person-profile", kwargs)
2087
-
2088
- async def aperson_profile(self, **kwargs: Any) -> Any:
2089
- """Enrich a person's profile from LinkedIn URL, email, or name. Async."""
2090
- return await self.acall_tool("/v1/tools/lookup/person-profile", kwargs)
2080
+ @staticmethod
2081
+ def _person_call_options(kwargs: dict[str, Any]) -> dict[str, Any]:
2082
+ """Keep SDK controls out of the canonical quoted input."""
2083
+ return {key: kwargs.pop(key) for key in (
2084
+ "max_cost", "timeout_sec", "wait", "wait_for_phones",
2085
+ "phone_timeout_sec", "idempotency_key", "approval_id",
2086
+ ) if key in kwargs}
2087
+
2088
+ def person_find(self, *, include_phone: bool = False, **kwargs: Any) -> Any:
2089
+ """Phone lookup is off by default; opt-in uses the limit (default 100) and the per-person add-on on the pricing page, with no hit guaranteed."""
2090
+ options = self._person_call_options(kwargs)
2091
+ return self.call_tool("/v1/tools/find/person", {**kwargs, "include_phone": include_phone}, **options)
2092
+
2093
+ async def aperson_find(self, *, include_phone: bool = False, **kwargs: Any) -> Any:
2094
+ """Phone lookup is off by default; opt-in uses the limit (default 100) and the per-person add-on on the pricing page, with no hit guaranteed."""
2095
+ options = self._person_call_options(kwargs)
2096
+ return await self.acall_tool("/v1/tools/find/person", {**kwargs, "include_phone": include_phone}, **options)
2097
+
2098
+ def person_profile(self, *, include_phone: bool = False, **kwargs: Any) -> Any:
2099
+ """Phone lookup is off by default; opt-in uses the add-on on the pricing page for one person, with no hit guaranteed."""
2100
+ options = self._person_call_options(kwargs)
2101
+ return self.call_tool("/v1/tools/lookup/person-profile", {**kwargs, "include_phone": include_phone}, **options)
2102
+
2103
+ async def aperson_profile(self, *, include_phone: bool = False, **kwargs: Any) -> Any:
2104
+ """Phone lookup is off by default; opt-in uses the add-on on the pricing page for one person, with no hit guaranteed."""
2105
+ options = self._person_call_options(kwargs)
2106
+ return await self.acall_tool("/v1/tools/lookup/person-profile", {**kwargs, "include_phone": include_phone}, **options)
2091
2107
 
2092
2108
  def company_find(self, **kwargs: Any) -> Any:
2093
2109
  """Search for companies by name, domain, industry, location, size, etc. Blocking."""
@@ -2159,13 +2175,15 @@ class OneShotClient:
2159
2175
  """Verify email deliverability. Async."""
2160
2176
  return await self.acall_tool("/v1/tools/lookup/person-email-status", {"email": email})
2161
2177
 
2162
- def person_dossier(self, **kwargs: Any) -> Any:
2163
- """Deep research on a person. Blocking."""
2164
- return self.call_tool("/v1/tools/lookup/person-dossier", kwargs)
2178
+ def person_dossier(self, *, include_phone: bool = False, **kwargs: Any) -> Any:
2179
+ """Phone lookup is off by default; opt-in uses the add-on on the pricing page for one person, with no hit guaranteed."""
2180
+ options = self._person_call_options(kwargs)
2181
+ return self.call_tool("/v1/tools/lookup/person-dossier", {**kwargs, "include_phone": include_phone}, **options)
2165
2182
 
2166
- async def aperson_dossier(self, **kwargs: Any) -> Any:
2167
- """Deep research on a person. Async."""
2168
- return await self.acall_tool("/v1/tools/lookup/person-dossier", kwargs)
2183
+ async def aperson_dossier(self, *, include_phone: bool = False, **kwargs: Any) -> Any:
2184
+ """Phone lookup is off by default; opt-in uses the add-on on the pricing page for one person, with no hit guaranteed."""
2185
+ options = self._person_call_options(kwargs)
2186
+ return await self.acall_tool("/v1/tools/lookup/person-dossier", {**kwargs, "include_phone": include_phone}, **options)
2169
2187
 
2170
2188
  def person_social_accounts(self, **kwargs: Any) -> Any:
2171
2189
  """Discover social media profiles. Blocking."""
@@ -2207,13 +2225,13 @@ class OneShotClient:
2207
2225
  """Get a person's social interactions. Async."""
2208
2226
  return await self.acall_tool("/v1/tools/lookup/person-followers", {"social_media_url": social_media_url, **kwargs})
2209
2227
 
2210
- def commerce_search(self, query: str, *, limit: int = 10, **kwargs: Any) -> Any:
2228
+ def product_find(self, query: str, *, limit: int = 10, **kwargs: Any) -> Any:
2211
2229
  """Search for products. Blocking."""
2212
- return self.call_tool("/v1/tools/commerce/search", {"query": query, "limit": limit, **kwargs})
2230
+ return self.call_tool("/v1/tools/find/product", {"query": query, "limit": limit, **kwargs})
2213
2231
 
2214
- async def acommerce_search(self, query: str, *, limit: int = 10, **kwargs: Any) -> Any:
2232
+ async def aproduct_find(self, query: str, *, limit: int = 10, **kwargs: Any) -> Any:
2215
2233
  """Search for products. Async."""
2216
- return await self.acall_tool("/v1/tools/commerce/search", {"query": query, "limit": limit, **kwargs})
2234
+ return await self.acall_tool("/v1/tools/find/product", {"query": query, "limit": limit, **kwargs})
2217
2235
 
2218
2236
  def commerce_buy(self, product_url: str, shipping_address: dict[str, Any], **kwargs: Any) -> Any:
2219
2237
  """Purchase a product. Blocking."""
@@ -2515,23 +2533,27 @@ class OneShotClient:
2515
2533
  @staticmethod
2516
2534
  def _is_phones_pending(result: Any) -> bool:
2517
2535
  """Detects whether a result is awaiting Apollo's async phone-reveal
2518
- webhook. Accepts legacy flat results and person_dossier's
2519
- ``result.profile.data.phones_pending``.
2536
+ webhook. Accepts worker search rows, profile objects, dossier sections
2537
+ and legacy flat results.
2520
2538
 
2521
2539
  Mirrors the TS SDK's _isPhonesPending — kept in lockstep so the worker
2522
2540
  contract works for both SDKs.
2523
2541
  """
2524
2542
  if not isinstance(result, dict):
2525
2543
  return False
2526
- if result.get("phones_pending") is True:
2527
- return True
2528
- inner = result.get("result")
2529
- if isinstance(inner, dict) and inner.get("phones_pending") is True:
2530
- return True
2531
- profile = inner.get("profile") if isinstance(inner, dict) else None
2532
- data = profile.get("data") if isinstance(profile, dict) else None
2533
- if isinstance(data, dict) and data.get("phones_pending") is True:
2534
- return True
2544
+ def pending(value: Any) -> bool:
2545
+ return isinstance(value, dict) and value.get("phones_pending") is True
2546
+
2547
+ for body in (result, result.get("result")):
2548
+ if not isinstance(body, dict):
2549
+ continue
2550
+ profile = body.get("profile")
2551
+ data = profile.get("data") if isinstance(profile, dict) else None
2552
+ rows = body.get("results")
2553
+ if pending(body) or pending(profile) or pending(data):
2554
+ return True
2555
+ if isinstance(rows, list) and any(pending(row) for row in rows):
2556
+ return True
2535
2557
  return False
2536
2558
 
2537
2559
  async def _poll_for_phones(
@@ -563,6 +563,76 @@ LOOKUP_MANIFEST: list[dict[str, Any]] = [
563
563
  "longitude": -95.3698,
564
564
  },
565
565
  },
566
+ {
567
+ "name": "market_freight_rate",
568
+ "path": "/v1/tools/lookup/market-freight-rate",
569
+ "title": "Freight Rate Quote",
570
+ "description": "Quote the rate to move freight between two US ZIP codes on a pickup date, by mode: ltl shares a trailer by the pallet, ftl is a whole 53-foot dry van, van is a cargo van and box_truck is a 26-foot box truck. The answer is one carrier's quote for that lane: the rate for the whole shipment with every included service, the transit time, the pickup and delivery dates, when the quote lapses and whether it is firm or indicative. Use it to check a billed freight charge against the lane's going rate. For ltl give the pallets, the weight per pallet and the pallet dimensions; for van and box_truck give the pallets and the weight per pallet; for ftl the lane and date are enough. A lane the mode does not serve is refused as invalid input: try another mode. Covers US lanes. Nothing is booked.",
571
+ "input_schema": {
572
+ "type": "object",
573
+ "properties": {
574
+ "origin_zip": {
575
+ "type": "string",
576
+ "description": "Where the freight is collected. 5-digit US ZIP code.",
577
+ },
578
+ "destination_zip": {
579
+ "type": "string",
580
+ "description": "Where the freight is delivered. 5-digit US ZIP code.",
581
+ },
582
+ "mode": {
583
+ "type": "string",
584
+ "enum": [
585
+ "ltl",
586
+ "ftl",
587
+ "van",
588
+ "box_truck",
589
+ ],
590
+ "description": "How the freight moves. Picks the quote asked for; there is no default",
591
+ },
592
+ "pickup_date": {
593
+ "type": "string",
594
+ "description": "The day the freight is collected: today (UTC) or later. YYYY-MM-DD.",
595
+ },
596
+ "pallets": {
597
+ "type": "integer",
598
+ "description": "How many pallets. Required for ltl, van (up to 3) and box_truck (up to 12); not needed for ftl. At least 1.",
599
+ },
600
+ "weight_lbs_per_pallet": {
601
+ "type": "integer",
602
+ "description": "The weight of each pallet in pounds. Required for ltl, van (3,500 lb in all) and box_truck (10,000 lb in all); not needed for ftl. At least 1.",
603
+ },
604
+ "length_in": {
605
+ "type": "integer",
606
+ "description": "The length of each pallet in inches. Required for ltl; ignored for other modes. At least 1.",
607
+ },
608
+ "width_in": {
609
+ "type": "integer",
610
+ "description": "The width of each pallet in inches. Required for ltl; ignored for other modes. At least 1.",
611
+ },
612
+ "height_in": {
613
+ "type": "integer",
614
+ "description": "The height of each pallet in inches, up to 85. Required for ltl; ignored for other modes. At least 1.",
615
+ },
616
+ },
617
+ "required": [
618
+ "origin_zip",
619
+ "destination_zip",
620
+ "mode",
621
+ "pickup_date",
622
+ ],
623
+ },
624
+ "example": {
625
+ "origin_zip": "90012",
626
+ "destination_zip": "94105",
627
+ "mode": "ltl",
628
+ "pickup_date": "2026-10-20",
629
+ "pallets": 2,
630
+ "weight_lbs_per_pallet": 800,
631
+ "length_in": 48,
632
+ "width_in": 40,
633
+ "height_in": 48,
634
+ },
635
+ },
566
636
  {
567
637
  "name": "person_health_provider",
568
638
  "path": "/v1/tools/lookup/person-health-provider",
@@ -1298,7 +1368,7 @@ LOOKUP_MANIFEST: list[dict[str, Any]] = [
1298
1368
  "method": "POST",
1299
1369
  "pricing": "fixed",
1300
1370
  "title": "Changes",
1301
- "description": "What changed about one entity since a date, from the observations OneShot recorded: each change has the earlier value, the later value, when it was seen and the source. Give `attribute` (the name of a data tool that keeps history), `since`, and exactly one of `key` and `input`. A window with no changes is a completed, charged result. Paid per call.",
1371
+ "description": "What changed about one entity since a date, from the observations OneShot recorded: each change has the earlier value, the later value, when it was seen and the source. Give `attribute` (the name of a data tool that keeps history), `since`, and exactly one of `key` and `input`. For healthcare_exclusions, input selects a held monthly release and optional exact name/NPI/UPIN; returns row membership and full-row additions/removals, with coverage. A corrected row is removal plus addition, never person identity. A window with no changes is a completed, charged result. Paid per call.",
1302
1372
  "input_schema": {
1303
1373
  "type": "object",
1304
1374
  "properties": {
@@ -1312,7 +1382,7 @@ LOOKUP_MANIFEST: list[dict[str, Any]] = [
1312
1382
  },
1313
1383
  "input": {
1314
1384
  "type": "object",
1315
- "description": "Input a search was called with, for a stored search. Give exactly one of key and input",
1385
+ "description": "Stored search input; healthcare_exclusions accepts {entity: person|company, release: YYYY-MM, name? | npi? | upin?, previous_release?, after?}. Exact row membership, not person identity. Give exactly one of key and input",
1316
1386
  },
1317
1387
  "source": {
1318
1388
  "type": "string",
@@ -1320,7 +1390,7 @@ LOOKUP_MANIFEST: list[dict[str, Any]] = [
1320
1390
  },
1321
1391
  "since": {
1322
1392
  "type": "string",
1323
- "description": "First day of the window, YYYY-MM-DD",
1393
+ "description": "First day of the window, YYYY-MM-DD; optional for healthcare_exclusions monthly releases",
1324
1394
  },
1325
1395
  "until": {
1326
1396
  "type": "string",
@@ -1333,7 +1403,6 @@ LOOKUP_MANIFEST: list[dict[str, Any]] = [
1333
1403
  },
1334
1404
  "required": [
1335
1405
  "attribute",
1336
- "since",
1337
1406
  ],
1338
1407
  },
1339
1408
  "example": {
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "oneshot-python"
3
- version = "0.39.0"
3
+ version = "0.40.0"
4
4
  description = "Core Python SDK for the OneShot API — HTTP client with x402 payment handling — 48 tools"
5
5
  readme = {text = "Core Python SDK for the OneShot API", content-type = "text/plain"}
6
6
  license = "MIT"
@@ -80,3 +80,51 @@ async def test_a_person_quote_the_credits_do_not_cover_signs_exactly_the_charge(
80
80
  assert len(sent) == 2
81
81
  assert signed.call_args.kwargs["amount"] == "0.101000"
82
82
  assert sent[1]["x-quote-id"] == "q-person"
83
+
84
+
85
+ @pytest.mark.asyncio
86
+ @pytest.mark.parametrize("method", ["aperson_find", "aperson_profile", "aperson_dossier"])
87
+ async def test_person_quote_caps_apply_to_full_price_before_wallet_or_credit_retry(method):
88
+ from oneshot._errors import OneShotError
89
+
90
+ for session in ("wallet", "wallet-credits", "token"):
91
+ for cap in ("max_cost", "budget"):
92
+ if session == "token" and cap == "budget":
93
+ continue # Access-token budgets are configured on the server.
94
+ client = (OneShotClient(access_token="oneshot_" + "ab" * 32, address=MERCHANT)
95
+ if session == "token" else OneShotClient(TEST_PRIVATE_KEY, **(
96
+ {"budgets": {"per_transaction": 0.05}} if cap == "budget" else {})))
97
+ client._budget_synced = True
98
+ http = MagicMock()
99
+ http.post = AsyncMock(return_value=_quote("0.101000" if session == "wallet" else "0.000000"))
100
+ http.__aenter__ = AsyncMock(return_value=http)
101
+ http.__aexit__ = AsyncMock(return_value=False)
102
+ with patch("oneshot.client.httpx.AsyncClient", return_value=http), \
103
+ patch("oneshot.client.parse_payment_required", return_value={"accepted": {"amount": "0"}}), \
104
+ patch("oneshot.client.sign_payment_authorization") as sign:
105
+ with pytest.raises(OneShotError, match="exceeds"):
106
+ await getattr(client, method)(name="Jane", include_phone=True, **({"max_cost": 0.05} if cap == "max_cost" else {}))
107
+ sign.assert_not_called()
108
+ assert http.post.await_count == 1
109
+ assert "max_cost" not in http.post.call_args.kwargs["json"]
110
+
111
+
112
+ @pytest.mark.asyncio
113
+ @pytest.mark.parametrize("method", ["aperson_find", "aperson_profile", "aperson_dossier"])
114
+ async def test_person_wait_false_preserves_receipt_and_keeps_controls_out_of_quote(method):
115
+ for token in (False, True):
116
+ client = (OneShotClient(access_token="oneshot_" + "ab" * 32, address=MERCHANT)
117
+ if token else OneShotClient(TEST_PRIVATE_KEY))
118
+ client._budget_synced = True
119
+ http = MagicMock()
120
+ http.post = AsyncMock(side_effect=[_quote("0.000000"), _resp(202, {
121
+ "request_id": "job-1", "receipt_id": "receipt-1", "status": "processing",
122
+ })])
123
+ http.__aenter__ = AsyncMock(return_value=http)
124
+ http.__aexit__ = AsyncMock(return_value=False)
125
+ with patch("oneshot.client.httpx.AsyncClient", return_value=http), \
126
+ patch("oneshot.client.parse_payment_required", return_value={"accepted": {"amount": "0"}}):
127
+ result = await getattr(client, method)(name="Jane", include_phone=True, wait=False, wait_for_phones=True, phone_timeout_sec=1)
128
+ assert result["receipt_id"] == "receipt-1"
129
+ assert http.post.call_args.kwargs["json"] == {"name": "Jane", "include_phone": True}
130
+ assert http.post.call_args.kwargs["headers"]["x-quote-id"] == "q-person"
@@ -351,3 +351,9 @@ def test_dossier_profile_section_retains_strict_phone_wait_detection():
351
351
  for value in (True, False, "true", None):
352
352
  result = {"result": {"profile": {"state": "found", "data": {"phones_pending": value}}}}
353
353
  assert OneShotClient._is_phones_pending(result) is (value is True)
354
+
355
+
356
+ def test_worker_profile_and_search_pending_shapes():
357
+ for value in (True, False, None, "true", 1):
358
+ assert OneShotClient._is_phones_pending({"status": "completed", "profile": {"phones_pending": value}}) is (value is True)
359
+ assert OneShotClient._is_phones_pending({"status": "completed", "results": [None, {}, {"phones_pending": value}]}) is (value is True)
@@ -0,0 +1,18 @@
1
+ """#1336: sync/async product discovery uses the new name without payload changes."""
2
+ from unittest.mock import AsyncMock, patch
3
+ import pytest
4
+ from oneshot import OneShotClient
5
+
6
+
7
+ @pytest.mark.asyncio
8
+ async def test_product_find_sync_and_async_have_no_old_alias():
9
+ client = OneShotClient("0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80")
10
+ result = {"products": [{"product_url": "https://shop.example/products/desk"}], "count": 1}
11
+ assert not hasattr(client, "commerce_search")
12
+ assert not hasattr(client, "acommerce_search")
13
+ with patch.object(OneShotClient, "call_tool", return_value=result) as call:
14
+ assert client.product_find("desk") is result
15
+ call.assert_called_once_with("/v1/tools/find/product", {"query": "desk", "limit": 10})
16
+ with patch.object(OneShotClient, "acall_tool", new_callable=AsyncMock, return_value=result) as call:
17
+ assert await client.aproduct_find("desk", limit=5) is result
18
+ call.assert_awaited_once_with("/v1/tools/find/product", {"query": "desk", "limit": 5})
File without changes