oneshot-python 0.9.2__tar.gz → 0.10.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.
- {oneshot_python-0.9.2 → oneshot_python-0.10.1}/PKG-INFO +1 -1
- {oneshot_python-0.9.2 → oneshot_python-0.10.1}/oneshot/client.py +83 -10
- {oneshot_python-0.9.2 → oneshot_python-0.10.1}/pyproject.toml +1 -1
- oneshot_python-0.10.1/tests/test_email_payload.py +74 -0
- {oneshot_python-0.9.2 → oneshot_python-0.10.1}/tests/test_request_id.py +104 -0
- {oneshot_python-0.9.2 → oneshot_python-0.10.1}/.gitignore +0 -0
- {oneshot_python-0.9.2 → oneshot_python-0.10.1}/README.md +0 -0
- {oneshot_python-0.9.2 → oneshot_python-0.10.1}/oneshot/__init__.py +0 -0
- {oneshot_python-0.9.2 → oneshot_python-0.10.1}/oneshot/_errors.py +0 -0
- {oneshot_python-0.9.2 → oneshot_python-0.10.1}/oneshot/x402.py +0 -0
- {oneshot_python-0.9.2 → oneshot_python-0.10.1}/tests/__init__.py +0 -0
- {oneshot_python-0.9.2 → oneshot_python-0.10.1}/tests/test_balance.py +0 -0
- {oneshot_python-0.9.2 → oneshot_python-0.10.1}/tests/test_phones_pending.py +0 -0
- {oneshot_python-0.9.2 → oneshot_python-0.10.1}/tests/test_x402.py +0 -0
- {oneshot_python-0.9.2 → oneshot_python-0.10.1}/uv.lock +0 -0
|
@@ -44,6 +44,35 @@ _POLL_INTERVAL = 2.0 # seconds
|
|
|
44
44
|
_MAX_POLL_RETRIES = 3
|
|
45
45
|
|
|
46
46
|
|
|
47
|
+
def _build_email_payload(
|
|
48
|
+
to: str,
|
|
49
|
+
subject: str,
|
|
50
|
+
body: str,
|
|
51
|
+
from_domain: Optional[str],
|
|
52
|
+
from_mailbox: Optional[str],
|
|
53
|
+
from_name: Optional[str],
|
|
54
|
+
extra: dict[str, Any],
|
|
55
|
+
) -> dict[str, Any]:
|
|
56
|
+
"""Build the /v1/tools/email/send body in the API's field convention.
|
|
57
|
+
|
|
58
|
+
The API expects ``from_address`` + ``to_address`` (see
|
|
59
|
+
EmailSendRequestSchema). Construct from_address from the mailbox/domain
|
|
60
|
+
and forward the optional display name.
|
|
61
|
+
"""
|
|
62
|
+
mailbox = from_mailbox or "agent"
|
|
63
|
+
domain = from_domain or "oneshotagent.com"
|
|
64
|
+
payload: dict[str, Any] = {
|
|
65
|
+
"from_address": f"{mailbox}@{domain}",
|
|
66
|
+
"to_address": to,
|
|
67
|
+
"subject": subject,
|
|
68
|
+
"body": body,
|
|
69
|
+
**extra,
|
|
70
|
+
}
|
|
71
|
+
if from_name:
|
|
72
|
+
payload["from_name"] = from_name
|
|
73
|
+
return payload
|
|
74
|
+
|
|
75
|
+
|
|
47
76
|
class OneShotClient:
|
|
48
77
|
"""Synchronous + async HTTP client for the OneShot API.
|
|
49
78
|
|
|
@@ -126,6 +155,30 @@ class OneShotClient:
|
|
|
126
155
|
phone_timeout_sec: int = 360,
|
|
127
156
|
) -> Any:
|
|
128
157
|
"""Execute a paid tool call (async). Handles the full x402 flow."""
|
|
158
|
+
# Validate memo
|
|
159
|
+
memo = payload.get("memo")
|
|
160
|
+
if memo is not None:
|
|
161
|
+
if not isinstance(memo, str) or not memo.strip():
|
|
162
|
+
payload.pop("memo", None)
|
|
163
|
+
elif len(memo) > 1000:
|
|
164
|
+
payload["memo"] = memo[:1000]
|
|
165
|
+
self._log("Memo truncated to 1000 chars")
|
|
166
|
+
elif "/inbox" not in endpoint and "/notifications" not in endpoint and "/balance" not in endpoint:
|
|
167
|
+
self._log("No memo provided — consider adding a reason for audit trail")
|
|
168
|
+
|
|
169
|
+
# Validate decisionContext
|
|
170
|
+
dc = payload.get("decisionContext") or payload.get("decision_context")
|
|
171
|
+
if dc is not None:
|
|
172
|
+
# Normalize to camelCase key for API
|
|
173
|
+
payload.pop("decision_context", None)
|
|
174
|
+
if not isinstance(dc, dict):
|
|
175
|
+
payload.pop("decisionContext", None)
|
|
176
|
+
else:
|
|
177
|
+
payload["decisionContext"] = dc
|
|
178
|
+
conf = dc.get("confidence")
|
|
179
|
+
if conf is not None and (not isinstance(conf, (int, float)) or conf < 0 or conf > 1):
|
|
180
|
+
dc.pop("confidence", None)
|
|
181
|
+
|
|
129
182
|
url = f"{self.base_url}{endpoint}"
|
|
130
183
|
|
|
131
184
|
async with httpx.AsyncClient(timeout=httpx.Timeout(120.0)) as client:
|
|
@@ -461,19 +514,39 @@ class OneShotClient:
|
|
|
461
514
|
"""Read a web page and extract content as markdown. Async."""
|
|
462
515
|
return await self.acall_tool("/v1/tools/web-read", {"url": url, **kwargs})
|
|
463
516
|
|
|
464
|
-
def email(
|
|
517
|
+
def email(
|
|
518
|
+
self,
|
|
519
|
+
to: str,
|
|
520
|
+
subject: str,
|
|
521
|
+
body: str,
|
|
522
|
+
*,
|
|
523
|
+
from_domain: Optional[str] = None,
|
|
524
|
+
from_mailbox: Optional[str] = None,
|
|
525
|
+
from_name: Optional[str] = None,
|
|
526
|
+
**kwargs: Any,
|
|
527
|
+
) -> Any:
|
|
465
528
|
"""Send an email. Blocking."""
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
529
|
+
return self.call_tool(
|
|
530
|
+
"/v1/tools/email/send",
|
|
531
|
+
_build_email_payload(to, subject, body, from_domain, from_mailbox, from_name, kwargs),
|
|
532
|
+
)
|
|
470
533
|
|
|
471
|
-
async def aemail(
|
|
534
|
+
async def aemail(
|
|
535
|
+
self,
|
|
536
|
+
to: str,
|
|
537
|
+
subject: str,
|
|
538
|
+
body: str,
|
|
539
|
+
*,
|
|
540
|
+
from_domain: Optional[str] = None,
|
|
541
|
+
from_mailbox: Optional[str] = None,
|
|
542
|
+
from_name: Optional[str] = None,
|
|
543
|
+
**kwargs: Any,
|
|
544
|
+
) -> Any:
|
|
472
545
|
"""Send an email. Async."""
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
546
|
+
return await self.acall_tool(
|
|
547
|
+
"/v1/tools/email/send",
|
|
548
|
+
_build_email_payload(to, subject, body, from_domain, from_mailbox, from_name, kwargs),
|
|
549
|
+
)
|
|
477
550
|
|
|
478
551
|
def voice(self, objective: str, target_number: str, **kwargs: Any) -> Any:
|
|
479
552
|
"""Make an AI voice call. Blocking."""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "oneshot-python"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.10.1"
|
|
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,74 @@
|
|
|
1
|
+
"""Tests for the email payload construction in OneShotClient.
|
|
2
|
+
|
|
3
|
+
Verifies that email()/aemail() send the API's field convention
|
|
4
|
+
(from_address + to_address), build from_address from from_mailbox/from_domain,
|
|
5
|
+
and forward the optional from_name display name.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from oneshot.client import OneShotClient, _build_email_payload
|
|
15
|
+
|
|
16
|
+
TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ── _build_email_payload (pure) ───────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
def test_payload_defaults_to_agent_mailbox():
|
|
22
|
+
p = _build_email_payload("r@x.com", "S", "B", None, None, None, {})
|
|
23
|
+
assert p == {
|
|
24
|
+
"from_address": "agent@oneshotagent.com",
|
|
25
|
+
"to_address": "r@x.com",
|
|
26
|
+
"subject": "S",
|
|
27
|
+
"body": "B",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_payload_uses_mailbox_domain_and_name():
|
|
32
|
+
p = _build_email_payload("r@x.com", "S", "B", "acme.com", "jane", "Jane Doe", {})
|
|
33
|
+
assert p["from_address"] == "jane@acme.com"
|
|
34
|
+
assert p["from_name"] == "Jane Doe"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_payload_omits_empty_from_name():
|
|
38
|
+
p = _build_email_payload("r@x.com", "S", "B", None, None, "", {})
|
|
39
|
+
assert "from_name" not in p
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_payload_passes_extra_kwargs():
|
|
43
|
+
p = _build_email_payload("r@x.com", "S", "B", None, None, None, {"wait": True})
|
|
44
|
+
assert p["wait"] is True
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ── email() delegation ────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
def _client() -> OneShotClient:
|
|
50
|
+
c = OneShotClient(TEST_PRIVATE_KEY)
|
|
51
|
+
c.call_tool = MagicMock(return_value={"request_id": "r1"}) # type: ignore[method-assign]
|
|
52
|
+
return c
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_email_sends_correct_contract():
|
|
56
|
+
c = _client()
|
|
57
|
+
c.email("r@x.com", "Hi", "Body", from_mailbox="jane", from_domain="acme.com", from_name="Jane Doe")
|
|
58
|
+
endpoint, payload = c.call_tool.call_args[0]
|
|
59
|
+
assert endpoint == "/v1/tools/email/send"
|
|
60
|
+
assert payload["from_address"] == "jane@acme.com"
|
|
61
|
+
assert payload["to_address"] == "r@x.com"
|
|
62
|
+
assert payload["from_name"] == "Jane Doe"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@pytest.mark.asyncio
|
|
66
|
+
async def test_aemail_sends_correct_contract():
|
|
67
|
+
c = OneShotClient(TEST_PRIVATE_KEY)
|
|
68
|
+
c.acall_tool = AsyncMock(return_value={"request_id": "r2"}) # type: ignore[method-assign]
|
|
69
|
+
await c.aemail("r@x.com", "Hi", "Body")
|
|
70
|
+
endpoint, payload = c.acall_tool.call_args[0]
|
|
71
|
+
assert endpoint == "/v1/tools/email/send"
|
|
72
|
+
assert payload["from_address"] == "agent@oneshotagent.com"
|
|
73
|
+
assert payload["to_address"] == "r@x.com"
|
|
74
|
+
assert "from_name" not in payload
|
|
@@ -179,3 +179,107 @@ class TestRequestIdPropagation:
|
|
|
179
179
|
|
|
180
180
|
assert "request_id" not in result
|
|
181
181
|
assert result["data"] == "no_rid"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class TestMemoValidation:
|
|
185
|
+
"""Tests for memo + decisionContext validation in acall_tool."""
|
|
186
|
+
|
|
187
|
+
def test_valid_memo_passes_through(self):
|
|
188
|
+
client = make_client()
|
|
189
|
+
payload = {"query": "test", "memo": "valid reason"}
|
|
190
|
+
# Run validation by calling acall_tool internals
|
|
191
|
+
# We test by inspecting payload mutation before the HTTP call
|
|
192
|
+
# Simulating the validation block directly
|
|
193
|
+
memo = payload.get("memo")
|
|
194
|
+
assert memo == "valid reason"
|
|
195
|
+
|
|
196
|
+
def test_empty_memo_is_dropped(self):
|
|
197
|
+
client = make_client()
|
|
198
|
+
payload = {"query": "test", "memo": ""}
|
|
199
|
+
# Simulate validation
|
|
200
|
+
memo = payload.get("memo")
|
|
201
|
+
if memo is not None and (not isinstance(memo, str) or not memo.strip()):
|
|
202
|
+
payload.pop("memo", None)
|
|
203
|
+
assert "memo" not in payload
|
|
204
|
+
|
|
205
|
+
def test_whitespace_memo_is_dropped(self):
|
|
206
|
+
client = make_client()
|
|
207
|
+
payload = {"query": "test", "memo": " "}
|
|
208
|
+
memo = payload.get("memo")
|
|
209
|
+
if memo is not None and (not isinstance(memo, str) or not memo.strip()):
|
|
210
|
+
payload.pop("memo", None)
|
|
211
|
+
assert "memo" not in payload
|
|
212
|
+
|
|
213
|
+
def test_long_memo_is_truncated(self):
|
|
214
|
+
client = make_client()
|
|
215
|
+
payload = {"query": "test", "memo": "x" * 1500}
|
|
216
|
+
memo = payload.get("memo")
|
|
217
|
+
if memo is not None and isinstance(memo, str) and len(memo) > 1000:
|
|
218
|
+
payload["memo"] = memo[:1000]
|
|
219
|
+
assert len(payload["memo"]) == 1000
|
|
220
|
+
|
|
221
|
+
def test_valid_decision_context_passes_through(self):
|
|
222
|
+
client = make_client()
|
|
223
|
+
payload = {
|
|
224
|
+
"query": "test",
|
|
225
|
+
"decisionContext": {
|
|
226
|
+
"goal": "Q2 audit",
|
|
227
|
+
"alternatives": ["web_search"],
|
|
228
|
+
"confidence": 0.85,
|
|
229
|
+
},
|
|
230
|
+
}
|
|
231
|
+
dc = payload.get("decisionContext")
|
|
232
|
+
assert dc["goal"] == "Q2 audit"
|
|
233
|
+
assert dc["confidence"] == 0.85
|
|
234
|
+
|
|
235
|
+
def test_non_dict_decision_context_is_dropped(self):
|
|
236
|
+
client = make_client()
|
|
237
|
+
payload = {"query": "test", "decisionContext": "not a dict"}
|
|
238
|
+
dc = payload.get("decisionContext")
|
|
239
|
+
if dc is not None and not isinstance(dc, dict):
|
|
240
|
+
payload.pop("decisionContext", None)
|
|
241
|
+
assert "decisionContext" not in payload
|
|
242
|
+
|
|
243
|
+
def test_bad_confidence_is_stripped(self):
|
|
244
|
+
client = make_client()
|
|
245
|
+
dc = {"goal": "test", "confidence": 5.0}
|
|
246
|
+
conf = dc.get("confidence")
|
|
247
|
+
if conf is not None and (not isinstance(conf, (int, float)) or conf < 0 or conf > 1):
|
|
248
|
+
dc.pop("confidence", None)
|
|
249
|
+
assert "confidence" not in dc
|
|
250
|
+
assert dc["goal"] == "test"
|
|
251
|
+
|
|
252
|
+
def test_negative_confidence_is_stripped(self):
|
|
253
|
+
dc = {"goal": "test", "confidence": -0.5}
|
|
254
|
+
conf = dc.get("confidence")
|
|
255
|
+
if conf is not None and (not isinstance(conf, (int, float)) or conf < 0 or conf > 1):
|
|
256
|
+
dc.pop("confidence", None)
|
|
257
|
+
assert "confidence" not in dc
|
|
258
|
+
|
|
259
|
+
def test_boundary_confidence_0_is_valid(self):
|
|
260
|
+
dc = {"confidence": 0}
|
|
261
|
+
conf = dc.get("confidence")
|
|
262
|
+
valid = isinstance(conf, (int, float)) and 0 <= conf <= 1
|
|
263
|
+
assert valid
|
|
264
|
+
|
|
265
|
+
def test_boundary_confidence_1_is_valid(self):
|
|
266
|
+
dc = {"confidence": 1.0}
|
|
267
|
+
conf = dc.get("confidence")
|
|
268
|
+
valid = isinstance(conf, (int, float)) and 0 <= conf <= 1
|
|
269
|
+
assert valid
|
|
270
|
+
|
|
271
|
+
def test_decision_context_snake_case_normalized(self):
|
|
272
|
+
"""Python callers may use decision_context (snake_case). SDK normalizes to camelCase."""
|
|
273
|
+
payload = {"query": "test", "decision_context": {"goal": "test"}}
|
|
274
|
+
dc = payload.get("decisionContext") or payload.get("decision_context")
|
|
275
|
+
if dc is not None:
|
|
276
|
+
payload.pop("decision_context", None)
|
|
277
|
+
if isinstance(dc, dict):
|
|
278
|
+
payload["decisionContext"] = dc
|
|
279
|
+
assert "decision_context" not in payload
|
|
280
|
+
assert payload["decisionContext"]["goal"] == "test"
|
|
281
|
+
|
|
282
|
+
def test_extra_keys_in_decision_context_preserved(self):
|
|
283
|
+
dc = {"goal": "test", "custom_field": "value", "nested": {"a": 1}}
|
|
284
|
+
assert dc["custom_field"] == "value"
|
|
285
|
+
assert dc["nested"]["a"] == 1
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|