oneshot-python 0.9.0__tar.gz → 0.9.2__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.
- {oneshot_python-0.9.0 → oneshot_python-0.9.2}/.gitignore +3 -0
- {oneshot_python-0.9.0 → oneshot_python-0.9.2}/PKG-INFO +1 -1
- {oneshot_python-0.9.0 → oneshot_python-0.9.2}/oneshot/client.py +6 -1
- {oneshot_python-0.9.0 → oneshot_python-0.9.2}/pyproject.toml +1 -1
- oneshot_python-0.9.2/tests/test_request_id.py +181 -0
- {oneshot_python-0.9.0 → oneshot_python-0.9.2}/uv.lock +1 -1
- {oneshot_python-0.9.0 → oneshot_python-0.9.2}/README.md +0 -0
- {oneshot_python-0.9.0 → oneshot_python-0.9.2}/oneshot/__init__.py +0 -0
- {oneshot_python-0.9.0 → oneshot_python-0.9.2}/oneshot/_errors.py +0 -0
- {oneshot_python-0.9.0 → oneshot_python-0.9.2}/oneshot/x402.py +0 -0
- {oneshot_python-0.9.0 → oneshot_python-0.9.2}/tests/__init__.py +0 -0
- {oneshot_python-0.9.0 → oneshot_python-0.9.2}/tests/test_balance.py +0 -0
- {oneshot_python-0.9.0 → oneshot_python-0.9.2}/tests/test_phones_pending.py +0 -0
- {oneshot_python-0.9.0 → oneshot_python-0.9.2}/tests/test_x402.py +0 -0
|
@@ -654,7 +654,10 @@ class OneShotClient:
|
|
|
654
654
|
|
|
655
655
|
if job.get("status") == "completed":
|
|
656
656
|
self._log("Job completed")
|
|
657
|
-
result = job.get("result"
|
|
657
|
+
result = job.get("result") or job
|
|
658
|
+
# Propagate request_id into the result so callers always have it
|
|
659
|
+
if isinstance(result, dict) and "request_id" not in result and job.get("request_id"):
|
|
660
|
+
result["request_id"] = job["request_id"]
|
|
658
661
|
# Optional second phase: keep polling for Apollo phone-reveal
|
|
659
662
|
# callbacks. Only fires when the caller explicitly opts in
|
|
660
663
|
# AND the result still has phones_pending=true (set by the
|
|
@@ -747,6 +750,8 @@ class OneShotClient:
|
|
|
747
750
|
)
|
|
748
751
|
job = resp.json()
|
|
749
752
|
last_result = job.get("result", job)
|
|
753
|
+
if isinstance(last_result, dict) and "request_id" not in last_result and job.get("request_id"):
|
|
754
|
+
last_result["request_id"] = job["request_id"]
|
|
750
755
|
if not self._is_phones_pending(last_result):
|
|
751
756
|
return last_result
|
|
752
757
|
except (OneShotError, JobError):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "oneshot-python"
|
|
3
|
-
version = "0.9.
|
|
3
|
+
version = "0.9.2"
|
|
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,181 @@
|
|
|
1
|
+
"""Tests for request_id propagation in OneShotClient._poll_job.
|
|
2
|
+
|
|
3
|
+
Verifies that `request_id` from the job envelope is injected into the
|
|
4
|
+
final result dict when the result itself doesn't already contain it.
|
|
5
|
+
Mirrors the TypeScript SDK's tests in tests/unit/sdk-request-id.test.ts.
|
|
6
|
+
|
|
7
|
+
Run with:
|
|
8
|
+
cd packages/oneshot-python && uv run pytest tests/test_request_id.py -v
|
|
9
|
+
# or
|
|
10
|
+
cd packages/oneshot-python && python -m pytest tests/test_request_id.py -v
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
from typing import Any
|
|
15
|
+
from unittest.mock import MagicMock
|
|
16
|
+
|
|
17
|
+
import pytest
|
|
18
|
+
|
|
19
|
+
from oneshot.client import OneShotClient
|
|
20
|
+
|
|
21
|
+
TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
|
|
22
|
+
TEST_BASE_URL = "http://test.local"
|
|
23
|
+
TEST_REQUEST_ID = "req_test_abc123"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def make_client() -> OneShotClient:
|
|
27
|
+
return OneShotClient(
|
|
28
|
+
private_key=TEST_PRIVATE_KEY,
|
|
29
|
+
base_url=TEST_BASE_URL,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ── Mock httpx.AsyncClient ──────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _make_mock_response(status_code: int = 200, json_data: Any = None) -> MagicMock:
|
|
37
|
+
resp = MagicMock()
|
|
38
|
+
resp.is_success = 200 <= status_code < 300
|
|
39
|
+
resp.status_code = status_code
|
|
40
|
+
resp.json = MagicMock(return_value=json_data or {})
|
|
41
|
+
resp.text = ""
|
|
42
|
+
return resp
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _MockHttpxClient:
|
|
46
|
+
def __init__(self, responses):
|
|
47
|
+
self.responses = list(responses)
|
|
48
|
+
self.calls = []
|
|
49
|
+
|
|
50
|
+
async def get(self, url, headers=None):
|
|
51
|
+
self.calls.append({"url": url, "headers": headers})
|
|
52
|
+
if not self.responses:
|
|
53
|
+
raise RuntimeError(f"Unexpected get to {url}")
|
|
54
|
+
next_resp = self.responses.pop(0)
|
|
55
|
+
if isinstance(next_resp, Exception):
|
|
56
|
+
raise next_resp
|
|
57
|
+
return next_resp
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
61
|
+
# _poll_job request_id injection
|
|
62
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TestRequestIdPropagation:
|
|
66
|
+
"""Verifies that _poll_job injects request_id from the job envelope
|
|
67
|
+
into the result dict."""
|
|
68
|
+
|
|
69
|
+
def test_injects_request_id_when_missing(self):
|
|
70
|
+
"""Result without request_id gets it from the job envelope."""
|
|
71
|
+
client = make_client()
|
|
72
|
+
mock_http = _MockHttpxClient([
|
|
73
|
+
_make_mock_response(200, {
|
|
74
|
+
"request_id": TEST_REQUEST_ID,
|
|
75
|
+
"status": "completed",
|
|
76
|
+
"result": {"url": "https://example.com", "markdown": "# Hello"},
|
|
77
|
+
}),
|
|
78
|
+
])
|
|
79
|
+
|
|
80
|
+
result = asyncio.get_event_loop().run_until_complete(
|
|
81
|
+
client._poll_job(mock_http, TEST_REQUEST_ID, timeout_sec=5)
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
assert result["request_id"] == TEST_REQUEST_ID
|
|
85
|
+
assert result["url"] == "https://example.com"
|
|
86
|
+
assert result["markdown"] == "# Hello"
|
|
87
|
+
|
|
88
|
+
def test_does_not_overwrite_existing_request_id(self):
|
|
89
|
+
"""Result that already has request_id keeps its own value."""
|
|
90
|
+
client = make_client()
|
|
91
|
+
mock_http = _MockHttpxClient([
|
|
92
|
+
_make_mock_response(200, {
|
|
93
|
+
"request_id": TEST_REQUEST_ID,
|
|
94
|
+
"status": "completed",
|
|
95
|
+
"result": {
|
|
96
|
+
"request_id": "req_original_keep",
|
|
97
|
+
"url": "https://example.com",
|
|
98
|
+
},
|
|
99
|
+
}),
|
|
100
|
+
])
|
|
101
|
+
|
|
102
|
+
result = asyncio.get_event_loop().run_until_complete(
|
|
103
|
+
client._poll_job(mock_http, TEST_REQUEST_ID, timeout_sec=5)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
assert result["request_id"] == "req_original_keep"
|
|
107
|
+
|
|
108
|
+
def test_handles_null_result(self):
|
|
109
|
+
"""When result is null, falls back to job envelope (which has request_id)."""
|
|
110
|
+
client = make_client()
|
|
111
|
+
mock_http = _MockHttpxClient([
|
|
112
|
+
_make_mock_response(200, {
|
|
113
|
+
"request_id": TEST_REQUEST_ID,
|
|
114
|
+
"status": "completed",
|
|
115
|
+
"result": None,
|
|
116
|
+
}),
|
|
117
|
+
])
|
|
118
|
+
|
|
119
|
+
result = asyncio.get_event_loop().run_until_complete(
|
|
120
|
+
client._poll_job(mock_http, TEST_REQUEST_ID, timeout_sec=5)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Falls back to the job dict which has request_id
|
|
124
|
+
assert result["request_id"] == TEST_REQUEST_ID
|
|
125
|
+
assert result["status"] == "completed"
|
|
126
|
+
|
|
127
|
+
def test_handles_non_dict_result(self):
|
|
128
|
+
"""Non-dict result (e.g. string) should not crash."""
|
|
129
|
+
client = make_client()
|
|
130
|
+
mock_http = _MockHttpxClient([
|
|
131
|
+
_make_mock_response(200, {
|
|
132
|
+
"request_id": TEST_REQUEST_ID,
|
|
133
|
+
"status": "completed",
|
|
134
|
+
"result": "plain string result",
|
|
135
|
+
}),
|
|
136
|
+
])
|
|
137
|
+
|
|
138
|
+
result = asyncio.get_event_loop().run_until_complete(
|
|
139
|
+
client._poll_job(mock_http, TEST_REQUEST_ID, timeout_sec=5)
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# String result — can't inject request_id, returned as-is
|
|
143
|
+
assert result == "plain string result"
|
|
144
|
+
|
|
145
|
+
def test_polls_until_completed(self):
|
|
146
|
+
"""Polls through processing states until completed, then injects request_id."""
|
|
147
|
+
client = make_client()
|
|
148
|
+
mock_http = _MockHttpxClient([
|
|
149
|
+
_make_mock_response(200, {"request_id": TEST_REQUEST_ID, "status": "processing"}),
|
|
150
|
+
_make_mock_response(200, {"request_id": TEST_REQUEST_ID, "status": "processing"}),
|
|
151
|
+
_make_mock_response(200, {
|
|
152
|
+
"request_id": TEST_REQUEST_ID,
|
|
153
|
+
"status": "completed",
|
|
154
|
+
"result": {"data": "final"},
|
|
155
|
+
}),
|
|
156
|
+
])
|
|
157
|
+
|
|
158
|
+
result = asyncio.get_event_loop().run_until_complete(
|
|
159
|
+
client._poll_job(mock_http, TEST_REQUEST_ID, timeout_sec=30)
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
assert len(mock_http.calls) == 3
|
|
163
|
+
assert result["request_id"] == TEST_REQUEST_ID
|
|
164
|
+
assert result["data"] == "final"
|
|
165
|
+
|
|
166
|
+
def test_no_request_id_in_job_envelope(self):
|
|
167
|
+
"""If the job envelope has no request_id, result is returned as-is."""
|
|
168
|
+
client = make_client()
|
|
169
|
+
mock_http = _MockHttpxClient([
|
|
170
|
+
_make_mock_response(200, {
|
|
171
|
+
"status": "completed",
|
|
172
|
+
"result": {"data": "no_rid"},
|
|
173
|
+
}),
|
|
174
|
+
])
|
|
175
|
+
|
|
176
|
+
result = asyncio.get_event_loop().run_until_complete(
|
|
177
|
+
client._poll_job(mock_http, TEST_REQUEST_ID, timeout_sec=5)
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
assert "request_id" not in result
|
|
181
|
+
assert result["data"] == "no_rid"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|