oneshot-python 0.40.0__tar.gz → 0.40.1__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 (53) hide show
  1. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/CHANGELOG.md +6 -0
  2. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/PKG-INFO +1 -1
  3. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/README.md +6 -1
  4. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/oneshot/client.py +130 -77
  5. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/oneshot/lookup_manifest.py +2 -2
  6. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/pyproject.toml +1 -1
  7. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/tests/conftest.py +2 -2
  8. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/tests/test_access_token.py +2 -4
  9. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/tests/test_email_payload.py +3 -8
  10. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/tests/test_hunts.py +21 -0
  11. oneshot_python-0.40.1/tests/test_person_quote_id.py +87 -0
  12. oneshot_python-0.40.1/tests/test_sync_without_loop.py +72 -0
  13. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/.gitignore +0 -0
  14. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/oneshot/__init__.py +0 -0
  15. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/oneshot/_errors.py +0 -0
  16. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/oneshot/_types.py +0 -0
  17. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/oneshot/lookup.py +0 -0
  18. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/oneshot/physical_mail.py +0 -0
  19. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/oneshot/x402.py +0 -0
  20. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/tests/__init__.py +0 -0
  21. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/tests/test_approvals.py +0 -0
  22. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/tests/test_balance.py +0 -0
  23. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/tests/test_browser.py +0 -0
  24. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/tests/test_budgets.py +0 -0
  25. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/tests/test_charge_amount.py +0 -0
  26. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/tests/test_compute.py +0 -0
  27. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/tests/test_compute_manifest.py +0 -0
  28. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/tests/test_compute_outputs.py +0 -0
  29. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/tests/test_declared_routes.py +0 -0
  30. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/tests/test_declared_worker_reads.py +0 -0
  31. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/tests/test_domains.py +0 -0
  32. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/tests/test_domains_signed.py +0 -0
  33. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/tests/test_emergency_error.py +0 -0
  34. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/tests/test_inbound_voice.py +0 -0
  35. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/tests/test_inbox_notifications.py +0 -0
  36. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/tests/test_linkedin.py +0 -0
  37. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/tests/test_lookup.py +0 -0
  38. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/tests/test_max_cost_header.py +0 -0
  39. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/tests/test_paid_call_proof.py +0 -0
  40. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/tests/test_payment_rejection.py +0 -0
  41. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/tests/test_person_quote_credits.py +0 -0
  42. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/tests/test_phones_pending.py +0 -0
  43. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/tests/test_physical_mail.py +0 -0
  44. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/tests/test_poll_backoff.py +0 -0
  45. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/tests/test_product_find.py +0 -0
  46. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/tests/test_request_id.py +0 -0
  47. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/tests/test_signed_job_and_analytics.py +0 -0
  48. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/tests/test_tag_receipt_value.py +0 -0
  49. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/tests/test_top_up_credits.py +0 -0
  50. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/tests/test_update_build.py +0 -0
  51. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/tests/test_wait_false.py +0 -0
  52. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/tests/test_x402.py +0 -0
  53. {oneshot_python-0.40.0 → oneshot_python-0.40.1}/uv.lock +0 -0
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.40.1
4
+
5
+ - Restore synchronous pause/resume calls when no event loop is current.
6
+ - Send person quote IDs through the request header, excluding them from tool payloads.
7
+ - Describe Changes queries for published dataset attributes.
8
+
3
9
  ## 0.40.0 — breaking release (#1336)
4
10
 
5
11
  - Renamed `commerce_search()` / `acommerce_search()` → `product_find()` / `aproduct_find()`, without aliases.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: oneshot-python
3
- Version: 0.40.0
3
+ Version: 0.40.1
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
@@ -34,6 +34,11 @@ balance = client.call_free_get("/v1/wallet/balance")
34
34
 
35
35
  ### Async
36
36
 
37
+ Blocking `OneShotClient` methods reuse the current open event loop, or run on a
38
+ temporary loop when none is available (including after `asyncio.run()`). Inside
39
+ a running event loop, use the async method instead; blocking calls raise an
40
+ error naming the async method to await.
41
+
37
42
  ```python
38
43
  import asyncio
39
44
  from oneshot import OneShotClient
@@ -346,7 +351,7 @@ MIT
346
351
 
347
352
  ## Optional phone lookup
348
353
 
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.
354
+ Phone lookup is off by default. Source-returned numbers and published business or record numbers still appear. `include_phone=True` adds the per-person add-on listed on the pricing page × the requested search limit (default 100) for a search, or once 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
355
 
351
356
  `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
357
 
@@ -13,7 +13,7 @@ import json
13
13
  import re
14
14
  import time
15
15
  from decimal import Decimal
16
- from typing import Any, Optional
16
+ from typing import Any, Coroutine, Optional, TypeVar
17
17
  from urllib.parse import quote
18
18
 
19
19
  import httpx
@@ -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.40.0"
63
+ SDK_VERSION = "0.40.1"
64
64
 
65
65
  _BASE_URL = "https://win.oneshotagent.com"
66
66
  _HUNT_BASE_URL = "https://api.soulhunt.ai"
@@ -77,6 +77,31 @@ _POLL_RETRY_BASE = 2.0 # seconds, transient-error retry backoff base
77
77
  _MAX_POLL_RETRIES = 3
78
78
  _BUILD_TRANSPORT_KWARGS = ("max_cost", "timeout_sec", "wait", "approval_id", "idempotency_key")
79
79
 
80
+ _T = TypeVar("_T")
81
+
82
+
83
+ def _run_sync(coro: Coroutine[Any, Any, _T], async_method: str) -> _T:
84
+ """Run a blocking call on the current open loop, or a temporary loop."""
85
+ try:
86
+ asyncio.get_running_loop()
87
+ except RuntimeError:
88
+ pass
89
+ else:
90
+ # The wrapper already created this coroutine; do not leave it unawaited.
91
+ coro.close()
92
+ raise RuntimeError(
93
+ f"Cannot make a blocking SDK call inside a running event loop; "
94
+ f"use await client.{async_method}(...) instead."
95
+ )
96
+
97
+ try:
98
+ loop = asyncio.get_event_loop()
99
+ except RuntimeError:
100
+ loop = None
101
+ if loop is None or loop.is_closed():
102
+ return asyncio.run(coro)
103
+ return loop.run_until_complete(coro)
104
+
80
105
 
81
106
  def _build_email_payload(
82
107
  to: Optional[str],
@@ -486,8 +511,9 @@ class OneShotClient:
486
511
  debug: bool = False,
487
512
  ) -> "OneShotClient":
488
513
  """Blocking wrapper for :meth:`acreate`."""
489
- return asyncio.get_event_loop().run_until_complete(
490
- cls.acreate(access_token=access_token, address=address, base_url=base_url, debug=debug)
514
+ return _run_sync(
515
+ cls.acreate(access_token=access_token, address=address, base_url=base_url, debug=debug),
516
+ "acreate",
491
517
  )
492
518
 
493
519
  def _assert_wallet_session(self, action: str) -> None:
@@ -501,7 +527,7 @@ class OneShotClient:
501
527
  return self._unwrap_data(await self.acall_free_post("/v1/agents/me/access-tokens", {"name": name} if name else {}, scope="write"))
502
528
 
503
529
  def create_access_token(self, name: Optional[str] = None) -> AccessTokenCreated:
504
- return asyncio.get_event_loop().run_until_complete(self.acreate_access_token(name))
530
+ return _run_sync(self.acreate_access_token(name), "acreate_access_token")
505
531
 
506
532
  async def alist_access_tokens(self, *, active_only: bool = False) -> list[AccessTokenInfo]:
507
533
  self._assert_wallet_session("list access tokens")
@@ -510,14 +536,14 @@ class OneShotClient:
510
536
  return data.get("tokens", data) if isinstance(data, dict) else data
511
537
 
512
538
  def list_access_tokens(self, *, active_only: bool = False) -> list[AccessTokenInfo]:
513
- return asyncio.get_event_loop().run_until_complete(self.alist_access_tokens(active_only=active_only))
539
+ return _run_sync(self.alist_access_tokens(active_only=active_only), "alist_access_tokens")
514
540
 
515
541
  async def arevoke_access_token(self, token_id: str) -> None:
516
542
  self._assert_wallet_session("revoke access tokens")
517
543
  await self.acall_free_delete(f"/v1/agents/me/access-tokens/{quote(token_id, safe='')}", scope="write")
518
544
 
519
545
  def revoke_access_token(self, token_id: str) -> None:
520
- asyncio.get_event_loop().run_until_complete(self.arevoke_access_token(token_id))
546
+ _run_sync(self.arevoke_access_token(token_id), "arevoke_access_token")
521
547
 
522
548
  async def aprovision_voice_number(self) -> dict[str, Any]:
523
549
  """Get this agent a voice number without placing a call.
@@ -529,14 +555,14 @@ class OneShotClient:
529
555
  return self._unwrap_data(await self.acall_free_post("/v1/tools/voice/numbers", {}, scope="write"))
530
556
 
531
557
  def provision_voice_number(self) -> dict[str, Any]:
532
- return asyncio.get_event_loop().run_until_complete(self.aprovision_voice_number())
558
+ return _run_sync(self.aprovision_voice_number(), "aprovision_voice_number")
533
559
 
534
560
  async def avoice_numbers(self) -> dict[str, Any]:
535
561
  """The agent's phone numbers, from ``provision_voice_number`` or the first outbound voice call."""
536
562
  return self._unwrap_data(await self.acall_free_get("/v1/tools/voice/numbers"))
537
563
 
538
564
  def voice_numbers(self) -> dict[str, Any]:
539
- return asyncio.get_event_loop().run_until_complete(self.avoice_numbers())
565
+ return _run_sync(self.avoice_numbers(), "avoice_numbers")
540
566
 
541
567
  async def aset_inbound_voice(self, phone_number_id: str, config: dict[str, Any]) -> dict[str, Any]:
542
568
  """Answer calls to one of the agent's numbers (replaces the whole config).
@@ -548,14 +574,14 @@ class OneShotClient:
548
574
  return self._unwrap_data(await self.acall_free_put(path, config))
549
575
 
550
576
  def set_inbound_voice(self, phone_number_id: str, config: dict[str, Any]) -> dict[str, Any]:
551
- return asyncio.get_event_loop().run_until_complete(self.aset_inbound_voice(phone_number_id, config))
577
+ return _run_sync(self.aset_inbound_voice(phone_number_id, config), "aset_inbound_voice")
552
578
 
553
579
  async def aget_inbound_voice(self, phone_number_id: str) -> dict[str, Any]:
554
580
  path = f"/v1/tools/voice/numbers/{quote(phone_number_id, safe='')}/inbound"
555
581
  return self._unwrap_data(await self.acall_free_get(path))
556
582
 
557
583
  def get_inbound_voice(self, phone_number_id: str) -> dict[str, Any]:
558
- return asyncio.get_event_loop().run_until_complete(self.aget_inbound_voice(phone_number_id))
584
+ return _run_sync(self.aget_inbound_voice(phone_number_id), "aget_inbound_voice")
559
585
 
560
586
  async def adisable_inbound_voice(self, phone_number_id: str) -> dict[str, Any]:
561
587
  """Stop answering calls to a number; the settings are kept, disabled."""
@@ -563,7 +589,7 @@ class OneShotClient:
563
589
  return self._unwrap_data(await self.acall_free_delete(path, scope="write"))
564
590
 
565
591
  def disable_inbound_voice(self, phone_number_id: str) -> dict[str, Any]:
566
- return asyncio.get_event_loop().run_until_complete(self.adisable_inbound_voice(phone_number_id))
592
+ return _run_sync(self.adisable_inbound_voice(phone_number_id), "adisable_inbound_voice")
567
593
 
568
594
  async def ainbound_calls(
569
595
  self,
@@ -586,14 +612,14 @@ class OneShotClient:
586
612
  return self._unwrap_data(await self.acall_free_get("/v1/tools/voice/inbound", params or None))
587
613
 
588
614
  def inbound_calls(self, **kwargs: Any) -> dict[str, Any]:
589
- return asyncio.get_event_loop().run_until_complete(self.ainbound_calls(**kwargs))
615
+ return _run_sync(self.ainbound_calls(**kwargs), "ainbound_calls")
590
616
 
591
617
  async def ainbound_call(self, call_id: str) -> dict[str, Any]:
592
618
  """One inbound call, with transcript."""
593
619
  return self._unwrap_data(await self.acall_free_get(f"/v1/tools/voice/inbound/{quote(call_id, safe='')}"))
594
620
 
595
621
  def inbound_call(self, call_id: str) -> dict[str, Any]:
596
- return asyncio.get_event_loop().run_until_complete(self.ainbound_call(call_id))
622
+ return _run_sync(self.ainbound_call(call_id), "ainbound_call")
597
623
 
598
624
  @staticmethod
599
625
  def _with_approval(headers: dict[str, str], approval_id: Optional[str]) -> dict[str, str]:
@@ -772,7 +798,7 @@ class OneShotClient:
772
798
 
773
799
  def get_budgets(self) -> AgentBudgetStatus:
774
800
  """Current spend budget and today's utilization. Blocking."""
775
- return asyncio.get_event_loop().run_until_complete(self.aget_budgets())
801
+ return _run_sync(self.aget_budgets(), "aget_budgets")
776
802
 
777
803
  async def aget_budgets(self) -> AgentBudgetStatus:
778
804
  """Current spend budget and today's utilization. Async."""
@@ -789,6 +815,7 @@ class OneShotClient:
789
815
  wait: bool = True,
790
816
  wait_for_phones: bool = False,
791
817
  phone_timeout_sec: int = 360,
818
+ quote_id: Optional[str] = None,
792
819
  idempotency_key: Optional[str] = None,
793
820
  approval_id: Optional[str] = None,
794
821
  ) -> Any:
@@ -802,8 +829,11 @@ class OneShotClient:
802
829
  until Apollo's async phone-reveal webhook delivers phone numbers
803
830
  (or until ``phone_timeout_sec`` expires, default 6 min — Apollo says
804
831
  "several minutes"). Default is False for backwards compat.
832
+
833
+ ``quote_id`` rides on the ``x-quote-id`` header from the first attempt,
834
+ as in :meth:`acall_tool`; it is never part of the quoted body.
805
835
  """
806
- return asyncio.get_event_loop().run_until_complete(
836
+ return _run_sync(
807
837
  self.acall_tool(
808
838
  endpoint,
809
839
  payload,
@@ -812,9 +842,11 @@ class OneShotClient:
812
842
  wait=wait,
813
843
  wait_for_phones=wait_for_phones,
814
844
  phone_timeout_sec=phone_timeout_sec,
845
+ quote_id=quote_id,
815
846
  idempotency_key=idempotency_key,
816
847
  approval_id=approval_id,
817
- )
848
+ ),
849
+ "acall_tool",
818
850
  )
819
851
 
820
852
  def call_lookup(self, name: str, /, **params: Any) -> Any:
@@ -831,7 +863,7 @@ class OneShotClient:
831
863
  method, pricing, path, payload = lookup_request(entry, params)
832
864
  if pricing in PAID_PRICING:
833
865
  return self.call_tool(path, payload)
834
- return asyncio.get_event_loop().run_until_complete(self._acall_free_lookup(method, path, payload))
866
+ return _run_sync(self._acall_free_lookup(method, path, payload), "acall_lookup")
835
867
 
836
868
  async def acall_lookup(self, name: str, /, **params: Any) -> Any:
837
869
  """Async form of :meth:`call_lookup`, sent through :meth:`acall_tool`."""
@@ -864,13 +896,14 @@ class OneShotClient:
864
896
  phone_timeout_sec: int = 360,
865
897
  ) -> Any:
866
898
  """Wait for a previously dispatched job (e.g. from ``wait=False``) and return its result."""
867
- return asyncio.get_event_loop().run_until_complete(
899
+ return _run_sync(
868
900
  self.await_result(
869
901
  request_id,
870
902
  timeout_sec=timeout_sec,
871
903
  wait_for_phones=wait_for_phones,
872
904
  phone_timeout_sec=phone_timeout_sec,
873
- )
905
+ ),
906
+ "await_result",
874
907
  )
875
908
 
876
909
  async def await_result(
@@ -1179,8 +1212,9 @@ class OneShotClient:
1179
1212
  params: Optional[dict[str, str]] = None,
1180
1213
  ) -> Any:
1181
1214
  """GET a free endpoint (blocking)."""
1182
- return asyncio.get_event_loop().run_until_complete(
1183
- self.acall_free_get(endpoint, params)
1215
+ return _run_sync(
1216
+ self.acall_free_get(endpoint, params),
1217
+ "acall_free_get",
1184
1218
  )
1185
1219
 
1186
1220
  async def acall_free_get(
@@ -1204,8 +1238,9 @@ class OneShotClient:
1204
1238
  scope: str = "read",
1205
1239
  ) -> Any:
1206
1240
  """POST to a free endpoint (blocking). Mutations pass scope="write"."""
1207
- return asyncio.get_event_loop().run_until_complete(
1208
- self.acall_free_post(endpoint, payload, scope=scope)
1241
+ return _run_sync(
1242
+ self.acall_free_post(endpoint, payload, scope=scope),
1243
+ "acall_free_post",
1209
1244
  )
1210
1245
 
1211
1246
  async def acall_free_post(
@@ -1238,8 +1273,9 @@ class OneShotClient:
1238
1273
  scope: str = "read",
1239
1274
  ) -> Any:
1240
1275
  """PATCH a free endpoint (blocking). Mutations pass scope="write"."""
1241
- return asyncio.get_event_loop().run_until_complete(
1242
- self.acall_free_patch(endpoint, payload, scope=scope)
1276
+ return _run_sync(
1277
+ self.acall_free_patch(endpoint, payload, scope=scope),
1278
+ "acall_free_patch",
1243
1279
  )
1244
1280
 
1245
1281
  async def acall_free_patch(
@@ -1266,8 +1302,9 @@ class OneShotClient:
1266
1302
  payload: Optional[dict[str, Any]] = None,
1267
1303
  ) -> Any:
1268
1304
  """PUT to a signed mutation endpoint (blocking)."""
1269
- return asyncio.get_event_loop().run_until_complete(
1270
- self.acall_free_put(endpoint, payload)
1305
+ return _run_sync(
1306
+ self.acall_free_put(endpoint, payload),
1307
+ "acall_free_put",
1271
1308
  )
1272
1309
 
1273
1310
  async def acall_free_put(
@@ -1290,8 +1327,9 @@ class OneShotClient:
1290
1327
  scope: str = "read",
1291
1328
  ) -> Any:
1292
1329
  """DELETE a free endpoint (blocking). Mutations pass scope="write"."""
1293
- return asyncio.get_event_loop().run_until_complete(
1294
- self.acall_free_delete(endpoint, scope=scope)
1330
+ return _run_sync(
1331
+ self.acall_free_delete(endpoint, scope=scope),
1332
+ "acall_free_delete",
1295
1333
  )
1296
1334
 
1297
1335
  async def acall_free_delete(
@@ -1333,7 +1371,7 @@ class OneShotClient:
1333
1371
 
1334
1372
  def create_approval(self, action_summary: str, **kwargs: Any) -> dict[str, Any]:
1335
1373
  """Ask a named human to decide something. Blocking."""
1336
- return asyncio.get_event_loop().run_until_complete(self.acreate_approval(action_summary, **kwargs))
1374
+ return _run_sync(self.acreate_approval(action_summary, **kwargs), "acreate_approval")
1337
1375
 
1338
1376
  async def aget_approval(self, approval_id: str) -> dict[str, Any]:
1339
1377
  """One approval — poll it for the decision. Async."""
@@ -1341,7 +1379,7 @@ class OneShotClient:
1341
1379
 
1342
1380
  def get_approval(self, approval_id: str) -> dict[str, Any]:
1343
1381
  """One approval — poll it for the decision. Blocking."""
1344
- return asyncio.get_event_loop().run_until_complete(self.aget_approval(approval_id))
1382
+ return _run_sync(self.aget_approval(approval_id), "aget_approval")
1345
1383
 
1346
1384
  async def alist_approvals(self, status: Optional[str] = None, limit: Optional[int] = None) -> list[dict[str, Any]]:
1347
1385
  """This agent's approvals, newest first. Async."""
@@ -1351,7 +1389,7 @@ class OneShotClient:
1351
1389
 
1352
1390
  def list_approvals(self, status: Optional[str] = None, limit: Optional[int] = None) -> list[dict[str, Any]]:
1353
1391
  """This agent's approvals, newest first. Blocking."""
1354
- return asyncio.get_event_loop().run_until_complete(self.alist_approvals(status, limit))
1392
+ return _run_sync(self.alist_approvals(status, limit), "alist_approvals")
1355
1393
 
1356
1394
  async def acancel_approval(self, approval_id: str) -> dict[str, Any]:
1357
1395
  """Withdraw a pending approval. Async."""
@@ -1359,7 +1397,7 @@ class OneShotClient:
1359
1397
 
1360
1398
  def cancel_approval(self, approval_id: str) -> dict[str, Any]:
1361
1399
  """Withdraw a pending approval. Blocking."""
1362
- return asyncio.get_event_loop().run_until_complete(self.acancel_approval(approval_id))
1400
+ return _run_sync(self.acancel_approval(approval_id), "acancel_approval")
1363
1401
 
1364
1402
  async def aget_action_policy(self) -> Optional[dict[str, Any]]:
1365
1403
  """The agent's action policy, or None. Async."""
@@ -1367,7 +1405,7 @@ class OneShotClient:
1367
1405
 
1368
1406
  def get_action_policy(self) -> Optional[dict[str, Any]]:
1369
1407
  """The agent's action policy, or None. Blocking."""
1370
- return asyncio.get_event_loop().run_until_complete(self.aget_action_policy())
1408
+ return _run_sync(self.aget_action_policy(), "aget_action_policy")
1371
1409
 
1372
1410
  async def aset_action_policy(self, policy: dict[str, Any]) -> dict[str, Any]:
1373
1411
  """Replace the action policy (rules, approver, timeout_seconds, enabled). Wallet sessions only. Async."""
@@ -1375,7 +1413,7 @@ class OneShotClient:
1375
1413
 
1376
1414
  def set_action_policy(self, policy: dict[str, Any]) -> dict[str, Any]:
1377
1415
  """Replace the action policy. Wallet sessions only. Blocking."""
1378
- return asyncio.get_event_loop().run_until_complete(self.aset_action_policy(policy))
1416
+ return _run_sync(self.aset_action_policy(policy), "aset_action_policy")
1379
1417
 
1380
1418
  async def adelete_action_policy(self) -> None:
1381
1419
  """Remove the action policy. Wallet sessions only. Async."""
@@ -1383,7 +1421,7 @@ class OneShotClient:
1383
1421
 
1384
1422
  def delete_action_policy(self) -> None:
1385
1423
  """Remove the action policy. Wallet sessions only. Blocking."""
1386
- asyncio.get_event_loop().run_until_complete(self.adelete_action_policy())
1424
+ _run_sync(self.adelete_action_policy(), "adelete_action_policy")
1387
1425
 
1388
1426
  @staticmethod
1389
1427
  def _unwrap_data(envelope: Any) -> Any:
@@ -1410,7 +1448,7 @@ class OneShotClient:
1410
1448
  memo: Optional[str] = None,
1411
1449
  ) -> Any:
1412
1450
  """Create a compute goal. Blocking."""
1413
- return asyncio.get_event_loop().run_until_complete(
1451
+ return _run_sync(
1414
1452
  self.acompute(
1415
1453
  objective,
1416
1454
  params=params,
@@ -1422,7 +1460,8 @@ class OneShotClient:
1422
1460
  approvals=approvals,
1423
1461
  max_cost=max_cost,
1424
1462
  memo=memo,
1425
- )
1463
+ ),
1464
+ "acompute",
1426
1465
  )
1427
1466
 
1428
1467
  async def acompute(
@@ -1469,8 +1508,9 @@ class OneShotClient:
1469
1508
 
1470
1509
  def get_compute_goal(self, goal_id: str) -> Any:
1471
1510
  """Get goal status. Blocking."""
1472
- return asyncio.get_event_loop().run_until_complete(
1473
- self.aget_compute_goal(goal_id)
1511
+ return _run_sync(
1512
+ self.aget_compute_goal(goal_id),
1513
+ "aget_compute_goal",
1474
1514
  )
1475
1515
 
1476
1516
  async def aget_compute_goal(self, goal_id: str) -> Any:
@@ -1479,8 +1519,9 @@ class OneShotClient:
1479
1519
 
1480
1520
  def get_compute_tasks(self, goal_id: str) -> Any:
1481
1521
  """List tasks under a goal. Blocking."""
1482
- return asyncio.get_event_loop().run_until_complete(
1483
- self.aget_compute_tasks(goal_id)
1522
+ return _run_sync(
1523
+ self.aget_compute_tasks(goal_id),
1524
+ "aget_compute_tasks",
1484
1525
  )
1485
1526
 
1486
1527
  async def aget_compute_tasks(self, goal_id: str) -> Any:
@@ -1491,8 +1532,9 @@ class OneShotClient:
1491
1532
  """List what a goal produced: the files its sandbox tasks left and the
1492
1533
  tool results too large for a task row. The goal's short answer is
1493
1534
  ``result`` on ``get_compute_goal``. Blocking."""
1494
- return asyncio.get_event_loop().run_until_complete(
1495
- self.aget_compute_outputs(goal_id)
1535
+ return _run_sync(
1536
+ self.aget_compute_outputs(goal_id),
1537
+ "aget_compute_outputs",
1496
1538
  )
1497
1539
 
1498
1540
  async def aget_compute_outputs(self, goal_id: str) -> Any:
@@ -1503,8 +1545,9 @@ class OneShotClient:
1503
1545
  """Read one output of a goal by the ``key`` from ``get_compute_outputs``
1504
1546
  (or a task's ``outputs``). ``content`` is None when the output was too
1505
1547
  large to keep (``readable`` False). Blocking."""
1506
- return asyncio.get_event_loop().run_until_complete(
1507
- self.aget_compute_output(goal_id, key)
1548
+ return _run_sync(
1549
+ self.aget_compute_output(goal_id, key),
1550
+ "aget_compute_output",
1508
1551
  )
1509
1552
 
1510
1553
  async def aget_compute_output(self, goal_id: str, key: str) -> Any:
@@ -1515,8 +1558,9 @@ class OneShotClient:
1515
1558
 
1516
1559
  def get_compute_budget(self, goal_id: str) -> Any:
1517
1560
  """Get budget breakdown. Blocking."""
1518
- return asyncio.get_event_loop().run_until_complete(
1519
- self.aget_compute_budget(goal_id)
1561
+ return _run_sync(
1562
+ self.aget_compute_budget(goal_id),
1563
+ "aget_compute_budget",
1520
1564
  )
1521
1565
 
1522
1566
  async def aget_compute_budget(self, goal_id: str) -> Any:
@@ -1530,8 +1574,9 @@ class OneShotClient:
1530
1574
  refund is deferred: the result then has ``status == "pending_settlement"``
1531
1575
  (no exception is raised).
1532
1576
  """
1533
- return asyncio.get_event_loop().run_until_complete(
1534
- self.acancel_compute_goal(goal_id, reason)
1577
+ return _run_sync(
1578
+ self.acancel_compute_goal(goal_id, reason),
1579
+ "acancel_compute_goal",
1535
1580
  )
1536
1581
 
1537
1582
  async def acancel_compute_goal(
@@ -1558,10 +1603,11 @@ class OneShotClient:
1558
1603
  approved: Optional[bool] = None,
1559
1604
  ) -> Any:
1560
1605
  """Respond to a human-in-the-loop approval task. Blocking."""
1561
- return asyncio.get_event_loop().run_until_complete(
1606
+ return _run_sync(
1562
1607
  self.arespond_to_compute_task(
1563
1608
  goal_id, task_id, response=response, approved=approved
1564
- )
1609
+ ),
1610
+ "arespond_to_compute_task",
1565
1611
  )
1566
1612
 
1567
1613
  async def arespond_to_compute_task(
@@ -1584,8 +1630,9 @@ class OneShotClient:
1584
1630
 
1585
1631
  def pause_compute_goal(self, goal_id: str, reason: Optional[str] = None) -> Any:
1586
1632
  """Pause a recurring goal. Blocking."""
1587
- return asyncio.get_event_loop().run_until_complete(
1588
- self.apause_compute_goal(goal_id, reason)
1633
+ return _run_sync(
1634
+ self.apause_compute_goal(goal_id, reason),
1635
+ "apause_compute_goal",
1589
1636
  )
1590
1637
 
1591
1638
  async def apause_compute_goal(
@@ -1599,8 +1646,9 @@ class OneShotClient:
1599
1646
 
1600
1647
  def resume_compute_goal(self, goal_id: str) -> Any:
1601
1648
  """Resume a paused recurring goal. Blocking."""
1602
- return asyncio.get_event_loop().run_until_complete(
1603
- self.aresume_compute_goal(goal_id)
1649
+ return _run_sync(
1650
+ self.aresume_compute_goal(goal_id),
1651
+ "aresume_compute_goal",
1604
1652
  )
1605
1653
 
1606
1654
  async def aresume_compute_goal(self, goal_id: str) -> Any:
@@ -1611,8 +1659,9 @@ class OneShotClient:
1611
1659
 
1612
1660
  def fund_compute_goal(self, goal_id: str, amount: float) -> Any:
1613
1661
  """Top up budget on a recurring goal. Blocking."""
1614
- return asyncio.get_event_loop().run_until_complete(
1615
- self.afund_compute_goal(goal_id, amount)
1662
+ return _run_sync(
1663
+ self.afund_compute_goal(goal_id, amount),
1664
+ "afund_compute_goal",
1616
1665
  )
1617
1666
 
1618
1667
  async def afund_compute_goal(self, goal_id: str, amount: float) -> Any:
@@ -1641,7 +1690,7 @@ class OneShotClient:
1641
1690
 
1642
1691
  def top_up_credits(self, amount: float) -> Any:
1643
1692
  """Blocking wrapper for :meth:`atop_up_credits`."""
1644
- return asyncio.get_event_loop().run_until_complete(self.atop_up_credits(amount))
1693
+ return _run_sync(self.atop_up_credits(amount), "atop_up_credits")
1645
1694
 
1646
1695
  def browser(
1647
1696
  self,
@@ -1657,13 +1706,14 @@ class OneShotClient:
1657
1706
  timeout_sec: int = 300,
1658
1707
  ) -> Any:
1659
1708
  """Run a browser automation task. Blocking."""
1660
- return asyncio.get_event_loop().run_until_complete(
1709
+ return _run_sync(
1661
1710
  self.abrowser(
1662
1711
  task, output_schema=output_schema, start_url=start_url,
1663
1712
  allowed_domains=allowed_domains, profile_id=profile_id,
1664
1713
  secrets=secrets, max_steps=max_steps, max_cost=max_cost,
1665
1714
  timeout_sec=timeout_sec,
1666
- )
1715
+ ),
1716
+ "abrowser",
1667
1717
  )
1668
1718
 
1669
1719
  async def abrowser(
@@ -1842,7 +1892,7 @@ class OneShotClient:
1842
1892
  return await self.acall_free_get(f"/v1/tools/linkedin/accounts/{quote(account_id, safe='')}/invitations", _linkedin_query(params))
1843
1893
 
1844
1894
  def _linkedin_invitation_call(self, endpoint: str, payload: dict, **kwargs: Any) -> Any:
1845
- return asyncio.get_event_loop().run_until_complete(self._alinkedin_invitation_call(endpoint, payload, **kwargs))
1895
+ return _run_sync(self._alinkedin_invitation_call(endpoint, payload, **kwargs), "_alinkedin_invitation_call")
1846
1896
 
1847
1897
  async def _alinkedin_invitation_call(self, endpoint: str, payload: dict, **kwargs: Any) -> Any:
1848
1898
  try:
@@ -1979,13 +2029,14 @@ class OneShotClient:
1979
2029
  ``mailbox_mode == 'mailbox'``, and read ``mailbox_provisioning_fee`` on
1980
2030
  the quote for the exact amount before paying.
1981
2031
  """
1982
- return asyncio.get_event_loop().run_until_complete(
2032
+ return _run_sync(
1983
2033
  self.aemail(
1984
2034
  to, subject, body,
1985
2035
  from_domain=from_domain, from_mailbox=from_mailbox, from_name=from_name,
1986
2036
  reply_to_email_id=reply_to_email_id, mailbox_mode=mailbox_mode,
1987
2037
  **kwargs,
1988
- )
2038
+ ),
2039
+ "aemail",
1989
2040
  )
1990
2041
 
1991
2042
  async def aemail(
@@ -2082,7 +2133,7 @@ class OneShotClient:
2082
2133
  """Keep SDK controls out of the canonical quoted input."""
2083
2134
  return {key: kwargs.pop(key) for key in (
2084
2135
  "max_cost", "timeout_sec", "wait", "wait_for_phones",
2085
- "phone_timeout_sec", "idempotency_key", "approval_id",
2136
+ "phone_timeout_sec", "idempotency_key", "approval_id", "quote_id",
2086
2137
  ) if key in kwargs}
2087
2138
 
2088
2139
  def person_find(self, *, include_phone: bool = False, **kwargs: Any) -> Any:
@@ -2361,7 +2412,7 @@ class OneShotClient:
2361
2412
 
2362
2413
  def pause_domain(self, domain: str) -> DomainPoolStatusResult:
2363
2414
  """Take a domain out of rotation without releasing it. Blocking."""
2364
- return asyncio.get_event_loop().run_until_complete(self.apause_domain(domain))
2415
+ return _run_sync(self.apause_domain(domain), "apause_domain")
2365
2416
 
2366
2417
  async def apause_domain(self, domain: str) -> DomainPoolStatusResult:
2367
2418
  """Take a domain out of rotation without releasing it. Async."""
@@ -2369,7 +2420,7 @@ class OneShotClient:
2369
2420
 
2370
2421
  def resume_domain(self, domain: str) -> DomainPoolStatusResult:
2371
2422
  """Put a paused domain back into rotation. Blocking."""
2372
- return asyncio.get_event_loop().run_until_complete(self.aresume_domain(domain))
2423
+ return _run_sync(self.aresume_domain(domain), "aresume_domain")
2373
2424
 
2374
2425
  async def aresume_domain(self, domain: str) -> DomainPoolStatusResult:
2375
2426
  """Put a paused domain back into rotation. Async.
@@ -2410,10 +2461,11 @@ class OneShotClient:
2410
2461
  ``savings``, ``engagement``. Stored as ``pending`` until a judge service
2411
2462
  confirms them against inbound signals.
2412
2463
  """
2413
- return asyncio.get_event_loop().run_until_complete(
2464
+ return _run_sync(
2414
2465
  self.atag_receipt_value(
2415
2466
  receipt_id, value_tag, request_id=request_id, goal_id=goal_id
2416
- )
2467
+ ),
2468
+ "atag_receipt_value",
2417
2469
  )
2418
2470
 
2419
2471
  async def atag_receipt_value(
@@ -2685,7 +2737,7 @@ class HuntsClient:
2685
2737
  return self.client._unwrap_data(resp.json())
2686
2738
 
2687
2739
  def list(self, status: str = "active", limit: int = 20, offset: int = 0) -> list[dict[str, Any]]:
2688
- return asyncio.get_event_loop().run_until_complete(self.alist(status, limit, offset))
2740
+ return _run_sync(self.alist(status, limit, offset), "hunts.alist")
2689
2741
 
2690
2742
  async def astatus(self, hunt_id: str) -> dict[str, Any]:
2691
2743
  async with httpx.AsyncClient(timeout=30.0) as transport:
@@ -2697,7 +2749,7 @@ class HuntsClient:
2697
2749
  return self.client._unwrap_data(resp.json())
2698
2750
 
2699
2751
  def status(self, hunt_id: str) -> dict[str, Any]:
2700
- return asyncio.get_event_loop().run_until_complete(self.astatus(hunt_id))
2752
+ return _run_sync(self.astatus(hunt_id), "hunts.astatus")
2701
2753
 
2702
2754
  async def apredict(
2703
2755
  self, hunt_id: str, predicted_tool: str, predicted_action: str, predicted_motivation: str
@@ -2719,8 +2771,9 @@ class HuntsClient:
2719
2771
  def predict(
2720
2772
  self, hunt_id: str, predicted_tool: str, predicted_action: str, predicted_motivation: str
2721
2773
  ) -> dict[str, Any]:
2722
- return asyncio.get_event_loop().run_until_complete(
2723
- self.apredict(hunt_id, predicted_tool, predicted_action, predicted_motivation)
2774
+ return _run_sync(
2775
+ self.apredict(hunt_id, predicted_tool, predicted_action, predicted_motivation),
2776
+ "hunts.apredict",
2724
2777
  )
2725
2778
 
2726
2779
  async def acapture(self, hunt_id: str) -> dict[str, Any]:
@@ -2734,7 +2787,7 @@ class HuntsClient:
2734
2787
  return self.client._unwrap_data(resp.json())
2735
2788
 
2736
2789
  def capture(self, hunt_id: str) -> dict[str, Any]:
2737
- return asyncio.get_event_loop().run_until_complete(self.acapture(hunt_id))
2790
+ return _run_sync(self.acapture(hunt_id), "hunts.acapture")
2738
2791
 
2739
2792
  async def acollection(self) -> list[dict[str, Any]]:
2740
2793
  async with httpx.AsyncClient(timeout=30.0) as transport:
@@ -2746,7 +2799,7 @@ class HuntsClient:
2746
2799
  return self.client._unwrap_data(resp.json())
2747
2800
 
2748
2801
  def collection(self) -> list[dict[str, Any]]:
2749
- return asyncio.get_event_loop().run_until_complete(self.acollection())
2802
+ return _run_sync(self.acollection(), "hunts.acollection")
2750
2803
 
2751
2804
  async def aleaderboard(
2752
2805
  self, period: str = "all", sort: str = "captures", limit: int = 25, offset: int = 0
@@ -2763,7 +2816,7 @@ class HuntsClient:
2763
2816
  def leaderboard(
2764
2817
  self, period: str = "all", sort: str = "captures", limit: int = 25, offset: int = 0
2765
2818
  ) -> list[dict[str, Any]]:
2766
- return asyncio.get_event_loop().run_until_complete(self.aleaderboard(period, sort, limit, offset))
2819
+ return _run_sync(self.aleaderboard(period, sort, limit, offset), "hunts.aleaderboard")
2767
2820
 
2768
2821
 
2769
2822
  def _new_idempotency_key() -> str:
@@ -1368,13 +1368,13 @@ LOOKUP_MANIFEST: list[dict[str, Any]] = [
1368
1368
  "method": "POST",
1369
1369
  "pricing": "fixed",
1370
1370
  "title": "Changes",
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.",
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, or of a published dataset attribute such as company_operating_authority), `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.",
1372
1372
  "input_schema": {
1373
1373
  "type": "object",
1374
1374
  "properties": {
1375
1375
  "attribute": {
1376
1376
  "type": "string",
1377
- "description": "Name of a data tool that keeps history, e.g. vehicle_identification",
1377
+ "description": "Name of a data tool that keeps history, e.g. vehicle_identification, or of a published dataset attribute, e.g. company_operating_authority",
1378
1378
  },
1379
1379
  "key": {
1380
1380
  "type": "string",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "oneshot-python"
3
- version = "0.40.0"
3
+ version = "0.40.1"
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"
@@ -10,8 +10,8 @@ def blocking_test_event_loop(request):
10
10
  if inspect.iscoroutinefunction(request.function):
11
11
  yield
12
12
  return
13
- # Blocking SDK methods call run_until_complete. Async tests may have closed
14
- # or cleared the thread's previous loop; no test should depend on its order.
13
+ # Exercise reuse of a caller-owned loop, independent of async test cleanup.
14
+ # test_sync_without_loop covers the absent/closed-loop fallback separately.
15
15
  loop = asyncio.new_event_loop()
16
16
  asyncio.set_event_loop(loop)
17
17
  try:
@@ -31,14 +31,12 @@ BASE = "http://test.local"
31
31
 
32
32
  @pytest.fixture(autouse=True)
33
33
  def _fresh_event_loop():
34
- """Sync wrappers use ``get_event_loop().run_until_complete``; pytest-asyncio
35
- closes loops between async tests, so give every test a fresh one."""
34
+ """Exercise sync wrappers with a caller-owned loop for each test."""
36
35
  loop = asyncio.new_event_loop()
37
36
  asyncio.set_event_loop(loop)
38
37
  yield
39
38
  loop.close()
40
- # Leave an OPEN loop behind: this module sorts first, and later modules'
41
- # sync wrappers call get_event_loop().run_until_complete on the current one.
39
+ # Leave an open loop for later tests that drive coroutines directly.
42
40
  asyncio.set_event_loop(asyncio.new_event_loop())
43
41
 
44
42
 
@@ -25,14 +25,9 @@ REPLY_ID = "4fa85f64-5717-4562-b3fc-2c963f66afa6"
25
25
  def _fresh_event_loop():
26
26
  """Give every test in this module its own event loop.
27
27
 
28
- The sync surface (``email()``) bridges to ``aemail()`` via
29
- ``asyncio.get_event_loop().run_until_complete(...)``. pytest-asyncio
30
- clears the current event loop after each async test, which otherwise
31
- breaks any *sync* test — mocked or not — that happens to run afterward
32
- in the same session ("There is no current event loop in thread
33
- 'MainThread'"). Without this fixture the suite's pass/fail would depend
34
- on test collection order, which is exactly the kind of environment-
35
- dependent test this correction round was asked to avoid introducing.
28
+ The sync surface (``email()``) bridges to ``aemail()`` via ``_run_sync``.
29
+ Keep these payload tests on a caller-owned loop; absent and closed loops
30
+ are covered separately in ``test_sync_without_loop.py``.
36
31
  """
37
32
  loop = asyncio.new_event_loop()
38
33
  asyncio.set_event_loop(loop)
@@ -131,6 +131,27 @@ def test_default_and_overridden_hunt_url():
131
131
  assert custom.hunt_url == "https://custom.hunt.example"
132
132
 
133
133
 
134
+ @pytest.mark.parametrize("sync_name,args", [
135
+ ("list", ()),
136
+ ("status", ("hunt_1",)),
137
+ ("predict", ("hunt_1", "tool", "action", "motivation")),
138
+ ("capture", ("hunt_1",)),
139
+ ("collection", ()),
140
+ ("leaderboard", ()),
141
+ ])
142
+ def test_sync_inside_running_loop_names_bound_async_method(sync_name, args):
143
+ client = _wallet_client()
144
+ async_name = "a" + sync_name
145
+ assert callable(getattr(client.hunts, async_name))
146
+
147
+ async def scenario():
148
+ with pytest.raises(RuntimeError) as error:
149
+ getattr(client.hunts, sync_name)(*args)
150
+ assert f"use await client.hunts.{async_name}(...) instead." in str(error.value)
151
+
152
+ asyncio.run(scenario())
153
+
154
+
134
155
  # ---------------------------------------------------------------- wallet session: reads
135
156
 
136
157
  @pytest.mark.asyncio
@@ -0,0 +1,87 @@
1
+ """A person call's ``quote_id`` is the ``x-quote-id`` header, never body input (#1945).
2
+
3
+ ``person_find(quote_id=...)``, ``person_profile``, ``person_dossier`` and their
4
+ async variants hand ``quote_id`` to ``call_tool`` / ``acall_tool`` with the other
5
+ SDK controls, so the quoted input the API hashes is the person input alone and
6
+ the header carries the quote from the very first attempt.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from unittest.mock import AsyncMock, MagicMock, patch
12
+
13
+ import pytest
14
+
15
+ from oneshot.client import OneShotClient
16
+
17
+ TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
18
+ QUOTE_ID = "q-person-1945"
19
+ PERSON_METHODS = [
20
+ ("person_find", "/v1/tools/find/person"),
21
+ ("person_profile", "/v1/tools/lookup/person-profile"),
22
+ ("person_dossier", "/v1/tools/lookup/person-dossier"),
23
+ ]
24
+
25
+
26
+ def _queued() -> MagicMock:
27
+ resp = MagicMock()
28
+ resp.status_code = 202
29
+ resp.is_success = True
30
+ resp.json.return_value = {"request_id": "job-1", "status": "processing"}
31
+ resp.text = ""
32
+ resp.headers = {}
33
+ return resp
34
+
35
+
36
+ def _http() -> MagicMock:
37
+ http = MagicMock()
38
+ http.post = AsyncMock(return_value=_queued())
39
+ http.__aenter__ = AsyncMock(return_value=http)
40
+ http.__aexit__ = AsyncMock(return_value=False)
41
+ return http
42
+
43
+
44
+ def _client() -> OneShotClient:
45
+ client = OneShotClient(TEST_PRIVATE_KEY)
46
+ client._budget_synced = True
47
+ return client
48
+
49
+
50
+ def _assert_header_only(http: MagicMock, endpoint: str) -> None:
51
+ http.post.assert_awaited_once()
52
+ call = http.post.await_args
53
+ assert call.args[0].endswith(endpoint)
54
+ assert call.kwargs["headers"]["x-quote-id"] == QUOTE_ID
55
+ assert "quote_id" not in call.kwargs["json"]
56
+ assert call.kwargs["json"] == {"name": "Jane", "include_phone": True}
57
+
58
+
59
+ @pytest.mark.parametrize("method,endpoint", PERSON_METHODS)
60
+ def test_blocking_person_call_sends_quote_id_as_the_header_and_not_in_the_body(method, endpoint):
61
+ client = _client()
62
+ http = _http()
63
+ with patch("oneshot.client.httpx.AsyncClient", return_value=http):
64
+ result = getattr(client, method)(name="Jane", include_phone=True, quote_id=QUOTE_ID, wait=False)
65
+ assert result["request_id"] == "job-1"
66
+ _assert_header_only(http, endpoint)
67
+
68
+
69
+ @pytest.mark.asyncio
70
+ @pytest.mark.parametrize("method,endpoint", PERSON_METHODS)
71
+ async def test_async_person_call_sends_quote_id_as_the_header_and_not_in_the_body(method, endpoint):
72
+ client = _client()
73
+ http = _http()
74
+ with patch("oneshot.client.httpx.AsyncClient", return_value=http):
75
+ result = await getattr(client, f"a{method}")(name="Jane", include_phone=True, quote_id=QUOTE_ID, wait=False)
76
+ assert result["request_id"] == "job-1"
77
+ _assert_header_only(http, endpoint)
78
+
79
+
80
+ def test_call_tool_forwards_quote_id_to_the_async_flow():
81
+ """The blocking ``call_tool`` accepts ``quote_id`` and sends the same header."""
82
+ client = _client()
83
+ http = _http()
84
+ with patch("oneshot.client.httpx.AsyncClient", return_value=http):
85
+ client.call_tool("/v1/tools/find/person", {"name": "Jane"}, quote_id=QUOTE_ID, wait=False)
86
+ assert http.post.await_args.kwargs["headers"]["x-quote-id"] == QUOTE_ID
87
+ assert http.post.await_args.kwargs["json"] == {"name": "Jane"}
@@ -0,0 +1,72 @@
1
+ """Blocking SDK calls tolerate absent/closed loops and reject running loops."""
2
+
3
+ import asyncio
4
+ from unittest.mock import AsyncMock, patch
5
+
6
+ import pytest
7
+
8
+ from oneshot.client import OneShotClient
9
+
10
+
11
+ @pytest.fixture
12
+ def client():
13
+ # Restore the caller's loop so these regressions do not alter other tests.
14
+ try:
15
+ previous_loop = asyncio.get_event_loop()
16
+ except RuntimeError:
17
+ previous_loop = None
18
+ try:
19
+ yield OneShotClient(access_token="oneshot_" + "a" * 64, address="0x" + "1" * 40)
20
+ finally:
21
+ asyncio.set_event_loop(previous_loop)
22
+
23
+
24
+ METHODS = [
25
+ ("pause_domain", "apause_domain", ("example.com",)),
26
+ ("resume_domain", "aresume_domain", ("example.com",)),
27
+ ("voice_numbers", "avoice_numbers", ()),
28
+ ]
29
+
30
+
31
+ @pytest.mark.parametrize("sync_name,async_name,args", METHODS)
32
+ def test_sync_after_asyncio_run(client, sync_name, async_name, args):
33
+ asyncio.run(asyncio.sleep(0))
34
+ expected = {"result": sync_name}
35
+ with patch.object(client, async_name, new_callable=AsyncMock, return_value=expected) as twin:
36
+ assert getattr(client, sync_name)(*args) is expected
37
+ twin.assert_awaited_once_with(*args)
38
+
39
+
40
+ def test_sync_reuses_open_current_loop(client):
41
+ loop = asyncio.new_event_loop()
42
+ asyncio.set_event_loop(loop)
43
+ try:
44
+ with patch.object(client, "apause_domain", new_callable=AsyncMock,
45
+ side_effect=lambda *_: asyncio.get_running_loop()) as twin:
46
+ assert client.pause_domain("example.com") is loop
47
+ twin.assert_awaited_once_with("example.com")
48
+ assert asyncio.get_event_loop() is loop
49
+ assert not loop.is_closed()
50
+ finally:
51
+ loop.close()
52
+
53
+
54
+ def test_sync_with_closed_current_loop(client):
55
+ loop = asyncio.new_event_loop()
56
+ asyncio.set_event_loop(loop)
57
+ loop.close()
58
+ expected = {"pool_status": "paused"}
59
+ with patch.object(client, "apause_domain", new_callable=AsyncMock, return_value=expected) as twin:
60
+ assert client.pause_domain("example.com") is expected
61
+ twin.assert_awaited_once_with("example.com")
62
+
63
+
64
+ @pytest.mark.parametrize("sync_name,async_name,args", METHODS)
65
+ def test_sync_inside_running_loop_names_async_method(client, sync_name, async_name, args):
66
+ async def scenario():
67
+ with patch.object(client, async_name, new_callable=AsyncMock) as twin:
68
+ with pytest.raises(RuntimeError, match=rf"running event loop.*{async_name}"):
69
+ getattr(client, sync_name)(*args)
70
+ twin.assert_not_awaited()
71
+
72
+ asyncio.run(scenario())
File without changes