oneshot-python 0.26.0__tar.gz → 0.28.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.
Files changed (33) hide show
  1. {oneshot_python-0.26.0 → oneshot_python-0.28.0}/PKG-INFO +1 -1
  2. {oneshot_python-0.26.0 → oneshot_python-0.28.0}/README.md +25 -0
  3. {oneshot_python-0.26.0 → oneshot_python-0.28.0}/oneshot/__init__.py +2 -0
  4. {oneshot_python-0.26.0 → oneshot_python-0.28.0}/oneshot/_errors.py +17 -0
  5. {oneshot_python-0.26.0 → oneshot_python-0.28.0}/oneshot/client.py +17 -3
  6. {oneshot_python-0.26.0 → oneshot_python-0.28.0}/pyproject.toml +1 -1
  7. oneshot_python-0.28.0/tests/conftest.py +21 -0
  8. {oneshot_python-0.26.0 → oneshot_python-0.28.0}/tests/test_linkedin.py +46 -0
  9. {oneshot_python-0.26.0 → oneshot_python-0.28.0}/tests/test_payment_rejection.py +22 -0
  10. {oneshot_python-0.26.0 → oneshot_python-0.28.0}/uv.lock +1 -1
  11. {oneshot_python-0.26.0 → oneshot_python-0.28.0}/.gitignore +0 -0
  12. {oneshot_python-0.26.0 → oneshot_python-0.28.0}/oneshot/_types.py +0 -0
  13. {oneshot_python-0.26.0 → oneshot_python-0.28.0}/oneshot/physical_mail.py +0 -0
  14. {oneshot_python-0.26.0 → oneshot_python-0.28.0}/oneshot/x402.py +0 -0
  15. {oneshot_python-0.26.0 → oneshot_python-0.28.0}/tests/__init__.py +0 -0
  16. {oneshot_python-0.26.0 → oneshot_python-0.28.0}/tests/test_access_token.py +0 -0
  17. {oneshot_python-0.26.0 → oneshot_python-0.28.0}/tests/test_balance.py +0 -0
  18. {oneshot_python-0.26.0 → oneshot_python-0.28.0}/tests/test_browser.py +0 -0
  19. {oneshot_python-0.26.0 → oneshot_python-0.28.0}/tests/test_budgets.py +0 -0
  20. {oneshot_python-0.26.0 → oneshot_python-0.28.0}/tests/test_charge_amount.py +0 -0
  21. {oneshot_python-0.26.0 → oneshot_python-0.28.0}/tests/test_compute.py +0 -0
  22. {oneshot_python-0.26.0 → oneshot_python-0.28.0}/tests/test_domains.py +0 -0
  23. {oneshot_python-0.26.0 → oneshot_python-0.28.0}/tests/test_email_payload.py +0 -0
  24. {oneshot_python-0.26.0 → oneshot_python-0.28.0}/tests/test_emergency_error.py +0 -0
  25. {oneshot_python-0.26.0 → oneshot_python-0.28.0}/tests/test_max_cost_header.py +0 -0
  26. {oneshot_python-0.26.0 → oneshot_python-0.28.0}/tests/test_phones_pending.py +0 -0
  27. {oneshot_python-0.26.0 → oneshot_python-0.28.0}/tests/test_physical_mail.py +0 -0
  28. {oneshot_python-0.26.0 → oneshot_python-0.28.0}/tests/test_poll_backoff.py +0 -0
  29. {oneshot_python-0.26.0 → oneshot_python-0.28.0}/tests/test_request_id.py +0 -0
  30. {oneshot_python-0.26.0 → oneshot_python-0.28.0}/tests/test_tag_receipt_value.py +0 -0
  31. {oneshot_python-0.26.0 → oneshot_python-0.28.0}/tests/test_top_up_credits.py +0 -0
  32. {oneshot_python-0.26.0 → oneshot_python-0.28.0}/tests/test_wait_false.py +0 -0
  33. {oneshot_python-0.26.0 → oneshot_python-0.28.0}/tests/test_x402.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: oneshot-python
3
- Version: 0.26.0
3
+ Version: 0.28.0
4
4
  Summary: Core Python SDK for the OneShot API — HTTP client with x402 payment handling — 43 tools
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
@@ -239,3 +239,28 @@ Use `create_browser_profile(name)`, then `start_browser_profile_setup(profile_id
239
239
  Finish inspects cookie metadata in a fresh browser before navigation, without returning values. Later `browser(profile_id=..., task=...)` calls reuse the saved profile; verify site authentication with a read before relying on it. Setup expires after 15 minutes, with abandoned-session cleanup by the lifecycle sweep; the profile is retained for reconnection. Never log private live URLs or put credentials/codes in task prompts.
240
240
 
241
241
  Alternatively, `create_browser_profile(name, cookies=[...])` or `storage_state={...}` imports cookies and localStorage. The legacy browser `secrets` field is rejected, and `max_steps` must be an integer from 25 through 100. Failed `JobError`s expose `code` and `partial_result`. Browser `cost` is provider cost; `billed_cost` is the reconciled customer charge, or null while pending.
242
+
243
+ ### Missing LinkedIn connections
244
+
245
+ Both `reconnect_linkedin_account()` and `areconnect_linkedin_account()` raise
246
+ `LinkedInConnectRequiredError` when the API confirms that the saved connection
247
+ no longer exists. It remains a `ToolError` subclass and exposes
248
+ `code == "connect_required"`, `next_action == "connect"`, `status_code == 409`,
249
+ and the original `response_body`.
250
+
251
+ ```python
252
+ from oneshot import LinkedInConnectRequiredError
253
+
254
+ try:
255
+ intent = client.reconnect_linkedin_account(account_id)
256
+ except LinkedInConnectRequiredError:
257
+ # Show “Connect LinkedIn” and ask the human to choose grants again.
258
+ # After confirmation: client.linkedin_connect(["read"])
259
+ pass
260
+ ```
261
+
262
+ Account reads stop offering reconnect after confirming upstream deletion.
263
+ Missing accounts are excluded from the active list; `include_revoked=True`
264
+ lets you inspect their `deleted_upstream` status and `reconnect_required=False`.
265
+ Authentication failures and timeouts remain ordinary errors and do not instruct
266
+ the consumer to create a new connection.
@@ -13,6 +13,7 @@ from oneshot._errors import (
13
13
  OneShotError,
14
14
  PaymentError,
15
15
  ToolError,
16
+ LinkedInConnectRequiredError,
16
17
  ValidationError,
17
18
  )
18
19
  from oneshot._types import (
@@ -43,6 +44,7 @@ __all__ = [
43
44
  "OneShotClient",
44
45
  "OneShotError",
45
46
  "ToolError",
47
+ "LinkedInConnectRequiredError",
46
48
  "PaymentError",
47
49
  "BudgetExceededError",
48
50
  "BudgetSyncError",
@@ -14,6 +14,17 @@ class ToolError(OneShotError):
14
14
  self.response_body = response_body
15
15
 
16
16
 
17
+ class LinkedInConnectRequiredError(ToolError):
18
+ """The saved connection is gone; ask the human to authorize a fresh connect."""
19
+
20
+ code = "connect_required"
21
+ next_action = "connect"
22
+
23
+ def __init__(self, response_body: str) -> None:
24
+ super().__init__("This LinkedIn connection no longer exists. Start a new connection.",
25
+ 409, response_body)
26
+
27
+
17
28
  class PaymentError(OneShotError):
18
29
  """The facilitator rejected the payment signed for this request.
19
30
 
@@ -33,12 +44,18 @@ class PaymentError(OneShotError):
33
44
  expected: dict | None = None,
34
45
  received: dict | None = None,
35
46
  quote_id: str | None = None,
47
+ payment_attempt_id: str | None = None,
48
+ stage: str | None = None,
49
+ diagnostics: dict | None = None,
36
50
  ) -> None:
37
51
  super().__init__(message)
38
52
  self.reason = reason
39
53
  self.expected = expected or {}
40
54
  self.received = received or {}
41
55
  self.quote_id = quote_id
56
+ self.payment_attempt_id = payment_attempt_id
57
+ self.stage = stage
58
+ self.diagnostics = diagnostics
42
59
 
43
60
 
44
61
  class JobError(OneShotError):
@@ -31,6 +31,7 @@ from oneshot._errors import (
31
31
  OneShotError,
32
32
  PaymentError,
33
33
  ToolError,
34
+ LinkedInConnectRequiredError,
34
35
  ValidationError,
35
36
  )
36
37
  from oneshot._types import (
@@ -56,7 +57,7 @@ try:
56
57
 
57
58
  SDK_VERSION = _pkg_version("oneshot-python")
58
59
  except Exception: # pragma: no cover - editable/source runs without dist metadata
59
- SDK_VERSION = "0.26.0"
60
+ SDK_VERSION = "0.28.0"
60
61
 
61
62
  # ---------------------------------------------------------------------------
62
63
  # Environment configuration
@@ -206,13 +207,13 @@ def _parse_payment_rejection(resp: Any) -> Optional[PaymentError]:
206
207
  payment is refused. Returns None for any other response so the caller can
207
208
  fall back to ``ToolError``.
208
209
  """
209
- if resp.status_code != 402:
210
+ if resp.status_code not in (402, 500):
210
211
  return None
211
212
  try:
212
213
  data = resp.json()
213
214
  except Exception: # noqa: BLE001 - non-JSON body
214
215
  return None
215
- if not isinstance(data, dict) or data.get("error") != "payment_verification_failed":
216
+ if not isinstance(data, dict) or data.get("error") not in ("payment_verification_failed", "payment_processing_error"):
216
217
  return None
217
218
 
218
219
  reason = data.get("reason") or "unknown"
@@ -230,6 +231,9 @@ def _parse_payment_rejection(resp: Any) -> Optional[PaymentError]:
230
231
  if data.get("message"):
231
232
  message += f" ({data['message']})"
232
233
 
234
+ if data.get("payment_attempt_id"):
235
+ message += f" [payment attempt {data['payment_attempt_id']}]"
236
+
233
237
  return PaymentError(
234
238
  message,
235
239
  reason,
@@ -241,6 +245,9 @@ def _parse_payment_rejection(resp: Any) -> Optional[PaymentError]:
241
245
  },
242
246
  received={"amount": received.get("amount")},
243
247
  quote_id=data.get("quote_id"),
248
+ payment_attempt_id=data.get("payment_attempt_id"),
249
+ stage=data.get("stage"),
250
+ diagnostics=data.get("diagnostics"),
244
251
  )
245
252
 
246
253
 
@@ -989,6 +996,13 @@ class OneShotClient:
989
996
  async with httpx.AsyncClient(timeout=httpx.Timeout(150.0 if endpoint.startswith("/v1/tools/browser/profiles") else 30.0)) as client:
990
997
  resp = await client.post(url, headers=self._read_headers(scope), json=payload or {})
991
998
  if not resp.is_success:
999
+ if endpoint.startswith("/v1/tools/linkedin/") and resp.status_code == 409:
1000
+ try:
1001
+ body = resp.json()
1002
+ except ValueError:
1003
+ body = None
1004
+ if isinstance(body, dict) and body.get("error") == "connect_required":
1005
+ raise LinkedInConnectRequiredError(resp.text)
992
1006
  raise ToolError(f"POST {endpoint} failed", resp.status_code, resp.text)
993
1007
  return resp.json()
994
1008
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "oneshot-python"
3
- version = "0.26.0"
3
+ version = "0.28.0"
4
4
  description = "Core Python SDK for the OneShot API — HTTP client with x402 payment handling — 43 tools"
5
5
  readme = {text = "Core Python SDK for the OneShot API", content-type = "text/plain"}
6
6
  license = "MIT"
@@ -0,0 +1,21 @@
1
+ """Give blocking SDK tests an event loop independent of pytest-asyncio cleanup."""
2
+ import asyncio
3
+ import inspect
4
+
5
+ import pytest
6
+
7
+
8
+ @pytest.fixture(autouse=True)
9
+ def blocking_test_event_loop(request):
10
+ if inspect.iscoroutinefunction(request.function):
11
+ yield
12
+ return
13
+ # Blocking SDK methods call run_until_complete. Async tests may have closed
14
+ # or cleared the thread's previous loop; no test should depend on its order.
15
+ loop = asyncio.new_event_loop()
16
+ asyncio.set_event_loop(loop)
17
+ try:
18
+ yield
19
+ finally:
20
+ loop.close()
21
+ asyncio.set_event_loop(None)
@@ -70,3 +70,49 @@ def test_idempotency_key_header_is_set_on_paid_requests():
70
70
  client = OneShotClient(TEST_PRIVATE_KEY)
71
71
  assert client._headers(idempotency_key="k1")["Idempotency-Key"] == "k1"
72
72
  assert "Idempotency-Key" not in client._headers()
73
+
74
+
75
+ @pytest.mark.parametrize("asynchronous", [False, True])
76
+ @pytest.mark.parametrize("status,body,typed", [
77
+ (409, {"error": "connect_required", "details": {"next_action": "connect"}}, True),
78
+ (409, {"error": "grant_widening_not_allowed"}, False),
79
+ (502, {"error": "linkedin_link_unavailable"}, False),
80
+ (401, {"error": "connect_required"}, False),
81
+ (409, ["connect_required"], False),
82
+ (409, "not JSON", False),
83
+ ])
84
+ def test_reconnect_recovery_error_over_http(monkeypatch, asynchronous, status, body, typed):
85
+ import asyncio
86
+ import httpx
87
+ from oneshot import LinkedInConnectRequiredError, ToolError
88
+
89
+ requests = []
90
+ def handler(request):
91
+ requests.append(request)
92
+ if isinstance(body, str):
93
+ return httpx.Response(status, text=body)
94
+ return httpx.Response(status, json=body)
95
+
96
+ original = httpx.AsyncClient
97
+ monkeypatch.setattr("oneshot.client.httpx.AsyncClient", lambda **kw: original(transport=httpx.MockTransport(handler), **kw))
98
+ client = OneShotClient(TEST_PRIVATE_KEY)
99
+ loop = asyncio.new_event_loop()
100
+ monkeypatch.setattr(asyncio, "get_event_loop", lambda: loop)
101
+ try:
102
+ with pytest.raises(ToolError) as caught:
103
+ if asynchronous:
104
+ loop.run_until_complete(client.areconnect_linkedin_account("account/id"))
105
+ else:
106
+ client.reconnect_linkedin_account("account/id")
107
+ error = caught.value
108
+ assert isinstance(error, LinkedInConnectRequiredError) is typed
109
+ assert error.status_code == status
110
+ if typed:
111
+ assert error.code == "connect_required"
112
+ assert error.next_action == "connect"
113
+ assert '"next_action":"connect"' in error.response_body.replace(" ", "")
114
+ assert len(requests) == 1 # No automatic reconnect or new grant.
115
+ assert requests[0].url.raw_path.endswith(b"/account%2Fid/reconnect")
116
+ assert "x-agent-proof" in requests[0].headers
117
+ finally:
118
+ loop.close()
@@ -86,3 +86,25 @@ class TestParsePaymentRejection:
86
86
  err = _parse_payment_rejection(_Resp(402, REJECTION))
87
87
  assert isinstance(err, OneShotError)
88
88
  assert isinstance(err, Exported)
89
+
90
+
91
+ def test_diagnostics_and_attempt_correlation():
92
+ diagnostics = {"status": "complete", "findings": [{"code": "unknown"}]}
93
+ error = _parse_payment_rejection(_Resp(402, {
94
+ **REJECTION, "payment_attempt_id": "attempt-123", "stage": "verification", "diagnostics": diagnostics,
95
+ }))
96
+ assert error.payment_attempt_id == "attempt-123"
97
+ assert "attempt-123" in str(error)
98
+ assert error.stage == "verification"
99
+ assert error.diagnostics == diagnostics
100
+ assert PaymentError("old", "unknown").diagnostics is None
101
+
102
+
103
+ def test_transport_and_settlement_correlation():
104
+ error = _parse_payment_rejection(_Resp(500, {
105
+ "error": "payment_processing_error", "payment_attempt_id": "transport-123", "stage": "verification_transport",
106
+ }))
107
+ assert error.payment_attempt_id == "transport-123"
108
+ assert error.stage == "verification_transport"
109
+ error = _parse_payment_rejection(_Resp(402, {**REJECTION, "stage": "settlement"}))
110
+ assert error.stage == "settlement"
@@ -549,7 +549,7 @@ wheels = [
549
549
 
550
550
  [[package]]
551
551
  name = "oneshot-python"
552
- version = "0.24.0"
552
+ version = "0.27.0"
553
553
  source = { editable = "." }
554
554
  dependencies = [
555
555
  { name = "eth-account" },