oneshot-python 0.8.2__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.
@@ -70,6 +70,8 @@ tmp/
70
70
 
71
71
  # Secrets
72
72
  soul-api-keys.json
73
+ config/envs/
74
+ config/secrets/
73
75
 
74
76
  # Next.js
75
77
  .next/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oneshot-python
3
- Version: 0.8.2
3
+ Version: 0.9.0
4
4
  Summary: Core Python SDK for the OneShot API — HTTP client with x402 payment handling
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
@@ -30,7 +30,7 @@ from oneshot.x402 import (
30
30
  sign_payment_authorization,
31
31
  )
32
32
 
33
- SDK_VERSION = "0.8.2"
33
+ SDK_VERSION = "0.8.3"
34
34
 
35
35
  # ---------------------------------------------------------------------------
36
36
  # Environment configuration
@@ -94,10 +94,25 @@ class OneShotClient:
94
94
  *,
95
95
  max_cost: Optional[float] = None,
96
96
  timeout_sec: int = 120,
97
+ wait_for_phones: bool = False,
98
+ phone_timeout_sec: int = 360,
97
99
  ) -> Any:
98
- """Execute a paid tool call (blocking). Handles the full x402 flow."""
100
+ """Execute a paid tool call (blocking). Handles the full x402 flow.
101
+
102
+ Set ``wait_for_phones=True`` to keep polling AFTER status=completed
103
+ until Apollo's async phone-reveal webhook delivers phone numbers
104
+ (or until ``phone_timeout_sec`` expires, default 6 min — Apollo says
105
+ "several minutes"). Default is False for backwards compat.
106
+ """
99
107
  return asyncio.get_event_loop().run_until_complete(
100
- self.acall_tool(endpoint, payload, max_cost=max_cost, timeout_sec=timeout_sec)
108
+ self.acall_tool(
109
+ endpoint,
110
+ payload,
111
+ max_cost=max_cost,
112
+ timeout_sec=timeout_sec,
113
+ wait_for_phones=wait_for_phones,
114
+ phone_timeout_sec=phone_timeout_sec,
115
+ )
101
116
  )
102
117
 
103
118
  async def acall_tool(
@@ -107,6 +122,8 @@ class OneShotClient:
107
122
  *,
108
123
  max_cost: Optional[float] = None,
109
124
  timeout_sec: int = 120,
125
+ wait_for_phones: bool = False,
126
+ phone_timeout_sec: int = 360,
110
127
  ) -> Any:
111
128
  """Execute a paid tool call (async). Handles the full x402 flow."""
112
129
  url = f"{self.base_url}{endpoint}"
@@ -137,7 +154,13 @@ class OneShotClient:
137
154
  "pending",
138
155
  "processing",
139
156
  ):
140
- return await self._poll_job(client, result["request_id"], timeout_sec)
157
+ return await self._poll_job(
158
+ client,
159
+ result["request_id"],
160
+ timeout_sec,
161
+ wait_for_phones=wait_for_phones,
162
+ phone_timeout_sec=phone_timeout_sec,
163
+ )
141
164
  return result.get("data", result)
142
165
 
143
166
  # Step 2 — Parse 402 response
@@ -219,7 +242,13 @@ class OneShotClient:
219
242
  "processing",
220
243
  ):
221
244
  self._log(f"Job queued: {result['request_id']}")
222
- return await self._poll_job(client, result["request_id"], timeout_sec)
245
+ return await self._poll_job(
246
+ client,
247
+ result["request_id"],
248
+ timeout_sec,
249
+ wait_for_phones=wait_for_phones,
250
+ phone_timeout_sec=phone_timeout_sec,
251
+ )
223
252
 
224
253
  return result.get("data", result)
225
254
 
@@ -404,6 +433,182 @@ class OneShotClient:
404
433
  """Delete a browser profile. Async."""
405
434
  return await self.acall_free_delete(f"/v1/tools/browser/profiles/{profile_id}")
406
435
 
436
+ # ------------------------------------------------------------------
437
+ # Paid tool convenience methods
438
+ # ------------------------------------------------------------------
439
+
440
+ def research(self, topic: str, *, depth: str = "deep", **kwargs: Any) -> Any:
441
+ """Run deep web research on a topic. Blocking."""
442
+ return self.call_tool("/v1/tools/research", {"topic": topic, "depth": depth, **kwargs})
443
+
444
+ async def aresearch(self, topic: str, *, depth: str = "deep", **kwargs: Any) -> Any:
445
+ """Run deep web research on a topic. Async."""
446
+ return await self.acall_tool("/v1/tools/research", {"topic": topic, "depth": depth, **kwargs})
447
+
448
+ def web_search(self, query: str, *, max_results: int = 5, **kwargs: Any) -> Any:
449
+ """Search the web. Blocking."""
450
+ return self.call_tool("/v1/tools/search", {"query": query, "max_results": max_results, **kwargs})
451
+
452
+ async def aweb_search(self, query: str, *, max_results: int = 5, **kwargs: Any) -> Any:
453
+ """Search the web. Async."""
454
+ return await self.acall_tool("/v1/tools/search", {"query": query, "max_results": max_results, **kwargs})
455
+
456
+ def web_read(self, url: str, **kwargs: Any) -> Any:
457
+ """Read a web page and extract content as markdown. Blocking."""
458
+ return self.call_tool("/v1/tools/web-read", {"url": url, **kwargs})
459
+
460
+ async def aweb_read(self, url: str, **kwargs: Any) -> Any:
461
+ """Read a web page and extract content as markdown. Async."""
462
+ return await self.acall_tool("/v1/tools/web-read", {"url": url, **kwargs})
463
+
464
+ def email(self, to: str, subject: str, body: str, *, from_domain: Optional[str] = None, **kwargs: Any) -> Any:
465
+ """Send an email. Blocking."""
466
+ payload: dict[str, Any] = {"to": to, "subject": subject, "body": body, **kwargs}
467
+ if from_domain:
468
+ payload["from_domain"] = from_domain
469
+ return self.call_tool("/v1/tools/email/send", payload)
470
+
471
+ async def aemail(self, to: str, subject: str, body: str, *, from_domain: Optional[str] = None, **kwargs: Any) -> Any:
472
+ """Send an email. Async."""
473
+ payload: dict[str, Any] = {"to": to, "subject": subject, "body": body, **kwargs}
474
+ if from_domain:
475
+ payload["from_domain"] = from_domain
476
+ return await self.acall_tool("/v1/tools/email/send", payload)
477
+
478
+ def voice(self, objective: str, target_number: str, **kwargs: Any) -> Any:
479
+ """Make an AI voice call. Blocking."""
480
+ return self.call_tool("/v1/tools/voice/call", {"objective": objective, "target_number": target_number, **kwargs})
481
+
482
+ async def avoice(self, objective: str, target_number: str, **kwargs: Any) -> Any:
483
+ """Make an AI voice call. Async."""
484
+ return await self.acall_tool("/v1/tools/voice/call", {"objective": objective, "target_number": target_number, **kwargs})
485
+
486
+ def sms(self, message: str, to_number: str, **kwargs: Any) -> Any:
487
+ """Send an SMS message. Blocking."""
488
+ return self.call_tool("/v1/tools/sms/send", {"message": message, "to_number": to_number, **kwargs})
489
+
490
+ async def asms(self, message: str, to_number: str, **kwargs: Any) -> Any:
491
+ """Send an SMS message. Async."""
492
+ return await self.acall_tool("/v1/tools/sms/send", {"message": message, "to_number": to_number, **kwargs})
493
+
494
+ def people_search(self, **kwargs: Any) -> Any:
495
+ """Search for people by job title, company, etc. Blocking."""
496
+ return self.call_tool("/v1/tools/research/people", kwargs)
497
+
498
+ async def apeople_search(self, **kwargs: Any) -> Any:
499
+ """Search for people by job title, company, etc. Async."""
500
+ return await self.acall_tool("/v1/tools/research/people", kwargs)
501
+
502
+ def enrich_profile(self, **kwargs: Any) -> Any:
503
+ """Enrich a person's profile from LinkedIn URL, email, or name. Blocking."""
504
+ return self.call_tool("/v1/tools/enrich/profile", kwargs)
505
+
506
+ async def aenrich_profile(self, **kwargs: Any) -> Any:
507
+ """Enrich a person's profile from LinkedIn URL, email, or name. Async."""
508
+ return await self.acall_tool("/v1/tools/enrich/profile", kwargs)
509
+
510
+ def find_email(self, company_domain: str, *, full_name: Optional[str] = None, first_name: Optional[str] = None, last_name: Optional[str] = None, **kwargs: Any) -> Any:
511
+ """Find a person's email address. Blocking."""
512
+ payload: dict[str, Any] = {"company_domain": company_domain, **kwargs}
513
+ if full_name:
514
+ payload["full_name"] = full_name
515
+ if first_name:
516
+ payload["first_name"] = first_name
517
+ if last_name:
518
+ payload["last_name"] = last_name
519
+ return self.call_tool("/v1/tools/enrich/email", payload)
520
+
521
+ async def afind_email(self, company_domain: str, *, full_name: Optional[str] = None, first_name: Optional[str] = None, last_name: Optional[str] = None, **kwargs: Any) -> Any:
522
+ """Find a person's email address. Async."""
523
+ payload: dict[str, Any] = {"company_domain": company_domain, **kwargs}
524
+ if full_name:
525
+ payload["full_name"] = full_name
526
+ if first_name:
527
+ payload["first_name"] = first_name
528
+ if last_name:
529
+ payload["last_name"] = last_name
530
+ return await self.acall_tool("/v1/tools/enrich/email", payload)
531
+
532
+ def verify_email(self, email: str) -> Any:
533
+ """Verify email deliverability. Blocking."""
534
+ return self.call_tool("/v1/tools/verify/email", {"email": email})
535
+
536
+ async def averify_email(self, email: str) -> Any:
537
+ """Verify email deliverability. Async."""
538
+ return await self.acall_tool("/v1/tools/verify/email", {"email": email})
539
+
540
+ def deep_research_person(self, **kwargs: Any) -> Any:
541
+ """Deep research on a person. Blocking."""
542
+ return self.call_tool("/v1/tools/research/person", kwargs)
543
+
544
+ async def adeep_research_person(self, **kwargs: Any) -> Any:
545
+ """Deep research on a person. Async."""
546
+ return await self.acall_tool("/v1/tools/research/person", kwargs)
547
+
548
+ def social_profiles(self, **kwargs: Any) -> Any:
549
+ """Discover social media profiles. Blocking."""
550
+ return self.call_tool("/v1/tools/research/social", kwargs)
551
+
552
+ async def asocial_profiles(self, **kwargs: Any) -> Any:
553
+ """Discover social media profiles. Async."""
554
+ return await self.acall_tool("/v1/tools/research/social", kwargs)
555
+
556
+ def article_search(self, name: str, company: str, **kwargs: Any) -> Any:
557
+ """Find articles by or about a person. Blocking."""
558
+ return self.call_tool("/v1/tools/research/articles", {"name": name, "company": company, **kwargs})
559
+
560
+ async def aarticle_search(self, name: str, company: str, **kwargs: Any) -> Any:
561
+ """Find articles by or about a person. Async."""
562
+ return await self.acall_tool("/v1/tools/research/articles", {"name": name, "company": company, **kwargs})
563
+
564
+ def person_newsfeed(self, social_media_url: str, **kwargs: Any) -> Any:
565
+ """Get recent social posts from a person. Blocking."""
566
+ return self.call_tool("/v1/tools/research/newsfeed", {"social_media_url": social_media_url, **kwargs})
567
+
568
+ async def aperson_newsfeed(self, social_media_url: str, **kwargs: Any) -> Any:
569
+ """Get recent social posts from a person. Async."""
570
+ return await self.acall_tool("/v1/tools/research/newsfeed", {"social_media_url": social_media_url, **kwargs})
571
+
572
+ def person_interests(self, **kwargs: Any) -> Any:
573
+ """Analyze a person's interests from their digital footprint. Blocking."""
574
+ return self.call_tool("/v1/tools/research/interests", kwargs)
575
+
576
+ async def aperson_interests(self, **kwargs: Any) -> Any:
577
+ """Analyze a person's interests from their digital footprint. Async."""
578
+ return await self.acall_tool("/v1/tools/research/interests", kwargs)
579
+
580
+ def person_interactions(self, social_media_url: str, **kwargs: Any) -> Any:
581
+ """Get a person's social interactions. Blocking."""
582
+ return self.call_tool("/v1/tools/research/interactions", {"social_media_url": social_media_url, **kwargs})
583
+
584
+ async def aperson_interactions(self, social_media_url: str, **kwargs: Any) -> Any:
585
+ """Get a person's social interactions. Async."""
586
+ return await self.acall_tool("/v1/tools/research/interactions", {"social_media_url": social_media_url, **kwargs})
587
+
588
+ def commerce_search(self, query: str, *, limit: int = 10, **kwargs: Any) -> Any:
589
+ """Search for products. Blocking."""
590
+ return self.call_tool("/v1/tools/commerce/search", {"query": query, "limit": limit, **kwargs})
591
+
592
+ async def acommerce_search(self, query: str, *, limit: int = 10, **kwargs: Any) -> Any:
593
+ """Search for products. Async."""
594
+ return await self.acall_tool("/v1/tools/commerce/search", {"query": query, "limit": limit, **kwargs})
595
+
596
+ def commerce_buy(self, product_url: str, shipping_address: dict[str, Any], **kwargs: Any) -> Any:
597
+ """Purchase a product. Blocking."""
598
+ return self.call_tool("/v1/tools/commerce/buy", {"product_url": product_url, "shipping_address": shipping_address, **kwargs})
599
+
600
+ async def acommerce_buy(self, product_url: str, shipping_address: dict[str, Any], **kwargs: Any) -> Any:
601
+ """Purchase a product. Async."""
602
+ return await self.acall_tool("/v1/tools/commerce/buy", {"product_url": product_url, "shipping_address": shipping_address, **kwargs})
603
+
604
+ def build(self, product: dict[str, Any], **kwargs: Any) -> Any:
605
+ """Build a website. Blocking."""
606
+ return self.call_tool("/v1/tools/build", {"product": product, **kwargs})
607
+
608
+ async def abuild(self, product: dict[str, Any], **kwargs: Any) -> Any:
609
+ """Build a website. Async."""
610
+ return await self.acall_tool("/v1/tools/build", {"product": product, **kwargs})
611
+
407
612
  # ------------------------------------------------------------------
408
613
  # Balance
409
614
  # ------------------------------------------------------------------
@@ -425,6 +630,9 @@ class OneShotClient:
425
630
  client: httpx.AsyncClient,
426
631
  request_id: str,
427
632
  timeout_sec: int,
633
+ *,
634
+ wait_for_phones: bool = False,
635
+ phone_timeout_sec: int = 360,
428
636
  ) -> Any:
429
637
  start = time.monotonic()
430
638
  retries = 0
@@ -446,7 +654,20 @@ class OneShotClient:
446
654
 
447
655
  if job.get("status") == "completed":
448
656
  self._log("Job completed")
449
- return job.get("result", job)
657
+ result = job.get("result", job)
658
+ # Optional second phase: keep polling for Apollo phone-reveal
659
+ # callbacks. Only fires when the caller explicitly opts in
660
+ # AND the result still has phones_pending=true (set by the
661
+ # worker when Apollo enrichment is awaiting an async webhook).
662
+ # Backwards-compat: existing callers see no change.
663
+ if wait_for_phones and self._is_phones_pending(result):
664
+ return await self._poll_for_phones(
665
+ client,
666
+ request_id,
667
+ phone_timeout_sec,
668
+ initial_result=result,
669
+ )
670
+ return result
450
671
 
451
672
  if job.get("status") == "failed":
452
673
  raise JobError(
@@ -468,3 +689,74 @@ class OneShotClient:
468
689
 
469
690
  elapsed_ms = int((time.monotonic() - start) * 1000)
470
691
  raise JobTimeoutError(request_id, elapsed_ms)
692
+
693
+ @staticmethod
694
+ def _is_phones_pending(result: Any) -> bool:
695
+ """Detects whether a result is awaiting Apollo's async phone-reveal
696
+ webhook. Tolerates two shapes: the unwrapped result ``{phones_pending}``
697
+ and the deep_research_person wrapper ``{result: {phones_pending}}``.
698
+
699
+ Mirrors the TS SDK's _isPhonesPending — kept in lockstep so the worker
700
+ contract works for both SDKs.
701
+ """
702
+ if not isinstance(result, dict):
703
+ return False
704
+ if result.get("phones_pending") is True:
705
+ return True
706
+ inner = result.get("result")
707
+ if isinstance(inner, dict) and inner.get("phones_pending") is True:
708
+ return True
709
+ return False
710
+
711
+ async def _poll_for_phones(
712
+ self,
713
+ client: httpx.AsyncClient,
714
+ request_id: str,
715
+ timeout_sec: int,
716
+ *,
717
+ initial_result: Any = None,
718
+ ) -> Any:
719
+ """Slow poll loop (5s cadence) for Apollo phone-reveal callbacks.
720
+
721
+ The webhook handler in soul-hunt-api UPDATEs jobs.result_data with
722
+ phones once Apollo POSTs them. We poll the GET endpoint until phones
723
+ arrive (phones_pending flips false) or ``timeout_sec`` expires. On
724
+ timeout we return the last snapshot — phones_pending will still be
725
+ True so the consumer knows phones never arrived.
726
+
727
+ Soft-fails on transient HTTP errors and returns the most recent
728
+ successful snapshot to avoid losing the sync result we already have.
729
+ """
730
+ deadline = time.monotonic() + timeout_sec
731
+ interval = 5.0
732
+ last_result: Any = initial_result
733
+
734
+ while time.monotonic() < deadline:
735
+ try:
736
+ resp = await client.get(
737
+ f"{self.base_url}/v1/requests/{request_id}",
738
+ headers=self._headers(),
739
+ )
740
+ if not resp.is_success:
741
+ if last_result is not None:
742
+ return last_result
743
+ raise ToolError(
744
+ "Failed to check job status",
745
+ resp.status_code,
746
+ resp.text,
747
+ )
748
+ job = resp.json()
749
+ last_result = job.get("result", job)
750
+ if not self._is_phones_pending(last_result):
751
+ return last_result
752
+ except (OneShotError, JobError):
753
+ raise
754
+ except Exception:
755
+ if last_result is not None:
756
+ return last_result
757
+ raise
758
+ await asyncio.sleep(interval)
759
+
760
+ # Timeout — return the last snapshot. phones_pending is still True
761
+ # so the caller knows phones never arrived.
762
+ return last_result
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "oneshot-python"
3
- version = "0.8.2"
3
+ version = "0.9.0"
4
4
  description = "Core Python SDK for the OneShot API — HTTP client with x402 payment handling"
5
5
  readme = {text = "Core Python SDK for the OneShot API", content-type = "text/plain"}
6
6
  license = "MIT"
@@ -0,0 +1,347 @@
1
+ """Tests for the Apollo phone-reveal polling extension on OneShotClient.
2
+
3
+ Apollo's phone numbers arrive asynchronously via a webhook minutes after the
4
+ initial enrichment. The worker writes ``result.phones_pending = True`` so SDK
5
+ clients can opt into a longer poll (``wait_for_phones=True``) until phones
6
+ land. These tests verify:
7
+
8
+ 1. The static ``_is_phones_pending`` shape detector
9
+ 2. The ``_poll_for_phones`` async loop with mocked httpx responses
10
+ 3. The ``_poll_job → _poll_for_phones`` decision branch (opt-in vs default)
11
+
12
+ Run with:
13
+ cd packages/oneshot-python && uv run pytest tests/test_phones_pending.py -v
14
+ """
15
+
16
+ import asyncio
17
+ from typing import Any
18
+ from unittest.mock import MagicMock
19
+
20
+ import pytest
21
+
22
+ from oneshot.client import OneShotClient
23
+
24
+ # Deterministic test key (same one used by test_x402.py — never use with funds)
25
+ TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
26
+ TEST_BASE_URL = "http://test.local"
27
+
28
+
29
+ def make_client() -> OneShotClient:
30
+ """Real OneShotClient with a stable test key. No network calls in tests
31
+ because we mock the httpx layer entirely."""
32
+ return OneShotClient(
33
+ private_key=TEST_PRIVATE_KEY,
34
+ base_url=TEST_BASE_URL,
35
+ )
36
+
37
+
38
+ # ── Mock httpx.AsyncClient ──────────────────────────────────────────────────
39
+
40
+
41
+ def _make_mock_response(status_code: int = 200, json_data: Any = None, text: str = "") -> MagicMock:
42
+ resp = MagicMock()
43
+ resp.is_success = 200 <= status_code < 300
44
+ resp.status_code = status_code
45
+ resp.json = MagicMock(return_value=json_data or {})
46
+ resp.text = text
47
+ return resp
48
+
49
+
50
+ class _MockHttpxClient:
51
+ """Scriptable httpx.AsyncClient stand-in. Tests load `responses` with the
52
+ sequence of mock responses (or exceptions) and the get() method pops them.
53
+ """
54
+
55
+ def __init__(self, responses):
56
+ self.responses = list(responses)
57
+ self.calls = []
58
+
59
+ async def get(self, url, headers=None):
60
+ self.calls.append({"url": url, "headers": headers})
61
+ if not self.responses:
62
+ raise RuntimeError(f"Unexpected get to {url} (queue empty, {len(self.calls)} total calls)")
63
+ next_resp = self.responses.pop(0)
64
+ if isinstance(next_resp, Exception):
65
+ raise next_resp
66
+ return next_resp
67
+
68
+
69
+ # ═══════════════════════════════════════════════════════════════════════════
70
+ # _is_phones_pending shape detection
71
+ # ═══════════════════════════════════════════════════════════════════════════
72
+
73
+
74
+ class TestIsPhonesPending:
75
+ """Locks the contract between the worker's result_data shape and the SDK.
76
+ If either side changes, these tests fail."""
77
+
78
+ detect = staticmethod(OneShotClient._is_phones_pending)
79
+
80
+ def test_false_for_non_dict(self):
81
+ assert self.detect(None) is False
82
+ assert self.detect("string") is False
83
+ assert self.detect(42) is False
84
+ assert self.detect([]) is False
85
+ assert self.detect(()) is False
86
+
87
+ def test_false_for_empty_dict(self):
88
+ assert self.detect({}) is False
89
+
90
+ def test_true_for_top_level_phones_pending(self):
91
+ # Shape used by enrich/profile, find_email — flatter result
92
+ assert self.detect({"full_name": "Jane", "phones_pending": True}) is True
93
+
94
+ def test_true_for_nested_result_phones_pending(self):
95
+ # Shape used by deep_research_person — wraps the person dict
96
+ assert self.detect({
97
+ "status": "completed",
98
+ "result": {"full_name": "Jane", "phones_pending": True},
99
+ }) is True
100
+
101
+ def test_false_when_explicitly_false(self):
102
+ assert self.detect({"phones_pending": False}) is False
103
+ assert self.detect({"result": {"phones_pending": False}}) is False
104
+
105
+ def test_false_when_missing(self):
106
+ # Non-Apollo providers never set phones_pending
107
+ assert self.detect({"full_name": "Jane", "phones": ["+1"]}) is False
108
+ assert self.detect({"result": {"full_name": "Jane"}}) is False
109
+
110
+ def test_strict_equality_only(self):
111
+ # Defensive: only literal True triggers waiting. A 'pending' string
112
+ # or a 1 won't accidentally trap a client in an infinite poll.
113
+ assert self.detect({"phones_pending": "pending"}) is False
114
+ assert self.detect({"phones_pending": 1}) is False
115
+ assert self.detect({"phones_pending": "true"}) is False
116
+
117
+ def test_does_not_scan_deeper_than_result(self):
118
+ # result.enrichment.phones_pending is intentionally NOT detected
119
+ assert self.detect({"result": {"enrichment": {"phones_pending": True}}}) is False
120
+
121
+
122
+ # ═══════════════════════════════════════════════════════════════════════════
123
+ # _poll_for_phones polling loop
124
+ # ═══════════════════════════════════════════════════════════════════════════
125
+
126
+
127
+ class TestPollForPhones:
128
+ """Exercises _poll_for_phones directly with a scripted mock httpx client.
129
+ Patches asyncio.sleep so multi-poll tests don't take 15+ seconds of real
130
+ wall time."""
131
+
132
+ @pytest.mark.asyncio
133
+ async def test_returns_immediately_when_phones_pending_false(self):
134
+ client = _MockHttpxClient([
135
+ _make_mock_response(200, {
136
+ "request_id": "req-1",
137
+ "status": "completed",
138
+ "result": {"result": {"phones": ["+15551234567"], "phones_pending": False}},
139
+ }),
140
+ ])
141
+ sdk = make_client()
142
+ out = await sdk._poll_for_phones(client, "req-1", 60)
143
+ assert out["result"]["phones"] == ["+15551234567"]
144
+ assert out["result"]["phones_pending"] is False
145
+ assert len(client.calls) == 1
146
+
147
+ @pytest.mark.asyncio
148
+ async def test_polls_until_phones_arrive(self, monkeypatch):
149
+ async def fast_sleep(_):
150
+ return None
151
+ monkeypatch.setattr(asyncio, "sleep", fast_sleep)
152
+
153
+ client = _MockHttpxClient([
154
+ _make_mock_response(200, {"result": {"result": {"phones_pending": True}}}),
155
+ _make_mock_response(200, {"result": {"result": {"phones_pending": True}}}),
156
+ _make_mock_response(200, {
157
+ "result": {"result": {"phones": ["+15559999999"], "phones_pending": False}},
158
+ }),
159
+ ])
160
+ sdk = make_client()
161
+ out = await sdk._poll_for_phones(client, "req-1", 60)
162
+ assert len(client.calls) == 3
163
+ assert out["result"]["phones"] == ["+15559999999"]
164
+
165
+ @pytest.mark.asyncio
166
+ async def test_returns_initial_result_on_first_fetch_failure(self, monkeypatch):
167
+ async def fast_sleep(_):
168
+ return None
169
+ monkeypatch.setattr(asyncio, "sleep", fast_sleep)
170
+
171
+ client = _MockHttpxClient([RuntimeError("network down")])
172
+ sdk = make_client()
173
+ initial = {"result": {"full_name": "Jane", "phones_pending": True, "apollo_person_id": "abc"}}
174
+ out = await sdk._poll_for_phones(client, "req-1", 60, initial_result=initial)
175
+ # Soft-failed on first fetch, returned the snapshot we passed in
176
+ assert out is initial
177
+ assert len(client.calls) == 1
178
+
179
+ @pytest.mark.asyncio
180
+ async def test_returns_last_snapshot_on_transient_failure_after_success(self, monkeypatch):
181
+ async def fast_sleep(_):
182
+ return None
183
+ monkeypatch.setattr(asyncio, "sleep", fast_sleep)
184
+
185
+ client = _MockHttpxClient([
186
+ _make_mock_response(200, {
187
+ "result": {"result": {"full_name": "Jane", "phones_pending": True}},
188
+ }),
189
+ RuntimeError("transient blip"),
190
+ ])
191
+ sdk = make_client()
192
+ out = await sdk._poll_for_phones(client, "req-1", 60)
193
+ assert out["result"]["full_name"] == "Jane"
194
+ assert out["result"]["phones_pending"] is True
195
+
196
+ @pytest.mark.asyncio
197
+ async def test_returns_last_snapshot_on_http_500_after_success(self, monkeypatch):
198
+ async def fast_sleep(_):
199
+ return None
200
+ monkeypatch.setattr(asyncio, "sleep", fast_sleep)
201
+
202
+ client = _MockHttpxClient([
203
+ _make_mock_response(200, {
204
+ "result": {"result": {"phones_pending": True, "full_name": "Jane"}},
205
+ }),
206
+ _make_mock_response(500, text="internal error"),
207
+ ])
208
+ sdk = make_client()
209
+ out = await sdk._poll_for_phones(client, "req-1", 60)
210
+ assert out["result"]["full_name"] == "Jane"
211
+ assert out["result"]["phones_pending"] is True
212
+
213
+ @pytest.mark.asyncio
214
+ async def test_raises_when_first_fetch_fails_and_no_initial(self, monkeypatch):
215
+ async def fast_sleep(_):
216
+ return None
217
+ monkeypatch.setattr(asyncio, "sleep", fast_sleep)
218
+
219
+ client = _MockHttpxClient([RuntimeError("network down")])
220
+ sdk = make_client()
221
+ with pytest.raises(RuntimeError, match="network down"):
222
+ await sdk._poll_for_phones(client, "req-1", 60)
223
+
224
+ @pytest.mark.asyncio
225
+ async def test_returns_last_snapshot_on_timeout(self, monkeypatch):
226
+ # Tight 0.05s timeout — first poll completes, then deadline fires
227
+ async def fast_sleep(_):
228
+ return None
229
+ monkeypatch.setattr(asyncio, "sleep", fast_sleep)
230
+
231
+ client = _MockHttpxClient([
232
+ _make_mock_response(200, {
233
+ "result": {"result": {"phones_pending": True, "full_name": "Jane"}},
234
+ }),
235
+ ] * 10) # plenty of responses, but timeout will end the loop
236
+ sdk = make_client()
237
+ out = await sdk._poll_for_phones(client, "req-1", 0.05)
238
+ assert out["result"]["phones_pending"] is True
239
+ assert out["result"]["full_name"] == "Jane"
240
+
241
+ @pytest.mark.asyncio
242
+ async def test_hits_correct_url_and_headers(self):
243
+ client = _MockHttpxClient([
244
+ _make_mock_response(200, {
245
+ "result": {"result": {"phones_pending": False, "phones": ["+15550000000"]}},
246
+ }),
247
+ ])
248
+ sdk = make_client()
249
+ await sdk._poll_for_phones(client, "req-1", 60)
250
+ assert client.calls[0]["url"] == f"{TEST_BASE_URL}/v1/requests/req-1"
251
+ # Real client sets X-Agent-ID to the wallet address derived from the test key
252
+ assert "X-Agent-ID" in client.calls[0]["headers"]
253
+
254
+
255
+ # ═══════════════════════════════════════════════════════════════════════════
256
+ # _poll_job → _poll_for_phones decision branch
257
+ # ═══════════════════════════════════════════════════════════════════════════
258
+
259
+
260
+ class TestPollJobWaitForPhonesDecision:
261
+ """Verifies that _poll_job opts into _poll_for_phones only when both
262
+ wait_for_phones=True AND result.phones_pending=True. All other combinations
263
+ must return at status=completed without extension polling."""
264
+
265
+ @pytest.mark.asyncio
266
+ async def test_default_returns_at_completed_even_if_phones_pending(self, monkeypatch):
267
+ async def fast_sleep(_):
268
+ return None
269
+ monkeypatch.setattr(asyncio, "sleep", fast_sleep)
270
+
271
+ client = _MockHttpxClient([
272
+ _make_mock_response(200, {
273
+ "request_id": "req-1",
274
+ "status": "completed",
275
+ "result": {"result": {"full_name": "Jane", "phones_pending": True}},
276
+ }),
277
+ ])
278
+ sdk = make_client()
279
+ # wait_for_phones default = False
280
+ out = await sdk._poll_job(client, "req-1", 60)
281
+ # Returned even though phones_pending=true — caller didn't opt in
282
+ assert out["result"]["phones_pending"] is True
283
+ assert out["result"]["full_name"] == "Jane"
284
+ assert len(client.calls) == 1 # no extension polling
285
+
286
+ @pytest.mark.asyncio
287
+ async def test_wait_for_phones_triggers_extension_polling(self, monkeypatch):
288
+ async def fast_sleep(_):
289
+ return None
290
+ monkeypatch.setattr(asyncio, "sleep", fast_sleep)
291
+
292
+ client = _MockHttpxClient([
293
+ # First poll: status=completed but phones_pending=true
294
+ _make_mock_response(200, {
295
+ "request_id": "req-1",
296
+ "status": "completed",
297
+ "result": {"result": {"phones_pending": True, "apollo_person_id": "abc"}},
298
+ }),
299
+ # _poll_for_phones first poll: phones arrived
300
+ _make_mock_response(200, {
301
+ "request_id": "req-1",
302
+ "status": "completed",
303
+ "result": {"result": {"phones": ["+15551234567"], "phones_pending": False}},
304
+ }),
305
+ ])
306
+ sdk = make_client()
307
+ out = await sdk._poll_job(client, "req-1", 60, wait_for_phones=True)
308
+ assert out["result"]["phones"] == ["+15551234567"]
309
+ assert out["result"]["phones_pending"] is False
310
+ assert len(client.calls) == 2
311
+
312
+ @pytest.mark.asyncio
313
+ async def test_wait_for_phones_no_op_when_phones_already_present(self, monkeypatch):
314
+ async def fast_sleep(_):
315
+ return None
316
+ monkeypatch.setattr(asyncio, "sleep", fast_sleep)
317
+
318
+ client = _MockHttpxClient([
319
+ _make_mock_response(200, {
320
+ "request_id": "req-1",
321
+ "status": "completed",
322
+ "result": {"result": {"phones": ["+15551234567"], "phones_pending": False}},
323
+ }),
324
+ ])
325
+ sdk = make_client()
326
+ out = await sdk._poll_job(client, "req-1", 60, wait_for_phones=True)
327
+ # No extension polls — _poll_for_phones never invoked
328
+ assert out["result"]["phones"] == ["+15551234567"]
329
+ assert len(client.calls) == 1
330
+
331
+ @pytest.mark.asyncio
332
+ async def test_wait_for_phones_no_op_for_non_apollo_results(self, monkeypatch):
333
+ async def fast_sleep(_):
334
+ return None
335
+ monkeypatch.setattr(asyncio, "sleep", fast_sleep)
336
+
337
+ client = _MockHttpxClient([
338
+ _make_mock_response(200, {
339
+ "request_id": "req-1",
340
+ "status": "completed",
341
+ "result": {"result": {"full_name": "Jane", "provider": "prospeo"}},
342
+ }),
343
+ ])
344
+ sdk = make_client()
345
+ out = await sdk._poll_job(client, "req-1", 60, wait_for_phones=True)
346
+ assert out["result"]["full_name"] == "Jane"
347
+ assert len(client.calls) == 1
@@ -549,7 +549,7 @@ wheels = [
549
549
 
550
550
  [[package]]
551
551
  name = "oneshot-python"
552
- version = "0.7.0"
552
+ version = "0.8.3"
553
553
  source = { editable = "." }
554
554
  dependencies = [
555
555
  { name = "eth-account" },
File without changes