oneshot-python 0.20.2__tar.gz → 0.23.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 (28) hide show
  1. {oneshot_python-0.20.2 → oneshot_python-0.23.1}/PKG-INFO +2 -2
  2. {oneshot_python-0.20.2 → oneshot_python-0.23.1}/README.md +4 -0
  3. {oneshot_python-0.20.2 → oneshot_python-0.23.1}/oneshot/__init__.py +3 -0
  4. {oneshot_python-0.20.2 → oneshot_python-0.23.1}/oneshot/client.py +30 -1
  5. oneshot_python-0.23.1/oneshot/physical_mail.py +168 -0
  6. {oneshot_python-0.20.2 → oneshot_python-0.23.1}/pyproject.toml +2 -2
  7. oneshot_python-0.23.1/tests/test_physical_mail.py +43 -0
  8. {oneshot_python-0.20.2 → oneshot_python-0.23.1}/uv.lock +1 -1
  9. {oneshot_python-0.20.2 → oneshot_python-0.23.1}/.gitignore +0 -0
  10. {oneshot_python-0.20.2 → oneshot_python-0.23.1}/oneshot/_errors.py +0 -0
  11. {oneshot_python-0.20.2 → oneshot_python-0.23.1}/oneshot/_types.py +0 -0
  12. {oneshot_python-0.20.2 → oneshot_python-0.23.1}/oneshot/x402.py +0 -0
  13. {oneshot_python-0.20.2 → oneshot_python-0.23.1}/tests/__init__.py +0 -0
  14. {oneshot_python-0.20.2 → oneshot_python-0.23.1}/tests/test_balance.py +0 -0
  15. {oneshot_python-0.20.2 → oneshot_python-0.23.1}/tests/test_budgets.py +0 -0
  16. {oneshot_python-0.20.2 → oneshot_python-0.23.1}/tests/test_charge_amount.py +0 -0
  17. {oneshot_python-0.20.2 → oneshot_python-0.23.1}/tests/test_compute.py +0 -0
  18. {oneshot_python-0.20.2 → oneshot_python-0.23.1}/tests/test_domains.py +0 -0
  19. {oneshot_python-0.20.2 → oneshot_python-0.23.1}/tests/test_email_payload.py +0 -0
  20. {oneshot_python-0.20.2 → oneshot_python-0.23.1}/tests/test_emergency_error.py +0 -0
  21. {oneshot_python-0.20.2 → oneshot_python-0.23.1}/tests/test_max_cost_header.py +0 -0
  22. {oneshot_python-0.20.2 → oneshot_python-0.23.1}/tests/test_payment_rejection.py +0 -0
  23. {oneshot_python-0.20.2 → oneshot_python-0.23.1}/tests/test_phones_pending.py +0 -0
  24. {oneshot_python-0.20.2 → oneshot_python-0.23.1}/tests/test_poll_backoff.py +0 -0
  25. {oneshot_python-0.20.2 → oneshot_python-0.23.1}/tests/test_request_id.py +0 -0
  26. {oneshot_python-0.20.2 → oneshot_python-0.23.1}/tests/test_tag_receipt_value.py +0 -0
  27. {oneshot_python-0.20.2 → oneshot_python-0.23.1}/tests/test_wait_false.py +0 -0
  28. {oneshot_python-0.20.2 → oneshot_python-0.23.1}/tests/test_x402.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: oneshot-python
3
- Version: 0.20.2
4
- Summary: Core Python SDK for the OneShot API — HTTP client with x402 payment handling
3
+ Version: 0.23.1
4
+ Summary: Core Python SDK for the OneShot API — HTTP client with x402 payment handling — 35 tools
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
7
7
  Requires-Dist: eth-account>=0.13.0
@@ -201,3 +201,7 @@ Every method has an `a*` async mirror (`acompute`, `aget_compute_goal`, …).
201
201
  ## License
202
202
 
203
203
  MIT
204
+
205
+ ### Physical mail
206
+
207
+ `client.physical_mail` supports U.S. letters and 4×6 postcards through `upload_artwork`, `validate_address`, `preview`, `get_quote`, `approve`, `send`, `get_order`, `recover`, and `cancel`. Async variants prefix the method with `a`. Review each proof and price, then call `approve(..., approved=True)` explicitly. Persist `idempotency_key` before sending. `recover(key)` retrieves the original order without creating another charge. Postal delivery does not prove readership; paid receipts and fulfillment events remain separate. Lob credentials stay on OneShot's server.
@@ -1,5 +1,7 @@
1
1
  """oneshot-python — Core Python SDK for the OneShot API."""
2
2
 
3
+ from oneshot.physical_mail import PhysicalMail, PostalAddress, MailQuote, MailOrder
4
+
3
5
  from oneshot._errors import (
4
6
  BudgetExceededError,
5
7
  BudgetSyncError,
@@ -34,6 +36,7 @@ from oneshot.client import OneShotClient
34
36
  from oneshot.x402 import sign_payment_authorization
35
37
 
36
38
  __all__ = [
39
+ "PhysicalMail", "PostalAddress", "MailQuote", "MailOrder",
37
40
  "OneShotClient",
38
41
  "OneShotError",
39
42
  "ToolError",
@@ -51,7 +51,7 @@ try:
51
51
 
52
52
  SDK_VERSION = _pkg_version("oneshot-python")
53
53
  except Exception: # pragma: no cover - editable/source runs without dist metadata
54
- SDK_VERSION = "0.20.2"
54
+ SDK_VERSION = "0.23.1"
55
55
 
56
56
  # ---------------------------------------------------------------------------
57
57
  # Environment configuration
@@ -313,6 +313,11 @@ class OneShotClient:
313
313
  headers["X-Max-Cost-USDC"] = str(max_cost)
314
314
  return headers
315
315
 
316
+ @property
317
+ def physical_mail(self):
318
+ from oneshot.physical_mail import PhysicalMail
319
+ return PhysicalMail(self)
320
+
316
321
  def _read_headers(self) -> dict[str, str]:
317
322
  """Auth headers plus a signed EIP-712 read proof (x-agent-proof) for the
318
323
  free READ routes (inbox, sms inbox, notifications, balance, browser
@@ -1235,6 +1240,30 @@ class OneShotClient:
1235
1240
  """Enrich a company from domain, name, LinkedIn URL, or ticker. Async."""
1236
1241
  return await self.acall_tool("/v1/tools/enrich/company", kwargs)
1237
1242
 
1243
+ def local_search(self, **kwargs: Any) -> Any:
1244
+ """Discover local businesses (restaurants, contractors, practices) by category/keywords x location. Blocking."""
1245
+ return self.call_tool("/v1/tools/local/search", kwargs)
1246
+
1247
+ async def alocal_search(self, **kwargs: Any) -> Any:
1248
+ """Discover local businesses (restaurants, contractors, practices) by category/keywords x location. Async."""
1249
+ return await self.acall_tool("/v1/tools/local/search", kwargs)
1250
+
1251
+ def local_resolve(self, name: str, **kwargs: Any) -> Any:
1252
+ """Resolve a business name + one locating field (address/city/postal_code/phone) to its domain, phone, status. Blocking."""
1253
+ return self.call_tool("/v1/tools/local/resolve", {"name": name, **kwargs})
1254
+
1255
+ async def alocal_resolve(self, name: str, **kwargs: Any) -> Any:
1256
+ """Resolve a business name + one locating field (address/city/postal_code/phone) to its domain, phone, status. Async."""
1257
+ return await self.acall_tool("/v1/tools/local/resolve", {"name": name, **kwargs})
1258
+
1259
+ def gov_solicitations(self, naics: list[str], **kwargs: Any) -> Any:
1260
+ """Federal Sources Sought / Presolicitation notices (SAM.gov) by NAICS code with the contracting officer's contact. Result carries `data_as_of` (snapshot behind the rows). Blocking."""
1261
+ return self.call_tool("/v1/tools/gov/solicitations", {"naics": naics, **kwargs})
1262
+
1263
+ async def agov_solicitations(self, naics: list[str], **kwargs: Any) -> Any:
1264
+ """Federal Sources Sought / Presolicitation notices (SAM.gov) by NAICS code with the contracting officer's contact. Result carries `data_as_of` (snapshot behind the rows). Async."""
1265
+ return await self.acall_tool("/v1/tools/gov/solicitations", {"naics": naics, **kwargs})
1266
+
1238
1267
  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:
1239
1268
  """Find a person's email address. Blocking."""
1240
1269
  payload: dict[str, Any] = {"company_domain": company_domain, **kwargs}
@@ -0,0 +1,168 @@
1
+ """Physical mail: explicit approval, durable recovery, independent fulfillment."""
2
+ from __future__ import annotations
3
+ from typing import Any, Literal, TypedDict
4
+ import math
5
+ import httpx
6
+ from oneshot.x402 import sign_read_proof, parse_payment_required, sign_payment_authorization, encode_payment_header
7
+ from oneshot._errors import OneShotError
8
+
9
+ class _PostalAddressRequired(TypedDict):
10
+ name: str
11
+ address_line1: str
12
+ address_city: str
13
+ address_state: str
14
+ address_zip: str
15
+
16
+ class PostalAddress(_PostalAddressRequired, total=False):
17
+ address_line2: str
18
+ address_country: Literal['US']
19
+
20
+ class MailQuote(TypedDict):
21
+ quote_id: str
22
+ input_hash: str
23
+ status: Literal['rendering', 'ready']
24
+ total_usdc: str | None
25
+ expires_at: str
26
+ preview: dict[str, Any]
27
+ input: dict[str, Any]
28
+ service_fee_usdc: str | None
29
+ approval_id: str | None
30
+ delivery_proves_readership: Literal[False]
31
+
32
+ class MailOrder(TypedDict):
33
+ order_id: str
34
+ quote_id: str
35
+ receipt_id: str
36
+ order_status: str
37
+ payment_status: str
38
+ fulfillment_status: str
39
+ total_usdc: str
40
+ cancellation_requested: bool
41
+ cancellation_error: str | None
42
+ cancel_before: str | None
43
+ refunded_at: str | None
44
+ signed_receipt: Any
45
+ events: list[dict[str, Any]]
46
+ idempotency_key: str
47
+ delivery_proves_readership: Literal[False]
48
+
49
+ class PhysicalMail:
50
+ def __init__(self, client: Any):
51
+ self.client = client
52
+
53
+ async def _request(self, path: str, method: str = 'POST', body: Any = None, *, mime: str | None = None, key: str | None = None, max_cost: float | None = None) -> Any:
54
+ c = self.client
55
+ scope = 'read' if method == 'GET' else 'submit' if path == '/send' else 'write'
56
+ def headers() -> dict[str, str]:
57
+ h = c._headers(max_cost=max_cost)
58
+ h['x-agent-proof'] = sign_read_proof(c._private_key, c.address, scope=scope)
59
+ if key:
60
+ h['Idempotency-Key'] = key
61
+ if mime:
62
+ h['Content-Type'] = mime
63
+ return h
64
+ if path == '/send':
65
+ await c.aensure_budgets_synced()
66
+ async with httpx.AsyncClient(timeout=60.0) as transport:
67
+ kwargs = {'content': body} if mime else {'json': body}
68
+ url = f'{c.base_url}/v1/tools/physical-mail{path}'
69
+ response = await transport.request(method, url, headers=headers(), **kwargs)
70
+ if response.status_code == 402 and path == '/send':
71
+ parsed = parse_payment_required(response.headers.get('payment-required'))
72
+ accepted = parsed['accepted']
73
+ payment = response.json()['payment_request']
74
+ charge = str(int(accepted['amount']) / 1_000_000)
75
+ c._assert_within_budget(charge)
76
+ if max_cost is not None and float(charge) > max_cost:
77
+ raise OneShotError('Physical mail quote exceeds max_cost')
78
+ auth = sign_payment_authorization(private_key=c._private_key, from_address=c.address,
79
+ to_address=payment['recipient'], amount=charge, token_address=payment['token_address'],
80
+ chain_id=payment['chain_id'], network=accepted['network'], accepted=accepted,
81
+ resource=parsed.get('resource'), extensions=parsed.get('extensions'))
82
+ h = headers() # Proof nonces are single use, including the unpaid leg.
83
+ h['payment-signature'] = encode_payment_header(auth)
84
+ response = await transport.post(url, headers=h, json=body)
85
+ response.raise_for_status()
86
+ return response.json()
87
+
88
+ async def aupload_artwork(self, data: bytes, mime: str) -> dict[str, str]:
89
+ return await self._request('/assets', body=data, mime=mime)
90
+
91
+ async def avalidate_address(self, address: PostalAddress) -> dict[str, Any]:
92
+ return await self._request('/validate-address', body=address)
93
+
94
+ async def apreview(self, mail_input: dict[str, Any]) -> MailQuote:
95
+ return await self._request('/preview', body=mail_input)
96
+
97
+ async def aget_quote(self, quote_id: str) -> MailQuote:
98
+ from urllib.parse import quote
99
+ return await self._request(f'/quotes/{quote(quote_id, safe="")}', 'GET')
100
+
101
+ async def aapprove(self, *, quote_id: str, input_hash: str, total_usdc: str, approved: Literal[True]) -> dict[str, str]:
102
+ if approved is not True:
103
+ raise ValueError('Explicit mailpiece approval required')
104
+ return await self._request('/approve', body=dict(quote_id=quote_id, input_hash=input_hash, total_usdc=total_usdc, approved=True))
105
+
106
+ async def asend(self, *, quote_id: str, approval_id: str, idempotency_key: str, max_cost: float | None = None, memo: str | None = None, decision_context: dict[str, Any] | None = None) -> MailOrder:
107
+ if max_cost is not None and (not math.isfinite(max_cost) or max_cost <= 0):
108
+ raise ValueError('Physical mail max_cost must be finite and positive')
109
+ if not idempotency_key or not approval_id:
110
+ raise ValueError('Persist an idempotency key and explicitly approve the quote before sending')
111
+ body: dict[str, Any] = dict(quote_id=quote_id, approval_id=approval_id)
112
+ if memo is not None:
113
+ body['memo'] = memo
114
+ if decision_context is not None:
115
+ body['decisionContext'] = decision_context
116
+ try:
117
+ return await self._request('/send', body=body, key=idempotency_key, max_cost=max_cost)
118
+ except Exception as error:
119
+ error.idempotency_key = idempotency_key # type: ignore[attr-defined]
120
+ raise
121
+
122
+ async def aget_order(self, order_id: str) -> MailOrder:
123
+ from urllib.parse import quote
124
+ return await self._request(f'/orders/{quote(order_id, safe="")}', 'GET')
125
+
126
+ async def arecover(self, key: str) -> MailOrder:
127
+ from urllib.parse import quote
128
+ return await self._request(f'/orders/recover?key={quote(key, safe="")}', 'GET')
129
+
130
+ async def acancel(self, order_id: str) -> MailOrder:
131
+ from urllib.parse import quote
132
+ return await self._request(f'/orders/{quote(order_id, safe="")}/cancel', body={})
133
+
134
+ def upload_artwork(self, *args: Any, **kwargs: Any) -> Any:
135
+ import asyncio
136
+ return asyncio.get_event_loop().run_until_complete(self.aupload_artwork(*args, **kwargs))
137
+
138
+ def validate_address(self, *args: Any, **kwargs: Any) -> Any:
139
+ import asyncio
140
+ return asyncio.get_event_loop().run_until_complete(self.avalidate_address(*args, **kwargs))
141
+
142
+ def preview(self, *args: Any, **kwargs: Any) -> Any:
143
+ import asyncio
144
+ return asyncio.get_event_loop().run_until_complete(self.apreview(*args, **kwargs))
145
+
146
+ def get_quote(self, *args: Any, **kwargs: Any) -> Any:
147
+ import asyncio
148
+ return asyncio.get_event_loop().run_until_complete(self.aget_quote(*args, **kwargs))
149
+
150
+ def approve(self, *args: Any, **kwargs: Any) -> Any:
151
+ import asyncio
152
+ return asyncio.get_event_loop().run_until_complete(self.aapprove(*args, **kwargs))
153
+
154
+ def send(self, *args: Any, **kwargs: Any) -> Any:
155
+ import asyncio
156
+ return asyncio.get_event_loop().run_until_complete(self.asend(*args, **kwargs))
157
+
158
+ def get_order(self, *args: Any, **kwargs: Any) -> Any:
159
+ import asyncio
160
+ return asyncio.get_event_loop().run_until_complete(self.aget_order(*args, **kwargs))
161
+
162
+ def recover(self, *args: Any, **kwargs: Any) -> Any:
163
+ import asyncio
164
+ return asyncio.get_event_loop().run_until_complete(self.arecover(*args, **kwargs))
165
+
166
+ def cancel(self, *args: Any, **kwargs: Any) -> Any:
167
+ import asyncio
168
+ return asyncio.get_event_loop().run_until_complete(self.acancel(*args, **kwargs))
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "oneshot-python"
3
- version = "0.20.2"
4
- description = "Core Python SDK for the OneShot API — HTTP client with x402 payment handling"
3
+ version = "0.23.1"
4
+ description = "Core Python SDK for the OneShot API — HTTP client with x402 payment handling — 35 tools"
5
5
  readme = {text = "Core Python SDK for the OneShot API", content-type = "text/plain"}
6
6
  license = "MIT"
7
7
  requires-python = ">=3.10"
@@ -0,0 +1,43 @@
1
+ import pytest
2
+ from unittest.mock import AsyncMock, MagicMock
3
+ from oneshot.physical_mail import PhysicalMail
4
+
5
+ @pytest.mark.asyncio
6
+ async def test_preview_and_approval_do_not_send():
7
+ mail = PhysicalMail(MagicMock())
8
+ mail._request = AsyncMock(return_value={})
9
+ await mail.apreview({'artwork': {'kind': 'letter', 'file': 'asset'}})
10
+ assert mail._request.call_args.args[0] == '/preview'
11
+ with pytest.raises(ValueError):
12
+ await mail.aapprove(quote_id='q', input_hash='h', total_usdc='1.05', approved=False)
13
+ await mail.aapprove(quote_id='q', input_hash='h', total_usdc='1.05', approved=True)
14
+ assert mail._request.call_args.args[0] == '/approve'
15
+
16
+ @pytest.mark.asyncio
17
+ async def test_send_preserves_key_and_approval_and_recovery_does_not_send():
18
+ mail = PhysicalMail(MagicMock())
19
+ mail._request = AsyncMock(return_value={})
20
+ with pytest.raises(ValueError):
21
+ await mail.asend(quote_id='q', approval_id='a', idempotency_key='')
22
+ await mail.asend(quote_id='q', approval_id='a', idempotency_key='durable', max_cost=1.05)
23
+ assert mail._request.call_args.kwargs['key'] == 'durable'
24
+ assert mail._request.call_args.kwargs['body'] == {'quote_id': 'q', 'approval_id': 'a'}
25
+ await mail.arecover('a/b')
26
+ assert mail._request.call_args.args == ('/orders/recover?key=a%2Fb', 'GET')
27
+
28
+ @pytest.mark.asyncio
29
+ async def test_transport_error_carries_recovery_key():
30
+ mail = PhysicalMail(MagicMock())
31
+ mail._request = AsyncMock(side_effect=TimeoutError())
32
+ with pytest.raises(TimeoutError) as caught:
33
+ await mail.asend(quote_id='q', approval_id='a', idempotency_key='persisted')
34
+ assert caught.value.idempotency_key == 'persisted'
35
+
36
+ @pytest.mark.asyncio
37
+ @pytest.mark.parametrize('max_cost', [0, -1, float('nan'), float('inf')])
38
+ async def test_invalid_caps_never_submit(max_cost):
39
+ mail = PhysicalMail(MagicMock())
40
+ mail._request = AsyncMock()
41
+ with pytest.raises(ValueError, match='finite and positive'):
42
+ await mail.asend(quote_id='q', approval_id='a', idempotency_key='key', max_cost=max_cost)
43
+ mail._request.assert_not_called()
@@ -549,7 +549,7 @@ wheels = [
549
549
 
550
550
  [[package]]
551
551
  name = "oneshot-python"
552
- version = "0.14.0"
552
+ version = "0.23.0"
553
553
  source = { editable = "." }
554
554
  dependencies = [
555
555
  { name = "eth-account" },