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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oneshot-python
3
- Version: 0.9.2
3
+ Version: 0.10.1
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
@@ -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(self, to: str, subject: str, body: str, *, from_domain: Optional[str] = None, **kwargs: Any) -> Any:
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
- 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)
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(self, to: str, subject: str, body: str, *, from_domain: Optional[str] = None, **kwargs: Any) -> Any:
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
- 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)
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.9.2"
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